diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
new file mode 100644
index 0000000..9d6cfcb
--- /dev/null
+++ b/.github/workflows/pr-validation.yml
@@ -0,0 +1,153 @@
+# Format, lint, backend stub, tests. Comments the PR with a status table.
+# Also callable from python-publish.yml so a release cannot skip the same gates.
+
+name: Validate
+
+on:
+ pull_request:
+ branches: [dev, master]
+ push:
+ branches: [dev, master]
+ workflow_call:
+
+concurrency:
+ group: pr-validation-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ LANG: C.UTF-8
+ LC_ALL: C.UTF-8
+ LANGUAGE: C
+ ORCSOME3_BUILD_JOBS: "2"
+
+jobs:
+ validate:
+ name: Validate
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install Python dependencies
+ run: |
+ python -m pip install -U pip
+ python -m pip install dbus-next typing_extensions "Cython>=3.0" mypy basedpyright ruff setuptools types-setuptools
+
+ - name: Check code formatting
+ id: formatting
+ run: make format-check
+
+ - name: Lint
+ id: lint
+ run: make lint
+
+ - name: Install native build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ git cmake autoconf automake libtool pkg-config meson ninja-build \
+ libx11-dev libxss-dev libxext-dev libxtst-dev curl xvfb
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - name: Cache native libraries
+ uses: actions/cache@v6
+ with:
+ path: |
+ ~/.cache/orcsome3/libs
+ orcsome3_built_libraries
+ key: native-${{ runner.os }}-${{ hashFiles('orcsome3/libs/build/**', 'orcsome3/common.py') }}
+ restore-keys: |
+ native-${{ runner.os }}-
+
+ - name: Build native backend
+ run: |
+ if [ -d orcsome3_built_libraries ]; then
+ make native-fast
+ else
+ make native
+ fi
+
+ - name: Validate backend stub
+ id: stubs
+ run: make stubs-check
+
+ - name: Run tests
+ id: tests
+ run: make test
+
+ - name: Comment PR status
+ if: always() && github.event_name == 'pull_request'
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const checks = {
+ formatting: '${{ steps.formatting.outcome }}' === 'success',
+ lint: '${{ steps.lint.outcome }}' === 'success',
+ stubs: '${{ steps.stubs.outcome }}' === 'success',
+ tests: '${{ steps.tests.outcome }}' === 'success',
+ };
+ const allPassed = Object.values(checks).every(v => v);
+ const status = allPassed ? '✅ All checks passed!' : '❌ Some checks failed';
+
+ let body = `## PR Validation Results\n\n${status}\n\n`;
+ body += '| Check | Status |\n|-------|--------|\n';
+ body += `| Code formatting (ruff format) | ${checks.formatting ? '✅' : '❌'} |\n`;
+ body += `| Lint (ruff, mypy, basedpyright, named-args, explicit-types) | ${checks.lint ? '✅' : '❌'} |\n`;
+ body += `| Backend stub (\`orcsome3_backend.pyi\`) | ${checks.stubs ? '✅' : '❌'} |\n`;
+ body += `| Tests (unittest) | ${checks.tests ? '✅' : '❌'} |\n`;
+
+ if (!checks.formatting) {
+ body += '\n\n**The code was not properly formatted.** Run:\n\n';
+ body += '```bash\nmake format\n```\n\nThen commit the changes.';
+ }
+ if (!checks.lint) {
+ body += '\n\n**Lint failed.** Run:\n\n';
+ body += '```bash\nmake lint\n```';
+ }
+ if (!checks.stubs) {
+ body += '\n\n**Backend stub is stale.** Run:\n\n';
+ body += '```bash\nmake stubs\n```\n\nThen commit `orcsome3_backend.pyi`. Needs a built `.so` (`make native` / `make native-fast`).';
+ }
+ if (!checks.tests) {
+ body += '\n\n**Tests failed.** Run:\n\n';
+ body += '```bash\nmake test\n```\n\nX11 tests skip without a display; CI installs Xvfb.';
+ }
+
+ const marker = '## PR Validation Results';
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+ const existing = comments.find(
+ (c) => c.user && c.user.type === 'Bot' && c.body && c.body.includes(marker)
+ );
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body,
+ });
+ }
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 7497794..e08e676 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -1,52 +1,122 @@
-# This workflow will upload a Python Package using Twine when a release is created
-# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
-
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
+# sdist + manylinux wheels → PyPI on a GitHub Release or Actions → Run workflow.
+# `uses:` cannot take ${{ matrix.* }}; pin each cibuildwheel version on its own job.
name: Upload Python Package
on:
release:
types: [published]
+ workflow_dispatch:
permissions:
contents: read
+env:
+ CIBW_SKIP: "*-musllinux_* *t-*"
+ CIBW_BEFORE_BUILD: "pip install 'Cython>=3.0'"
+ CIBW_ENVIRONMENT: "ORCSOME3_BUILD_JOBS=2"
+ CIBW_TEST_SKIP: "*"
+
jobs:
- deploy:
+ validate:
+ uses: ./.github/workflows/pr-validation.yml
+ permissions:
+ contents: read
+ wheels-cp39-plus:
+ needs: validate
runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Install build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ 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 --default-toolchain stable
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - uses: pypa/cibuildwheel@v4.2.0
+ env:
+ CIBW_BUILD: "cp3[9]* cp31*"
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: wheels-v4.2.0
+ path: wheelhouse/*.whl
+ wheels-cp38:
+ needs: validate
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - name: Set up Python
- uses: actions/setup-python@v3
- with:
- python-version: '3.x'
- - name: Install dependencies
- run: |
- sudo apt remove -y imagemagick imagemagick-6-common
- wget https://imagemagick.org/archive/ImageMagick.tar.gz
- mkdir -p ./ImageMagick7
- tar xvzf ImageMagick.tar.gz --directory ./ImageMagick7 --strip-components=1
- cd ImageMagick7
- ./configure
- make
- sudo make install
- sudo ldconfig /usr/local/lib
-
- sudo apt remove -y nginx libgd3
- sudo apt install -y libev-dev libx11-dev libxss-dev libxext-dev libgd-dev
- python -m pip install --upgrade pip
- pip install build
- - name: Build package
- run: |
- python -m build --sdist
- - name: Publish package
- uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
- with:
- user: __token__
- password: ${{ secrets.PYPI_API_TOKEN }}
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - name: Install build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ 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 --default-toolchain stable
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - uses: pypa/cibuildwheel@v3.4.1
+ env:
+ CIBW_BUILD: "cp38-*"
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: wheels-v3.4.1
+ path: wheelhouse/*.whl
+
+ build_sdist:
+ needs: validate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.x"
+
+ - name: Install dependencies
+ run: python -m pip install --upgrade pip build
+
+ - name: Build sdist
+ run: python -m build --sdist
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: sdist
+ path: dist/*.tar.gz
+
+ pypi-publish:
+ needs:
+ - wheels-cp39-plus
+ - wheels-cp38
+ - build_sdist
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ environment:
+ name: pypi
+ url: https://pypi.org/p/orcsome3
+ steps:
+ - uses: actions/download-artifact@v8
+ with:
+ path: dist
+ merge-multiple: true
+
+ - name: Publish package distributions to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: dist/
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 14e2687..0db910e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ __pycache__/
# Distribution / packaging
.Python
-build/
+/build/
develop-eggs/
dist/
downloads/
@@ -151,6 +151,9 @@ dmypy.json
# pytype static type analyzer
.pytype/
+# Ruff
+.ruff_cache/
+
# Cython debug symbols
cython_debug/
@@ -163,3 +166,47 @@ cython_debug/
.vscode/
typings/
+
+# orcsome3 native backend build artifacts
+orcsome3_built_libraries/
+orcsome3_backend*.so
+
+orcsome3/libs/cython_libs/*
+!orcsome3/libs/cython_libs/__init__.py
+!orcsome3/libs/cython_libs/orcsome3_backend.pyx
+
+# cairo
+!orcsome3/libs/cython_libs/cairo
+orcsome3/libs/cython_libs/cairo/*
+!orcsome3/libs/cython_libs/cairo/__init__.pxd
+!orcsome3/libs/cython_libs/cairo/cairo.pxd
+
+# imagemagick
+!orcsome3/libs/cython_libs/imagemagick
+orcsome3/libs/cython_libs/imagemagick/*
+!orcsome3/libs/cython_libs/imagemagick/__init__.pxd
+!orcsome3/libs/cython_libs/imagemagick/imagemagick.pxd
+
+# libev
+!orcsome3/libs/cython_libs/libev
+orcsome3/libs/cython_libs/libev/*
+!orcsome3/libs/cython_libs/libev/__init__.pxd
+!orcsome3/libs/cython_libs/libev/libev.pxd
+
+# libgd
+!orcsome3/libs/cython_libs/libgd
+orcsome3/libs/cython_libs/libgd/*
+!orcsome3/libs/cython_libs/libgd/__init__.pxd
+!orcsome3/libs/cython_libs/libgd/libgd.pxd
+
+# resvg
+!orcsome3/libs/cython_libs/resvg
+orcsome3/libs/cython_libs/resvg/*
+!orcsome3/libs/cython_libs/resvg/__init__.pxd
+!orcsome3/libs/cython_libs/resvg/resvg.pxd
+
+# xlib
+!orcsome3/libs/cython_libs/xlib
+orcsome3/libs/cython_libs/xlib/*
+!orcsome3/libs/cython_libs/xlib/__init__.pxd
+!orcsome3/libs/cython_libs/xlib/xlib.pxd
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..ddd6ce0
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,4 @@
+include orcsome3_backend.pyi
+include orcsome3/py.typed
+recursive-include orcsome3/icons *
+recursive-include orcsome3/libs/cython_libs *.pxd *.pyx
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b62c60a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,89 @@
+# Use project venv when present; override with: make PYTHON=/path/to/python lint
+VENV := $(CURDIR)/venv/bin/python
+PYTHON := $(if $(wildcard $(VENV)),$(VENV),python3)
+
+# English diagnostics from mypy/basedpyright
+export LANG := C.UTF-8
+export LC_ALL := C.UTF-8
+export LANGUAGE := C
+
+.PHONY: help format format-check lint stubs stubs-check install install-dev uninstall build native native-rebuild native-fast clean dev run test
+
+help:
+ @echo "format ruff format + isort"
+ @echo "format-check ruff format --check (CI)"
+ @echo "lint ruff + mypy + basedpyright + named-args + explicit-types"
+ @echo "stubs regenerate orcsome3_backend.pyi from the Cython backend"
+ @echo "stubs-check fail if orcsome3_backend.pyi is stale (CI)"
+ @echo "dev venv + deps + native backend (does not pip-install orcsome3)"
+ @echo "run python -m orcsome3 from this tree (needs: make dev)"
+ @echo "test unittest (X tests skip if DISPLAY cannot be opened)"
+ @echo "install pip install ."
+ @echo "install-dev pip install -e '.[dev]'"
+ @echo "uninstall pip uninstall orcsome3"
+ @echo "build sdist + wheel (needs: pip install build)"
+ @echo "native build Cython backend (static libs)"
+ @echo "native-rebuild wipe cached libs and rebuild native backend"
+ @echo "native-fast re-cythonize/link only (needs a prior native build)"
+ @echo "clean remove build artifacts and caches"
+
+format:
+ $(PYTHON) -m ruff check --fix .
+ $(PYTHON) -m ruff format .
+
+format-check:
+ $(PYTHON) -m ruff format --check .
+
+stubs:
+ $(PYTHON) tools/generate_backend_stub.py
+
+stubs-check:
+ $(PYTHON) tools/generate_backend_stub.py --check
+
+lint:
+ $(PYTHON) -m ruff check .
+ $(PYTHON) -m mypy orcsome3 tools tests
+ $(PYTHON) -m basedpyright orcsome3 setup.py orcsome3_backend.pyi tools tests
+ $(PYTHON) tools/check_named_args.py
+ $(PYTHON) tools/check_explicit_types.py
+
+test:
+ $(PYTHON) -m unittest discover -s tests -v
+
+dev:
+ @test -x "$(VENV)" || python3 -m venv "$(CURDIR)/venv"
+ $(VENV) -m pip install -q dbus-next typing_extensions "Cython>=3.0" mypy basedpyright ruff setuptools types-setuptools
+ @if [ -d orcsome3_built_libraries ]; then \
+ $(MAKE) native-fast PYTHON="$(VENV)"; \
+ else \
+ $(MAKE) native PYTHON="$(VENV)"; \
+ fi
+ @echo "Ready. Run: make run"
+
+run:
+ $(PYTHON) -m orcsome3 $(ARGS)
+
+install:
+ $(PYTHON) -m pip install .
+
+install-dev:
+ $(PYTHON) -m pip install -e ".[dev]"
+
+uninstall:
+ $(PYTHON) -m pip uninstall -y orcsome3
+
+build:
+ $(PYTHON) -m build
+
+native:
+ $(PYTHON) -m orcsome3.libs.build --build-dir .
+
+native-rebuild:
+ $(PYTHON) -m orcsome3.libs.build --build-dir . --force-rebuild
+
+native-fast:
+ $(PYTHON) -m orcsome3.libs.build --build-dir . --skip-build-external-libs
+
+clean:
+ rm -rf dist build .eggs *.egg-info orcsome3_built_libraries .mypy_cache .ruff_cache
+ rm -f orcsome3_backend*.so
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8e9dc7b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,296 @@
+# 🪟 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="Notification body",
+ 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: ``** — 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).
diff --git a/README.rst b/README.rst
deleted file mode 100644
index db09607..0000000
--- a/README.rst
+++ /dev/null
@@ -1,124 +0,0 @@
-Orcsome3 is a port from python2 to python3 and rework of `orcsome `_, which is a scripting extension for NETWM compliant window managers.
-
-Features
---------
-
-* Written on python3. It means very hackable.
-
-* Optimization, cpu and memory efficiency are top goals (cffi is used for xlib
- bindings).
-
-* Extensive use of python3 syntax to provide easy and expressive eDSL in
- configuration script.
-
-* Supports NETWM standards.
-
-* Very thin wrapper around X. You can use existing xlib background.
-
-
-Installation
-------------
-
-Before installing orcsome3 it is necessary to have the build dependencies installed:
-
-orcsome3 uses the following libraries:
-
- - libev: Full-featured and high-performance event loop
- - X11
- - Xss: X11 Screen Saver extension client library
- - Xext: Misc X Extension Library
- - gd: GD graphics library
- - MagickWand: C API for ImageMagick
-
-To install them:
-
-Debian/Ubuntu
-'''''''''''''
-::
-
- sudo apt install libev-dev libx11-dev libxss-dev libxext-dev libgd-dev
-
-It is necessary to install ImageMagick7 from source::
-
- sudo apt remove -y imagemagick imagemagick-6-common
- sudo apt build-dep -y imagemagick
- wget https://imagemagick.org/archive/ImageMagick.tar.gz
- mkdir -p ./ImageMagick7
- tar xvzf ImageMagick.tar.gz --directory ./ImageMagick7 --strip-components=1
- cd ImageMagick7
- ./configure
- make
- sudo make install
- sudo ldconfig /usr/local/lib
-
-Arch Linux
-''''''''''
-::
-
- sudo pacman -S libev libx11 libxss libxext imagemagick
-
-After installing the build dependencies, orcsome3 can be installed:
-'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
-
-From PyPI
-'''''''''
-::
-
- python3 -m pip install orcsome3
-
-
-From source
-'''''''''''
-
-::
-
- git clone --depth=1 git://github.com/ahsand97/orcsome3.git
- cd orcsome3
- python3 -m pip install .
-
-**orcsome3 installs an executable script "orcsome3" located in** ``~/.local/bin/orcsome3`` **at a user level
-or in** ``/usr/bin/orcsome3`` **if it was installed as root**
-
----------------------------------------------------
-
-Quick start
-'''''''''''
-
-Some of the functionalities offered are:
-
- - To bind global hot keys
- - To hide the title bar when a window is maximized
- - To change the icon of a window
-
-Edit ``~/.config/orcsome3/rc.py``:
-
-.. code-block:: python
-
- from orcsome3.orcsome import get_wm
- from orcsome3.orcsome.wm import WM
- from pathlib import Path
-
- wm: WM = get_wm()
-
- # Global hotkey
- @wm.on_key(keydef="Control + b")
- def on_pressed_hotkey() -> None:
- print("Control + b was pressed")
-
- # Change window icon
- @wm.on_manage(name="easyeffects", cls="easyeffects")
- def on_create_easyeffects() -> None:
- path_imagen: Path = Path("/my/other/icon/icon.svg")
- wm.event_window.set_window_icon(icon=path_imagen)
-
- # Hide title bar when a window is maximized
- @wm.on_property_change(properties=["_NET_WM_STATE"])
- def window_state_changed() -> None:
- if wm.event_window.maximized_horz and wm.event_window.maximized_vert:
- if wm.event_window.decorated:
- wm.set_window_state(window=wm.event_window, decorate=False)
- else:
- if not wm.event_window.decorated:
- wm.set_window_state(window=wm.event_window, decorate=True)
-
-And start ``orcsome3``. That's all.
diff --git a/orcsome3-stubs/orcsome/__init__.pyi b/orcsome3-stubs/orcsome/__init__.pyi
deleted file mode 100644
index b3d649d..0000000
--- a/orcsome3-stubs/orcsome/__init__.pyi
+++ /dev/null
@@ -1,5 +0,0 @@
-from .wm import ImmediateWM as ImmediateWM, WM as WM
-
-def get_wm() -> WM: ...
-def get_wm_immediate() -> ImmediateWM: ...
-def update_wm(new_wm: WM) -> None: ...
diff --git a/orcsome3-stubs/orcsome/__main__.pyi b/orcsome3-stubs/orcsome/__main__.pyi
deleted file mode 100644
index 31c5c44..0000000
--- a/orcsome3-stubs/orcsome/__main__.pyi
+++ /dev/null
@@ -1 +0,0 @@
-from orcsome3.orcsome.run import run as run
diff --git a/orcsome3-stubs/orcsome/actions.pyi b/orcsome3-stubs/orcsome/actions.pyi
deleted file mode 100644
index 52e94f6..0000000
--- a/orcsome3-stubs/orcsome/actions.pyi
+++ /dev/null
@@ -1,9 +0,0 @@
-from . import wm as wm, wrappers as wrappers
-from typing import Optional
-
-class Actions(wm.Actions):
- def __init__(self, window_manager: wm.WM) -> None: ...
- def focus_next(self, window: Optional[wrappers.Window] = ...) -> None: ...
- def focus_prev(self, window: Optional[wrappers.Window] = ...) -> None: ...
- def restart(self) -> None: ...
- def activate_window_desktop(self, window: wrappers.Window) -> Optional[bool]: ...
diff --git a/orcsome3-stubs/orcsome/aliases.pyi b/orcsome3-stubs/orcsome/aliases.pyi
deleted file mode 100644
index d167df2..0000000
--- a/orcsome3-stubs/orcsome/aliases.pyi
+++ /dev/null
@@ -1,3 +0,0 @@
-from typing import Dict
-
-KEYS: Dict[str, str]
diff --git a/orcsome3-stubs/orcsome/ev.pyi b/orcsome3-stubs/orcsome/ev.pyi
deleted file mode 100644
index 4060b17..0000000
--- a/orcsome3-stubs/orcsome/ev.pyi
+++ /dev/null
@@ -1,29 +0,0 @@
-from . import ev_build as ev_build
-from ._ev import ffi as ffi, lib as lib
-from typing import Any, Callable, Optional
-
-class Loop:
- def __init__(self) -> None: ...
- def destroy(self) -> None: ...
- def run(self, flags: int = ...) -> None: ...
- def break_(self, flags: int = ...) -> None: ...
-
-class IOWatcher:
- def __init__(self, callback: Callable[..., Any], file_descriptor: int, flags: int) -> None: ...
- def start(self, loop: Loop) -> None: ...
- def stop(self, loop: Loop) -> None: ...
-
-class SignalWatcher:
- def __init__(self, callback: Callable[..., Any], signum: int) -> None: ...
- def start(self, loop: Loop) -> None: ...
- def stop(self, loop: Loop) -> None: ...
-
-class TimerWatcher:
- next_stop: int
- def __init__(self, callback: Callable[..., Any], after: float, repeat: float = ...) -> None: ...
- def start(self, loop: Loop, after: Optional[float] = ..., repeat: Optional[float] = ...) -> None: ...
- def stop(self, loop: Loop) -> None: ...
- def again(self, loop: Loop) -> None: ...
- def remaining(self, loop: Loop) -> float: ...
- def update_next_stop(self) -> None: ...
- def overdue(self, timeout: float) -> bool: ...
diff --git a/orcsome3-stubs/orcsome/ev_build.pyi b/orcsome3-stubs/orcsome/ev_build.pyi
deleted file mode 100644
index 5dd124b..0000000
--- a/orcsome3-stubs/orcsome/ev_build.pyi
+++ /dev/null
@@ -1,7 +0,0 @@
-from cffi.api import FFI as FFI
-
-source: str
-export_source: str
-ffibuilder: FFI
-
-def main(verbose: bool = ...) -> None: ...
diff --git a/orcsome3-stubs/orcsome/notify.pyi b/orcsome3-stubs/orcsome/notify.pyi
deleted file mode 100644
index e9cc8bd..0000000
--- a/orcsome3-stubs/orcsome/notify.pyi
+++ /dev/null
@@ -1,17 +0,0 @@
-from typing import List, Optional
-
-
-def notify(summary: str, body: str, timeout: int = ..., urgency: int = ..., appname: Optional[str] = ...) -> Notification: ...
-
-class Notification:
- summary: str
- body: str
- timeout: int
- urgency: int
- appname: str
- replace_id: int
- lastcmd: List[str]
- def __init__(self, summary: str, body: str, timeout: int, urgency: int, appname: str) -> None: ...
- def show(self) -> None: ...
- def update(self, summary: Optional[str] = ..., body: Optional[str] = ..., timeout: Optional[int] = ..., urgency: Optional[int] = ...) -> None: ...
- def close(self) -> None: ...
diff --git a/orcsome3-stubs/orcsome/run.pyi b/orcsome3-stubs/orcsome/run.pyi
deleted file mode 100644
index 13385f9..0000000
--- a/orcsome3-stubs/orcsome/run.pyi
+++ /dev/null
@@ -1,12 +0,0 @@
-import logging
-from . import ev as ev, update_wm as update_wm
-from ..version import VERSION as VERSION
-from .wm import WM as WM
-from pathlib import Path
-from typing import Any, Dict, Optional
-
-logger: logging.Logger
-
-def execfile(filepath: Path, globales: Optional[Dict[str, Any]] = ...) -> None: ...
-def load_config(wm: WM, config: Path) -> None: ...
-def run() -> None: ...
diff --git a/orcsome3-stubs/orcsome/utils.pyi b/orcsome3-stubs/orcsome/utils.pyi
deleted file mode 100644
index 951532a..0000000
--- a/orcsome3-stubs/orcsome/utils.pyi
+++ /dev/null
@@ -1,4 +0,0 @@
-from typing import List, Tuple
-
-def match_string(pattern: str, data: str) -> bool: ...
-def get_compiler_args(*libraries: str) -> Tuple[List[str], List[str]]: ...
diff --git a/orcsome3-stubs/orcsome/wm.pyi b/orcsome3-stubs/orcsome/wm.pyi
deleted file mode 100644
index d00ecf5..0000000
--- a/orcsome3-stubs/orcsome/wm.pyi
+++ /dev/null
@@ -1,84 +0,0 @@
-import abc
-import logging
-from . import ev as ev, wrappers as wrappers, xlib as xlib
-from abc import ABC, abstractmethod
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
-
-logger: logging.Logger
-ignore_logger: bool
-MODIFICATORS: Dict[str, int]
-IGNORED_MOD_MASKS: Tuple[int, int, int, int]
-
-class RestartException(Exception): ...
-
-class Actions(ABC, metaclass=abc.ABCMeta):
- @abstractmethod
- def focus_next(self, window: Optional[wrappers.Window] = ...) -> None: ...
- @abstractmethod
- def focus_prev(self, window: Optional[wrappers.Window] = ...) -> None: ...
- @abstractmethod
- def restart(self) -> None: ...
- @abstractmethod
- def activate_window_desktop(self, window: wrappers.Window) -> Optional[bool]: ...
-
-class WM:
- focus_history: List[xlib.Window]
- dpy: xlib.Display
- root: xlib.Window
- atom: xlib.AtomCache
- track_kbd_layout: bool
- actions: Actions
- def __init__(self, loop: ev.Loop) -> None: ...
- def init(self) -> None: ...
- def stop(self, is_exit: bool = ...) -> None: ...
- def create_window(self, window_id: int) -> wrappers.Window: ...
- def get_keycode_from_string(self, key: str) -> Optional[int]: ...
- def parse_keydef(self, keydef: str) -> Optional[List[Tuple[int, int]]]: ...
- def on_key(self, keydef: str, window: Optional[wrappers.Window] = ...) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
- def on_create(self, **matchers: Any) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
- def on_manage(self, **matchers: Any) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
- def on_destroy(self, window: Optional[wrappers.Window] = ...) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
- def on_property_change(self, properties: List[str], window: Optional[wrappers.Window] = ...) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
- def on_timer(self, timeout: float, start: bool = ..., first_timeout: Optional[float] = ...) -> Callable[[Callable[[], None]], Callable[[], None]]: ...
- def get_clients(self) -> List[wrappers.Window]: ...
- def get_stacked_clients(self) -> List[wrappers.Window]: ...
- @property
- def current_window(self) -> Optional[wrappers.Window]: ...
- @property
- def current_desktop(self) -> int: ...
- @property
- def wm_name(self) -> Optional[str]: ...
- @property
- def event_window(self) -> wrappers.Window: ...
- @property
- def event(self) -> Union[xlib.XKeyEvent, xlib.XCreateWindowEvent, xlib.XDestroyWindowEvent, xlib.XPropertyEvent]: ...
- def activate_desktop(self, num: int) -> None: ...
- def find_clients(self, clients: List[wrappers.Window], **matchers: Any) -> List[wrappers.Window]: ...
- def find_client(self, clients: List[wrappers.Window], **matchers: Any) -> Optional[wrappers.Window]: ...
- def focus_window(self, window: xlib.Window) -> None: ...
- def focus_and_raise(self, window: wrappers.Window) -> None: ...
- def place_window_above(self, window: xlib.Window) -> None: ...
- def place_window_below(self, window: xlib.Window) -> None: ...
- def minimize_window(self, window: xlib.Window) -> None: ...
- def restore_window(self, window: xlib.Window) -> None: ...
- def set_window_state(self, window: xlib.Window, taskbar: Optional[bool] = ..., pager: Optional[bool] = ..., decorate: Optional[bool] = ..., otaskbar: Optional[bool] = ..., vmax: Optional[bool] = ..., hmax: Optional[bool] = ...) -> None: ...
- def get_window_geometry(self, window: xlib.Window) -> Tuple[int, int, int, int]: ...
- def get_screen_size(self) -> Tuple[int, int]: ...
- def get_workarea(self, desktop: Optional[int] = ...) -> List[int]: ...
- def moveresize_window(self, window: xlib.Window, x: Optional[int] = ..., y: Optional[int] = ..., w: Optional[int] = ..., h: Optional[int] = ...) -> None: ...
- def moveresize_window2(self, window: wrappers.Window, left: int, top: int, right: int, bottom: int) -> None: ...
- def close_window(self, window: Optional[wrappers.Window] = ...) -> None: ...
- def change_window_desktop(self, window: xlib.Window, desktop: int) -> None: ...
- def on_init(self, func: Callable[[], None]) -> Callable[[], None]: ...
- def on_deinit(self, func: Callable[[], None]) -> Callable[[], None]: ...
- def get_screen_saver_info(self) -> Optional[wrappers.XScreenSaverInfo]: ...
- def reset_dpms(self) -> None: ...
- def get_atom_name(self, atom: xlib.Atom) -> str: ...
-
-class ImmediateWM(WM):
- dpy: xlib.Display
- root: xlib.Window
- atom: xlib.AtomCache
- def __init__(self) -> None: ...
-
-def error_handler(_display: xlib.Display, error: xlib.XErrorEvent) -> int: ...
diff --git a/orcsome3-stubs/orcsome/wrappers.pyi b/orcsome3-stubs/orcsome/wrappers.pyi
deleted file mode 100644
index 26395de..0000000
--- a/orcsome3-stubs/orcsome/wrappers.pyi
+++ /dev/null
@@ -1,81 +0,0 @@
-from . import utils as utils, wm as wm, xlib as xlib
-from enum import Enum
-from functools import cached_property as cached_property
-from pathlib import Path
-from typing import List, Optional, Tuple, Union
-
-class WindowTree:
- window: Window
- root: Window
- parent: Window
- children: List[Window]
- def __init__(self, window: Window, root: Window, parent: Window, children: List[Window]) -> None: ...
-
-class XWindowAttributes:
- class MapState(Enum):
- IsUnmapped: Any
- IsUnviewable: Any
- IsViewable: Any
- x: int
- y: int
- width: int
- height: int
- border_width: int
- depth: int
- root: Window
- override_redirect: bool
- map_state: XWindowAttributes.MapState
- def __init__(self, attributes: xlib.XWindowAttributes) -> None: ...
-
-class XScreenSaverInfo:
- class State(Enum):
- Off: Any
- On: Any
- Disabled: Any
- class Kind(Enum):
- Blanked: Any
- Internal: Any
- External: Any
- window: Window
- state: XScreenSaverInfo.State
- kind: XScreenSaverInfo.Kind
- til_or_since: int
- idle: int
- event_mask: int
- def __init__(self, screensaverinfo: xlib.ScreenSaverInfo) -> None: ...
-
-class Window(int):
- wm: wm.WM
- @cached_property
- def desktop(self) -> Optional[int]: ...
- @cached_property
- def role(self) -> Optional[str]: ...
- @cached_property
- def cls(self) -> Optional[str]: ...
- @cached_property
- def name(self) -> Optional[str]: ...
- @cached_property
- def title(self) -> Optional[str]: ...
- def get_name_and_class(self) -> Tuple[Optional[str], Optional[str]]: ...
- def matches(self, name: Optional[str] = ..., cls: Optional[str] = ..., role: Optional[str] = ..., desktop: Optional[int] = ..., title: Optional[str] = ...) -> bool: ...
- def get_property(self, property: str, type: Optional[str] = ..., split: bool = ...) -> Optional[Union[List[int], List[str]]]: ...
- def set_property(self, property: str, format: int, data: Union[List[int], List[str]], type: Optional[str] = ...) -> None: ...
- def get_windows_same_pid(self) -> List[Window]: ...
- def get_window_tree(self) -> Optional[WindowTree]: ...
- def set_window_icon(self, icon: Union[Path, str]) -> None: ...
- @cached_property
- def attributes(self) -> XWindowAttributes: ...
- @cached_property
- def state(self) -> List[str]: ...
- @cached_property
- def maximized_vert(self) -> bool: ...
- @cached_property
- def maximized_horz(self) -> bool: ...
- @cached_property
- def decorated(self) -> bool: ...
- @cached_property
- def urgent(self) -> bool: ...
- @cached_property
- def fullscreen(self) -> bool: ...
- @cached_property
- def pid(self) -> Optional[int]: ...
diff --git a/orcsome3-stubs/orcsome/xlib.pyi b/orcsome3-stubs/orcsome/xlib.pyi
deleted file mode 100644
index d5e41b6..0000000
--- a/orcsome3-stubs/orcsome/xlib.pyi
+++ /dev/null
@@ -1,133 +0,0 @@
-from . import xlib_build as xlib_build
-from ._xlib import ffi as ffi, lib as lib
-from enum import Enum
-from typing import Any, Dict, List, Optional, Union
-
-Atom = int
-Window = int
-Display = Any
-XEvent = Any
-XErrorEvent = Any
-XWindowAttributes = Any
-ScreenSaverInfo = Any
-
-class MASKS(Enum):
- Mod1Mask: Any
- ControlMask: Any
- ShiftMask: Any
- Mod2Mask: Any
- Mod4Mask: Any
- LockMask: Any
-
-class EVENTS(Enum):
- KeyPress: Any
- KeyRelease: Any
- CreateNotify: Any
- DestroyNotify: Any
- FocusIn: Any
- FocusOut: Any
- PropertyNotify: Any
- MapNotify: Any
- UnmapNotify: Any
-
-class AtomCache:
- dpy: Display
- def __init__(self, dpy: Display) -> None: ...
- def __getitem__(self, name: str) -> Atom: ...
-
-class XEvent_:
- class Type(Enum):
- KeyPress: Any
- KeyRelease: Any
- CreateNotify: Any
- DestroyNotify: Any
- PropertyNotify: Any
- FocusIn: Any
- FocusOut: Any
- CirculateNotify: Any
- ConfigureNotify: Any
- GravityNotify: Any
- ReparentNotify: Any
- MapNotify: Any
- UnmapNotify: Any
- type: XEvent_.Type
- def __init__(self, xevent: XEvent, specific_event: Any) -> None: ...
-
-class XKeyEvent(XEvent_):
- window: Window
- root: Window
- subwindow: Window
- time: float
- x: int
- y: int
- x_root: int
- y_root: int
- state: int
- keycode: int
- same_screen: bool
- def __init__(self, event: XEvent) -> None: ...
-
-class XCreateWindowEvent(XEvent_):
- parent: Window
- window: Window
- x: int
- y: int
- width: int
- height: int
- border_width: int
- override_redirect: bool
- def __init__(self, event: XEvent) -> None: ...
-
-class XDestroyWindowEvent(XEvent_):
- parent: Window
- window: Window
- def __init__(self, event: XEvent) -> None: ...
-
-class XPropertyEvent(XEvent_):
- class State(Enum):
- PropertyNewValue: Any
- PropertyDelete: Any
- window: Window
- atom: Atom
- time: float
- state: int
- def __init__(self, event: XEvent) -> None: ...
-
-class XFocusChangeEvent(XEvent_):
- class Mode(Enum):
- NotifyNormal: Any
- NotifyWhileGrabbed: Any
- NotifyGrab: Any
- NotifyUngrab: Any
- class Detail(Enum):
- NotifyAncestor: Any
- NotifyVirtual: Any
- NotifyInferior: Any
- NotifyNonlinear: Any
- NotifyNonlinearVirtual: Any
- NotifyPointer: Any
- NotifyPointerRoot: Any
- NotifyDetailNone: Any
- window: Window
- mode: XFocusChangeEvent.Mode
- detail: XFocusChangeEvent.Detail
- def __init__(self, event: XEvent) -> None: ...
-
-class XErrorEvent_:
- type: XEvent_.Type
- display: Display
- resourceid: float
- serial: float
- error_code: str
- request_code: str
- minor_code: str
- def __init__(self, error: XErrorEvent) -> None: ...
- def get_message(self, size: int = ...) -> str: ...
-
-def get_window_property(display: Display, window: Window, property: Atom, type: Atom = ..., split: bool = ...) -> Optional[Union[List[int], List[str]]]: ...
-def get_window_attributes(display: Display, window: Window) -> Optional[XWindowAttributes]: ...
-def get_screen_saver_info(display: Display, drawable: Window) -> Optional[ScreenSaverInfo]: ...
-def set_window_property(display: Display, window: Window, property: Atom, type: Atom, format: int, values: Union[List[int], List[str]]) -> None: ...
-def get_kbd_group(display: Display) -> str: ...
-def set_kbd_group(display: Display, group: int) -> None: ...
-def get_atom_name(display: Display, atom: Atom) -> str: ...
diff --git a/orcsome3-stubs/orcsome/xlib_build.pyi b/orcsome3-stubs/orcsome/xlib_build.pyi
deleted file mode 100644
index df27ad2..0000000
--- a/orcsome3-stubs/orcsome/xlib_build.pyi
+++ /dev/null
@@ -1,11 +0,0 @@
-from cffi.api import FFI as FFI
-from orcsome3.orcsome import utils as utils
-from typing import List, Tuple
-
-source: str
-export_source: str
-LIBRARIES: List[str]
-compiler_args: Tuple[List[str], List[str]]
-ffibuilder: FFI
-
-def main(verbose: bool = ...) -> None: ...
diff --git a/orcsome3-stubs/version.pyi b/orcsome3-stubs/version.pyi
deleted file mode 100644
index 3acee93..0000000
--- a/orcsome3-stubs/version.pyi
+++ /dev/null
@@ -1 +0,0 @@
-VERSION: str
diff --git a/orcsome3/__init__.py b/orcsome3/__init__.py
index e69de29..1cf6ec7 100644
--- a/orcsome3/__init__.py
+++ b/orcsome3/__init__.py
@@ -0,0 +1,26 @@
+"""Orcsome3: Python 3 scripting for NETWM-compliant X11 window managers.
+
+Typical config.py usage::
+
+ from orcsome3 import get_wm
+
+ wm = get_wm()
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from orcsome3.run import get_wm as get_wm
+
+__all__: list[str] = ["get_wm"]
+
+
+def __getattr__(name: str) -> Any:
+ # Importing this package must not load orcsome3_backend; `python -m orcsome3.libs.build` needs that.
+ if name == "get_wm":
+ from orcsome3.run import get_wm
+
+ return get_wm
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/orcsome3/__main__.py b/orcsome3/__main__.py
new file mode 100644
index 0000000..72340c0
--- /dev/null
+++ b/orcsome3/__main__.py
@@ -0,0 +1,5 @@
+"""`python -m orcsome3` → same as the `orcsome3` console script (`run()`)."""
+
+from orcsome3.run import run
+
+run()
diff --git a/orcsome3/orcsome/aliases.py b/orcsome3/aliases.py
similarity index 88%
rename from orcsome3/orcsome/aliases.py
rename to orcsome3/aliases.py
index 6acbe62..4ddc309 100644
--- a/orcsome3/orcsome/aliases.py
+++ b/orcsome3/aliases.py
@@ -1,6 +1,6 @@
-from typing import Dict
+"""Short key names mapped to X11 keysym names (`xev` / `X11/keysymdef.h` without the `XK_` prefix)."""
-KEYS: Dict[str, str] = {
+KEYS: dict[str, str] = {
"`": "grave",
"-": "minus",
"=": "equal",
diff --git a/orcsome3/bin/orcsome3 b/orcsome3/bin/orcsome3
index d4261ab..08877d7 100755
--- a/orcsome3/bin/orcsome3
+++ b/orcsome3/bin/orcsome3
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-from orcsome3.orcsome.run import run
+from orcsome3.run import run
run()
diff --git a/orcsome3/common.py b/orcsome3/common.py
new file mode 100644
index 0000000..168e58c
--- /dev/null
+++ b/orcsome3/common.py
@@ -0,0 +1,4 @@
+"""Package name and version used by the CLI, logs, and native-lib cache keys."""
+
+APPNAME: str = "orcsome3"
+VERSION: str = "1.0.0"
diff --git a/orcsome3/icons/hicolor/index.theme b/orcsome3/icons/hicolor/index.theme
new file mode 100644
index 0000000..8b28b54
--- /dev/null
+++ b/orcsome3/icons/hicolor/index.theme
@@ -0,0 +1,12 @@
+[Icon Theme]
+Name=Hicolor
+Comment=orcsome3 tray icon
+Hidden=true
+Directories=scalable/apps
+
+[scalable/apps]
+Size=48
+Context=Applications
+Type=Scalable
+MinSize=16
+MaxSize=256
diff --git a/orcsome3/icons/hicolor/scalable/apps/orcsome3-error.svg b/orcsome3/icons/hicolor/scalable/apps/orcsome3-error.svg
new file mode 100644
index 0000000..f2570e0
--- /dev/null
+++ b/orcsome3/icons/hicolor/scalable/apps/orcsome3-error.svg
@@ -0,0 +1,15 @@
+
diff --git a/orcsome3/icons/hicolor/scalable/apps/orcsome3.svg b/orcsome3/icons/hicolor/scalable/apps/orcsome3.svg
new file mode 100644
index 0000000..79381b6
--- /dev/null
+++ b/orcsome3/icons/hicolor/scalable/apps/orcsome3.svg
@@ -0,0 +1,13 @@
+
diff --git a/orcsome3/instance.py b/orcsome3/instance.py
new file mode 100644
index 0000000..f8b4564
--- /dev/null
+++ b/orcsome3/instance.py
@@ -0,0 +1,63 @@
+"""One orcsome3 process per X `$DISPLAY`, not per machine and not per monitor.
+
+Same X server (several monitors on `:0` / `:0.0`) → one lock, one process. A second X
+server (Chrome Remote Desktop, VNC, `:1`) has a different `DISPLAY` → another process,
+so grabs and event selection stay on that connection only.
+"""
+
+from __future__ import annotations
+
+import fcntl
+import os
+from pathlib import Path
+from typing import Optional, TextIO
+
+from orcsome3.common import APPNAME
+
+
+def display_key(display: Optional[str] = None) -> str:
+ """Host + display number, dropping a trailing `.screen` so `:0` and `:0.0` share a lock."""
+ raw: str = display if display is not None else os.getenv(key="DISPLAY", default=":0")
+ if "." in raw:
+ host_display: str
+ screen: str
+ host_display, screen = raw.rsplit(sep=".", maxsplit=1)
+ if screen.isdigit():
+ raw = host_display
+ return raw or ":0"
+
+
+def _lock_name(key: str) -> str:
+ safe: str = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in key)
+ return f"{safe}.lock"
+
+
+def acquire_display_lock() -> Optional[TextIO]:
+ """Exclusive lock for this `DISPLAY`. Keep the returned file open for the process lifetime.
+
+ Returns `None` if another orcsome3 already holds the lock.
+ """
+ xdg: Optional[str] = os.getenv(key="XDG_RUNTIME_DIR")
+ base: Path = Path(xdg) / APPNAME if xdg else Path(f"/tmp/{APPNAME}-{os.getuid()}")
+ base.mkdir(mode=0o700, parents=True, exist_ok=True)
+ handle: TextIO = (base / _lock_name(key=display_key())).open(mode="w")
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ handle.close()
+ return None
+ print(os.getpid(), file=handle, flush=True)
+ return handle
+
+
+def _self_check() -> None:
+ assert display_key(display=":0.0") == ":0"
+ assert display_key(display=":0") == ":0"
+ assert display_key(display=":1.0") == ":1"
+ assert display_key(display="localhost:10.0") == "localhost:10"
+ assert display_key(display=":0") != display_key(display=":1")
+
+
+if __name__ == "__main__":
+ _self_check()
+ print("ok")
diff --git a/orcsome3/keys.py b/orcsome3/keys.py
new file mode 100644
index 0000000..ff275b0
--- /dev/null
+++ b/orcsome3/keys.py
@@ -0,0 +1,194 @@
+"""Hotkey and window-match types used by WindowManager without importing it."""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, NamedTuple, Optional, Union
+
+from typing_extensions import override
+
+import orcsome3.libs.xlib as xlib
+from orcsome3.aliases import KEYS as KEY_ALIASES
+
+
+class KeyboardModifiers(int, Enum):
+ """Enum representation of keyboard modifiers. A modifier is a key associated with a keyboard modifier mask."""
+
+ NoModifiers = xlib.KEY_MASKS.NoModifiers.value
+ AnyModifier = xlib.KEY_MASKS.AnyModifier.value
+ Alt = xlib.KEY_MASKS.Mod1Mask.value
+ Meta = Alt
+ Control = xlib.KEY_MASKS.ControlMask.value
+ Ctrl = Control
+ Shift = xlib.KEY_MASKS.ShiftMask.value
+ Win = xlib.KEY_MASKS.Mod4Mask.value
+ Windows = Win
+ Hyper = Win
+ Super = Win
+
+
+class WindowMatchers(NamedTuple):
+ """
+ Class representing matchers for a window. All attributes can be `None`
+
+ Attrs:
+ - `name`: Window name. The first part of `WM_CLASS` property.
+ - `class_`: Window class. The second part of `WM_CLASS` property.
+ - `role`: Window role. Value of `WM_WINDOW_ROLE` property.
+ - `desktop`: Matches windows placed on specific desktop. Value of `_NET_WM_DESKTOP` property.
+ - `title`: Window title. Value of `_NET_WM_NAME` property.
+ - `window_type`: Window type/s. Value/s of `_NET_WM_WINDOW_TYPE` property.
+
+ `name`, `class_`, `title`, `role` and the elements of `window_type` can be regular expressions.
+ """
+
+ name: Optional[str] = None
+ class_: Optional[str] = None
+ role: Optional[str] = None
+ desktop: Optional[int] = None
+ title: Optional[str] = None
+ window_type: Optional[list[str]] = None
+
+ def is_empty(self) -> bool:
+ """Check if all attributes are `None`"""
+ attrs: list[str] = ["name", "class_", "role", "desktop", "title", "window_type"]
+ values: list[Any] = [getattr(self, x) for x in attrs]
+ return all(value is None for value in values)
+
+
+def keycode_from_string_or_keysym(
+ display: xlib.TYPES.Cython_Display, key: Union[str, xlib.TYPES.Cython_KeySym]
+) -> Optional[xlib.TYPES.Cython_KeyCode]:
+ """Resolve a key name or KeySym to a KeyCode on `display`."""
+ keysym: xlib.TYPES.Cython_KeySym = xlib.CONSTANTS.KB.NO_SYMBOL
+ if isinstance(key, str):
+ keysym = xlib.x_string_to_keysym(string=KEY_ALIASES.get(key, key))
+ else:
+ keysym = key
+ if keysym == xlib.CONSTANTS.KB.NO_SYMBOL:
+ return None
+ return xlib.x_keysym_to_keycode(display=display, keysym=keysym)
+
+
+class KeyDefinition:
+ """
+ Class representing a key definition for a global hotkey. A key definition consists of modifiers and a key.
+
+ Attrs:
+ - `modifiers`: Specifies the set of modifiers for the global hotkey.
+
+ It can be an instance of `int` representing a valid value for a keymask bit or the value of the
+ bitwise inclusive OR of valid keymask bits.
+
+ Also, it can be a value from enum :class:`orcsome3.keys.KeyboardModifiers`
+ or a list of values from same enum.
+
+ For all possible modifiers combination use `orcsome3.keys.KeyboardModifiers.AnyModifier`.
+
+ - `key`: Key for the global hotkey. See class :class:`orcsome3.keys.KeyDefinition.Key`
+
+
+ More information about keyboard modifiers can be found using utility `xmodmap`.
+
+ More information about keys, keycodes and keysyms can be found using utility `xev`.
+ """
+
+ class Key:
+ """
+ Class representing a key. a Key can be represented by its keycode, keysym or name. Only one attribute is allowed.
+
+ For all possible keys use keycode `orcsome3.xlib.CONSTANTS.KB.ANY_KEY`
+
+ Attrs:
+ - `name`: Key name. Valid names can be obtained from `X11/keysymdef.h` by removing the "XK_" prefix from each.
+ Defaults to `None`.
+ - `keycode`: KeyCode of key. Defaults to `None`.
+ - `keysym`: KeySym of key. Defaults to `None`.
+ """
+
+ def __init__(
+ self,
+ name: Optional[str] = None,
+ keycode: Optional[xlib.TYPES.Cython_KeyCode] = None,
+ keysym: Optional[xlib.TYPES.Cython_KeySym] = None,
+ ) -> None:
+ """Exactly one of `name`, `keycode`, or `keysym` must be set; `name` must be non-empty if used."""
+ self.name: Optional[str] = name
+ self.keycode: Optional[Union[int, xlib.TYPES.Cython_KeyCode]] = keycode
+ self.keysym: Optional[Union[int, xlib.TYPES.Cython_KeySym]] = keysym
+
+ non_none_attrs: int = 0
+ for attr in ["name", "keycode", "keysym"]:
+ if getattr(self, attr) is not None:
+ non_none_attrs += 1
+ if non_none_attrs == 0:
+ raise Exception("Provide one attribute to create a Key object")
+ elif non_none_attrs > 1:
+ raise Exception("Only one attribute can be specified when creating a Key object")
+
+ if self.name is not None and not len(self.name.strip()):
+ raise Exception("Key name cannot be empty")
+
+ def get_keycode(self, display: xlib.TYPES.Cython_Display) -> Optional[xlib.TYPES.Cython_KeyCode]:
+ """Resolve this key to a KeyCode on `display`. `name="any_key"` maps to `CONSTANTS.KB.ANY_KEY`."""
+ if self.keycode is not None:
+ return self.keycode
+ if self.keysym is not None:
+ return keycode_from_string_or_keysym(display=display, key=self.keysym)
+ if self.name is not None:
+ if self.name.lower() == "any_key":
+ return xlib.CONSTANTS.KB.ANY_KEY
+ return keycode_from_string_or_keysym(display=display, key=self.name)
+ return None
+
+ @override
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({', '.join([f'{k}={v!r}' for k, v in self.__dict__.items() if not k.startswith('_')])})"
+
+ def __init__(self, modifiers: Union[int, KeyboardModifiers, list[KeyboardModifiers]], key: Key) -> None:
+ """Store `modifiers` (mask, enum, or list of enums) and `key` without resolving them yet."""
+ self.modifiers: Union[int, KeyboardModifiers, list[KeyboardModifiers]] = modifiers
+ self.key: KeyDefinition.Key = key
+
+ @classmethod
+ def new_from_string(cls, keydef: str) -> KeyDefinition:
+ """Parse `"Modifier + Modifier + key"` (spaces ignored). Last `+` segment is the key name."""
+ parts: list[str] = ["".join(x.split()) for x in keydef.split(sep="+") if len(x.strip())]
+ modifiers: list[str] = parts[:-1]
+ key: str = parts[-1]
+ modifiers_for_key_definition: list[KeyboardModifiers] = []
+ for modifier in modifiers:
+ for keyboard_modifier in KeyboardModifiers:
+ if modifier.lower() == keyboard_modifier.name.lower():
+ modifiers_for_key_definition.append(keyboard_modifier)
+ break
+ return KeyDefinition(modifiers=modifiers_for_key_definition, key=KeyDefinition.Key(name=key))
+
+ def get_modifiers_value(self) -> int:
+ """OR together modifier masks. An empty list is `KeyboardModifiers.NoModifiers`."""
+ if isinstance(self.modifiers, int):
+ return self.modifiers
+ elif isinstance(self.modifiers, KeyboardModifiers):
+ return self.modifiers.value
+ else:
+ new_modifier: int = KeyboardModifiers.NoModifiers.value
+ if not len(self.modifiers):
+ return new_modifier
+ for modifier in self.modifiers:
+ new_modifier |= modifier.value
+ return new_modifier
+
+ def get_modifiers_value_and_keycode(
+ self, display: xlib.TYPES.Cython_Display
+ ) -> tuple[int, xlib.TYPES.Cython_KeyCode]:
+ """Returns modifiers value and keycode for the key definition. Raise an exception if no keycode is found for attribute `key`."""
+
+ modifiers_value: int = self.get_modifiers_value()
+ keycode_of_key: Optional[xlib.TYPES.Cython_KeyCode] = self.key.get_keycode(display=display)
+ if keycode_of_key is None:
+ raise Exception(f"Error obtaining KeyCode for {self.key}")
+ return modifiers_value, keycode_of_key
+
+ @override
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({', '.join([f'{k}={v!r}' for k, v in self.__dict__.items() if not k.startswith('_')])})"
diff --git a/orcsome3-stubs/__init__.py b/orcsome3/libs/__init__.py
similarity index 100%
rename from orcsome3-stubs/__init__.py
rename to orcsome3/libs/__init__.py
diff --git a/orcsome3/libs/build/__init__.py b/orcsome3/libs/build/__init__.py
new file mode 100644
index 0000000..ecb168c
--- /dev/null
+++ b/orcsome3/libs/build/__init__.py
@@ -0,0 +1,36 @@
+"""Native library build system for orcsome3."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from setuptools.command.build_py import build_py as _BuildPy
+from typing_extensions import override
+
+from orcsome3.libs.build.libraries import (
+ build_extensions,
+ build_shared_library,
+ cythonize_cython_extensions,
+ install_backend_for_local_dev,
+ main,
+)
+
+__all__: list[str] = [
+ "BuildPy",
+ "build_extensions",
+ "build_shared_library",
+ "cythonize_cython_extensions",
+ "install_backend_for_local_dev",
+ "main",
+]
+
+
+class BuildPy(_BuildPy):
+ """Copy the top-level Cython stub next to the .so (it is not package data)."""
+
+ @override
+ def run(self) -> None:
+ super().run()
+ stub: Path = Path("orcsome3_backend.pyi")
+ if stub.is_file():
+ _ = self.copy_file(infile=str(stub), outfile=str(Path(self.build_lib).joinpath(stub.name)))
diff --git a/orcsome3/libs/build/__main__.py b/orcsome3/libs/build/__main__.py
new file mode 100644
index 0000000..566040e
--- /dev/null
+++ b/orcsome3/libs/build/__main__.py
@@ -0,0 +1,5 @@
+"""`python -m orcsome3.libs.build` — compile static native libs and the Cython backend."""
+
+from orcsome3.libs.build.libraries import main
+
+main()
diff --git a/orcsome3/libs/build/cache.py b/orcsome3/libs/build/cache.py
new file mode 100644
index 0000000..8a88ee6
--- /dev/null
+++ b/orcsome3/libs/build/cache.py
@@ -0,0 +1,96 @@
+"""On-disk cache of compiled static libraries (`~/.cache/orcsome3/libs` or `ORCSOME3_LIB_CACHE`)."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+from pathlib import Path
+from typing import Iterable, Optional
+
+from orcsome3.common import VERSION
+from orcsome3.utils import rmdir
+
+
+def force_rebuild() -> bool:
+ """True when `ORCSOME3_FORCE_REBUILD` is 1/true/yes."""
+ return os.getenv(key="ORCSOME3_FORCE_REBUILD", default="").lower() in ("1", "true", "yes")
+
+
+def cache_root() -> Path:
+ """Cache directory: `ORCSOME3_LIB_CACHE` or `~/.cache/orcsome3/libs`."""
+ override: Optional[str] = os.getenv(key="ORCSOME3_LIB_CACHE")
+ if override:
+ return Path(override)
+ return Path.home() / ".cache" / "orcsome3" / "libs"
+
+
+def library_cache_key(name: str, version: str, artifacts: Iterable[Path]) -> str:
+ """Hash of name, version, orcsome3 version, and artifact paths (cache folder name)."""
+ payload: dict[str, object] = {
+ "name": name,
+ "version": version,
+ "orcsome3": VERSION,
+ "artifacts": [str(a) for a in artifacts],
+ }
+ return hashlib.sha256(json.dumps(obj=payload, sort_keys=True).encode()).hexdigest()[:16]
+
+
+def artifacts_exist(artifacts: Iterable[Path]) -> bool:
+ """True if every path in `artifacts` is an existing file."""
+ return all(path.is_file() for path in artifacts)
+
+
+def restore_from_cache(cache_key: str, compiled_library_folder: Path, artifacts: list[Path]) -> bool:
+ """Copy cached install dir into `compiled_library_folder`. True if artifacts are then present."""
+ if force_rebuild():
+ return False
+ if artifacts_exist(artifacts=artifacts):
+ return True
+ cached: Path = cache_root() / cache_key
+ if not cached.is_dir():
+ return False
+ if compiled_library_folder.is_dir():
+ shutil.rmtree(path=compiled_library_folder)
+ _ = shutil.copytree(src=cached, dst=compiled_library_folder)
+ return artifacts_exist(artifacts=artifacts)
+
+
+def store_in_cache(cache_key: str, compiled_library_folder: Path) -> None:
+ """Copy `compiled_library_folder` into the cache keyed by `cache_key`."""
+ if not compiled_library_folder.is_dir():
+ return
+ cached: Path = cache_root() / cache_key
+ cached.parent.mkdir(parents=True, exist_ok=True)
+ if cached.is_dir():
+ shutil.rmtree(path=cached)
+ _ = shutil.copytree(src=compiled_library_folder, dst=cached)
+
+
+def begin_library_build(
+ skip_build: bool,
+ cache_name: str,
+ cache_version: str,
+ artifacts: list[Path],
+ compiled_library_folder: Path,
+ clean_paths: tuple[Path, ...] = (),
+) -> bool:
+ """Return True if the caller should compile. False if skip, artifacts exist, or cache restored."""
+ if skip_build:
+ return False
+ if artifacts_exist(artifacts=artifacts) and not force_rebuild():
+ return False
+ cache_key: str = library_cache_key(name=cache_name, version=cache_version, artifacts=artifacts)
+ if restore_from_cache(cache_key=cache_key, compiled_library_folder=compiled_library_folder, artifacts=artifacts):
+ return False
+ rmdir(*clean_paths)
+ return True
+
+
+def end_library_build(lib_name: str, version: str, artifacts: list[Path], compiled_library_folder: Path) -> None:
+ """Store the just-built install dir in cache."""
+ store_in_cache(
+ cache_key=library_cache_key(name=lib_name, version=version, artifacts=artifacts),
+ compiled_library_folder=compiled_library_folder,
+ )
diff --git a/orcsome3/libs/build/context.py b/orcsome3/libs/build/context.py
new file mode 100644
index 0000000..190efa2
--- /dev/null
+++ b/orcsome3/libs/build/context.py
@@ -0,0 +1,41 @@
+"""Build flags and paths for compiling bundled native libraries."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+
+
+def parallel_jobs() -> int:
+ """Job count from `ORCSOME3_BUILD_JOBS` / `CMAKE_BUILD_PARALLEL_LEVEL`, else CPU count."""
+ raw: Optional[str] = os.getenv(key="ORCSOME3_BUILD_JOBS") or os.getenv(key="CMAKE_BUILD_PARALLEL_LEVEL")
+ if raw:
+ return max(1, int(raw))
+ return max(1, os.cpu_count() or 1)
+
+
+def make_build_cmd() -> str:
+ """`make -jN` using `parallel_jobs()`."""
+ return f"make -j{parallel_jobs()}"
+
+
+def meson_compile_cmd() -> str:
+ """`meson compile -j N` using `parallel_jobs()`."""
+ return f"meson compile -j {parallel_jobs()}"
+
+
+@dataclass
+class BuildContext:
+ """Where to put compiled libs and whether to skip / force debug cmake."""
+
+ build_directory: Path
+ skip_build_external_libs: bool
+ static: bool
+ debug: bool = False
+
+ @property
+ def cmake_debug_flag(self) -> str:
+ """`ON`/`OFF` for `-DCMAKE_FIND_DEBUG_MODE`."""
+ return "ON" if self.debug else "OFF"
diff --git a/orcsome3/libs/build/engine.py b/orcsome3/libs/build/engine.py
new file mode 100644
index 0000000..787536d
--- /dev/null
+++ b/orcsome3/libs/build/engine.py
@@ -0,0 +1,318 @@
+"""Clone, configure, and install static C/C++/Rust libraries used by the Cython backend."""
+
+from __future__ import annotations
+
+import shutil
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Callable, Iterable, Optional
+
+from orcsome3.libs.build.cache import begin_library_build, end_library_build
+from orcsome3.libs.build.context import BuildContext, make_build_cmd, meson_compile_cmd
+from orcsome3.libs.build.helpers import cmake_prefix_paths_from_deps, pkg_config_paths_from_deps
+from orcsome3.libs.build.pkgconfig import get_compiler_args
+from orcsome3.libs.build.types import CompilerArgs, ExtraDirs, Library, PkgConfigInfo
+from orcsome3.utils import rmdir, run_command_and_show_output
+
+
+@dataclass
+class _BaseRecipe:
+ """Shared fields for git-cloned native libraries (url, tag, install layout, artifacts)."""
+
+ name: str
+ url: str
+ tag: str
+ source_folder: str
+ install_prefix_suffix: str = "-compiled"
+ pkg_config_names: list[str] = field(default_factory=list)
+ pkg_config_subpath: tuple[str, ...] = ("lib", "pkgconfig")
+ include_subdirs: list[str] = field(default_factory=lambda: ["include"])
+ lib_subdir: str = "lib"
+ artifacts: list[str] = field(default_factory=list)
+ cmake_dirs: Optional[list[str]] = None
+
+
+@dataclass
+class CMakeGitRecipe(_BaseRecipe):
+ """Git + CMake recipe (`cmake_flags`, optional clone/install hooks)."""
+
+ cmake_flags: list[str] = field(default_factory=list)
+ cmake_source: str = "."
+ pre_build_steps: list[str] = field(default_factory=list)
+ post_install_cleanup: Optional[Callable[[Path], None]] = None
+ post_clone_steps: list[str] = field(default_factory=list)
+ post_clone_hook: Optional[Callable[[Path], None]] = None
+
+
+@dataclass
+class MesonGitRecipe(_BaseRecipe):
+ """Git + Meson recipe."""
+
+ meson_flags: list[str] = field(default_factory=list)
+ meson_build_dir: str = "builddir"
+ pre_meson_steps: list[str] = field(default_factory=list)
+
+
+@dataclass
+class CargoGitRecipe:
+ """Git + Cargo recipe (used for resvg)."""
+
+ name: str
+ url: str
+ tag: str
+ source_folder: str
+ install_prefix_suffix: str = "-compiled"
+ cargo_subdir: str = "c-api"
+ header_relative: str = "c-api/resvg.h"
+ artifact_relative: str = "target/release/libresvg.a"
+ artifact_name: str = "libresvg.a"
+ include_subdirs: list[str] = field(default_factory=lambda: ["include"])
+ lib_subdir: str = "lib"
+ link_libs: list[str] = field(default_factory=lambda: ["-lresvg", "-lm"])
+
+
+def _compiled_folder(ctx: BuildContext, recipe: _BaseRecipe | CargoGitRecipe) -> Path:
+ return ctx.build_directory / f"{recipe.source_folder}{recipe.install_prefix_suffix}"
+
+
+def _pkg_config_folder(compiled: Path, recipe: _BaseRecipe) -> Path:
+ return compiled.joinpath(*recipe.pkg_config_subpath)
+
+
+def _artifact_paths(lib_dir: Path, names: list[str]) -> list[Path]:
+ return [lib_dir / name for name in names]
+
+
+def _resolve_cmake_deps(
+ cmake_prefix_path: Optional[list[str]],
+ extra_paths_to_search: Optional[list[Path]],
+ dependencies: Optional[Iterable[Library]],
+) -> tuple[list[str], Optional[list[Path]]]:
+ prefix_paths: list[str] = list(cmake_prefix_path or [])
+ search_paths: list[Path] = list(extra_paths_to_search or [])
+ if dependencies is not None:
+ prefix_paths.extend(cmake_prefix_paths_from_deps(dependencies=dependencies))
+ search_paths.extend(Path(path) for path in pkg_config_paths_from_deps(dependencies=dependencies))
+ return prefix_paths, search_paths or None
+
+
+def _library_from_recipe(
+ recipe: _BaseRecipe,
+ compiled_library_folder: Path,
+ artifacts: list[Path],
+ extra_paths_to_search: Optional[list[Path]] = None,
+) -> Library:
+ static_lib_folder: Path = compiled_library_folder / recipe.lib_subdir
+ pkg_config_folder: Path = _pkg_config_folder(compiled=compiled_library_folder, recipe=recipe)
+ pkg_config_info: PkgConfigInfo = PkgConfigInfo(
+ name=recipe.pkg_config_names or [recipe.name],
+ pkg_config_path=pkg_config_folder,
+ static=True,
+ )
+ cmake_dir_paths: Optional[list[Path]] = None
+ if recipe.cmake_dirs is not None:
+ cmake_dir_paths = [static_lib_folder.joinpath(*part.split("/")) for part in recipe.cmake_dirs]
+
+ return Library(
+ name=recipe.name,
+ compiler_args=get_compiler_args(libraries=[pkg_config_info], extra_paths_to_search=extra_paths_to_search),
+ pkg_config_info=pkg_config_info,
+ extra_dirs=ExtraDirs(
+ include_dirs=[compiled_library_folder / part for part in recipe.include_subdirs],
+ lib_dir=static_lib_folder,
+ cmake_dirs=cmake_dir_paths,
+ ),
+ extra_objects=artifacts,
+ )
+
+
+def build_cmake_git_library(
+ ctx: BuildContext,
+ recipe: CMakeGitRecipe,
+ skip_build: bool,
+ cmake_prefix_path: Optional[list[str]] = None,
+ extra_paths_to_search: Optional[list[Path]] = None,
+ dependencies: Optional[Iterable[Library]] = None,
+ extra_cmake_flags: Optional[list[str]] = None,
+) -> Library:
+ """Clone `recipe.url` at `recipe.tag`, cmake/make install, and return a `Library` with compiler flags."""
+ lib_base_folder: Path = ctx.build_directory / recipe.source_folder
+ compiled_library_folder: Path = _compiled_folder(ctx=ctx, recipe=recipe)
+ static_lib_folder: Path = compiled_library_folder / recipe.lib_subdir
+ artifacts: list[Path] = _artifact_paths(lib_dir=static_lib_folder, names=recipe.artifacts)
+ prefix_paths, search_paths = _resolve_cmake_deps(
+ cmake_prefix_path=cmake_prefix_path, extra_paths_to_search=extra_paths_to_search, dependencies=dependencies
+ )
+
+ if begin_library_build(
+ skip_build=skip_build,
+ cache_name=recipe.name,
+ cache_version=recipe.tag,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=(lib_base_folder, compiled_library_folder),
+ ):
+ flags: list[str] = [
+ f"-DCMAKE_FIND_DEBUG_MODE={ctx.cmake_debug_flag}",
+ f"-DCMAKE_INSTALL_PREFIX='{compiled_library_folder}'",
+ "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
+ ]
+ if prefix_paths:
+ flags.append(f"-DCMAKE_PREFIX_PATH='{';'.join(prefix_paths)}'")
+ flags.extend(recipe.cmake_flags)
+ if extra_cmake_flags:
+ flags.extend(extra_cmake_flags)
+ configure_command: str = f"CMAKE_POLICY_VERSION_MINIMUM=3.5 cmake {' '.join(flags)} -S {recipe.cmake_source}"
+ clone_command: str = " && ".join(
+ [
+ f"git clone {recipe.url} '{lib_base_folder}'",
+ f"cd '{lib_base_folder}'",
+ f"git switch --detach {recipe.tag}",
+ *recipe.post_clone_steps,
+ ]
+ )
+ run_command_and_show_output(command=clone_command, cwd=ctx.build_directory)
+ if recipe.post_clone_hook is not None:
+ recipe.post_clone_hook(lib_base_folder)
+ build_command: str = " && ".join(
+ [
+ f"cd '{lib_base_folder}'",
+ *recipe.pre_build_steps,
+ configure_command,
+ make_build_cmd(),
+ "make install",
+ ]
+ )
+ run_command_and_show_output(command=build_command, cwd=ctx.build_directory)
+ rmdir(lib_base_folder)
+ if recipe.post_install_cleanup is not None:
+ recipe.post_install_cleanup(static_lib_folder)
+ end_library_build(
+ lib_name=recipe.name,
+ version=recipe.tag,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ )
+
+ return _library_from_recipe(
+ recipe=recipe,
+ compiled_library_folder=compiled_library_folder,
+ artifacts=artifacts,
+ extra_paths_to_search=search_paths,
+ )
+
+
+def build_meson_git_library(
+ ctx: BuildContext,
+ recipe: MesonGitRecipe,
+ skip_build: bool,
+ extra_paths_to_search: Optional[list[Path]] = None,
+ dependencies: Optional[Iterable[Library]] = None,
+) -> Library:
+ """Clone `recipe.url` at `recipe.tag`, meson setup/compile/install, and return a `Library`."""
+ lib_base_folder: Path = ctx.build_directory / recipe.source_folder
+ compiled_library_folder: Path = _compiled_folder(ctx=ctx, recipe=recipe)
+ static_lib_folder: Path = compiled_library_folder / recipe.lib_subdir
+ artifacts: list[Path] = _artifact_paths(lib_dir=static_lib_folder, names=recipe.artifacts)
+ _, search_paths = _resolve_cmake_deps(
+ cmake_prefix_path=None, extra_paths_to_search=extra_paths_to_search, dependencies=dependencies
+ )
+
+ if begin_library_build(
+ skip_build=skip_build,
+ cache_name=recipe.name,
+ cache_version=recipe.tag,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=(lib_base_folder, compiled_library_folder),
+ ):
+ meson_flags: list[str] = [f"--prefix '{compiled_library_folder}'", *recipe.meson_flags]
+ if dependencies is not None:
+ pkg_config_path: str = ":".join(pkg_config_paths_from_deps(dependencies=dependencies))
+ if pkg_config_path:
+ meson_flags.append(f'-Dpkg_config_path="{pkg_config_path}"')
+ configure_command: str = f"meson setup {recipe.meson_build_dir} {' '.join(meson_flags)}"
+ commands: str = " && ".join(
+ [
+ f"git clone {recipe.url} '{lib_base_folder}'",
+ f"cd '{lib_base_folder}'",
+ f"git switch --detach {recipe.tag}",
+ *recipe.pre_meson_steps,
+ configure_command,
+ f"cd {recipe.meson_build_dir}",
+ meson_compile_cmd(),
+ "meson install",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=ctx.build_directory)
+ rmdir(lib_base_folder)
+ end_library_build(
+ lib_name=recipe.name,
+ version=recipe.tag,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ )
+
+ return _library_from_recipe(
+ recipe=recipe,
+ compiled_library_folder=compiled_library_folder,
+ artifacts=artifacts,
+ extra_paths_to_search=search_paths,
+ )
+
+
+def build_cargo_git_library(
+ ctx: BuildContext,
+ recipe: CargoGitRecipe,
+ skip_build: bool,
+) -> Library:
+ """Clone `recipe.url` at `recipe.tag`, `cargo build --release`, and return a `Library`."""
+ lib_base_folder: Path = ctx.build_directory / recipe.source_folder
+ compiled_library_folder: Path = _compiled_folder(ctx=ctx, recipe=recipe)
+ static_lib_folder: Path = compiled_library_folder / recipe.lib_subdir
+ static_include_folder: Path = compiled_library_folder / "include"
+ artifacts: list[Path] = [static_lib_folder / recipe.artifact_name]
+
+ if begin_library_build(
+ skip_build=skip_build,
+ cache_name=recipe.name,
+ cache_version=recipe.tag,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=(lib_base_folder, compiled_library_folder),
+ ):
+ commands: str = " && ".join(
+ [
+ f"git clone {recipe.url} '{lib_base_folder}'",
+ f"mkdir -p '{compiled_library_folder}' '{static_lib_folder}' '{static_include_folder}'",
+ f"cd '{lib_base_folder}'",
+ f"git switch --detach {recipe.tag}",
+ f"cd {recipe.cargo_subdir}",
+ "cargo build --release",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=ctx.build_directory)
+ _ = shutil.move(src=str(lib_base_folder / recipe.header_relative), dst=str(static_include_folder))
+ _ = shutil.move(
+ src=str(lib_base_folder / recipe.artifact_relative), dst=str(static_lib_folder / recipe.artifact_name)
+ )
+ rmdir(lib_base_folder)
+ end_library_build(
+ lib_name=recipe.name,
+ version=recipe.tag,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ )
+
+ return Library(
+ name=recipe.name,
+ compiler_args=CompilerArgs(
+ cflags=[f"-I{static_include_folder}"],
+ libs=[f"-L{static_lib_folder}", *recipe.link_libs],
+ ),
+ extra_dirs=ExtraDirs(
+ include_dirs=[compiled_library_folder / part for part in recipe.include_subdirs],
+ lib_dir=static_lib_folder,
+ ),
+ extra_objects=artifacts,
+ )
diff --git a/orcsome3/libs/build/helpers.py b/orcsome3/libs/build/helpers.py
new file mode 100644
index 0000000..226f203
--- /dev/null
+++ b/orcsome3/libs/build/helpers.py
@@ -0,0 +1,77 @@
+"""Path helpers and skip/cache wrappers shared by native library recipes."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Callable, Iterable
+
+from orcsome3.libs.build.cache import begin_library_build, end_library_build
+from orcsome3.libs.build.context import BuildContext
+from orcsome3.libs.build.types import Library
+
+
+def versioned_folder(name: str, version: str, *, prefix: str = "") -> str:
+ """Directory name `{name}{prefix}-{version}` (e.g. `zlib-v1.3.1`)."""
+ return f"{name}{prefix}-{version}"
+
+
+def cmake_prefix_paths_from_deps(dependencies: Iterable[Library]) -> list[str]:
+ """pkg-config and extra include/lib dirs from already-built `dependencies`."""
+ paths: list[str] = []
+ for dependency in dependencies:
+ if dependency.pkg_config_info is not None and dependency.pkg_config_info.pkg_config_path is not None:
+ paths.append(str(dependency.pkg_config_info.pkg_config_path))
+ if dependency.extra_dirs is not None:
+ for extra_dir in dependency.extra_dirs.iter_dirs():
+ paths.append(str(extra_dir))
+ return paths
+
+
+def pkg_config_paths_from_deps(dependencies: Iterable[Library]) -> list[str]:
+ """`PKG_CONFIG_PATH` entries from already-built `dependencies`."""
+ paths: list[str] = []
+ for dependency in dependencies:
+ if dependency.pkg_config_info is not None and dependency.pkg_config_info.pkg_config_path is not None:
+ paths.append(str(dependency.pkg_config_info.pkg_config_path))
+ return paths
+
+
+def maybe_build_library(
+ skip_build: bool,
+ lib_name: str,
+ version: str,
+ artifacts: list[Path],
+ compiled_library_folder: Path,
+ clean_paths: list[Path],
+ build_fn: Callable[[], None],
+) -> None:
+ """Run `build_fn` unless skip/cache already produced `artifacts`; then store the result in cache."""
+ if not begin_library_build(
+ skip_build=skip_build,
+ cache_name=lib_name,
+ cache_version=version,
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=tuple(clean_paths),
+ ):
+ return
+ build_fn()
+ end_library_build(
+ lib_name=lib_name, version=version, artifacts=artifacts, compiled_library_folder=compiled_library_folder
+ )
+
+
+def make_build_context(
+ build_directory: Path,
+ skip_build_external_libs: bool,
+ *,
+ static: bool = True,
+ debug: bool = False,
+) -> BuildContext:
+ """Construct a `BuildContext` for recipe functions."""
+ return BuildContext(
+ build_directory=build_directory,
+ skip_build_external_libs=skip_build_external_libs,
+ static=static,
+ debug=debug,
+ )
diff --git a/orcsome3/libs/build/libraries.py b/orcsome3/libs/build/libraries.py
new file mode 100644
index 0000000..6497089
--- /dev/null
+++ b/orcsome3/libs/build/libraries.py
@@ -0,0 +1,1100 @@
+"""Orchestrate static native builds and Cythonize `orcsome3_backend` (CLI: `python -m orcsome3.libs.build`)."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import traceback
+import urllib.request
+from pathlib import Path
+from typing import Any, Callable, Optional, cast
+
+from setuptools import setup
+from setuptools.extension import Extension
+
+from orcsome3.common import APPNAME
+from orcsome3.libs.build.cache import force_rebuild as env_force_rebuild
+from orcsome3.libs.build.context import BuildContext, make_build_cmd
+from orcsome3.libs.build.engine import build_cargo_git_library, build_cmake_git_library, build_meson_git_library
+from orcsome3.libs.build.helpers import make_build_context, maybe_build_library, versioned_folder
+from orcsome3.libs.build.paths import CYTHON_BACKEND_PYX, CYTHON_LIBS_DIR
+from orcsome3.libs.build.pkgconfig import complete_linker_args, get_compiler_args
+from orcsome3.libs.build.recipes import (
+ CARGO_GIT_RECIPES,
+ CMAKE_GIT_RECIPES,
+ CORE_CMAKE_RECIPES,
+ CORE_MESON_RECIPES,
+ LIBRARIES_SOURCES_VERSION,
+ MESON_GIT_RECIPES,
+)
+from orcsome3.libs.build.types import CompilerArgs, ExtraDirs, Library, PkgConfigInfo
+from orcsome3.utils import extract_tar_strip_components, rmdir, run_command_and_show_output
+
+debug: bool = False
+
+
+def _lib_dirs(name: str) -> tuple[str, str]:
+ source: str = versioned_folder(name=name, version=LIBRARIES_SOURCES_VERSION[name][1])
+ return source, f"{source}-compiled"
+
+
+def validate_skipped_external_build(build_directory: Path) -> None:
+ """Ensure cached static libraries exist when skipping external builds."""
+ missing: list[str] = []
+ required_files: list[Path] = [
+ build_directory / _lib_dirs(name="zlib")[1] / "lib" / "libz.a",
+ build_directory / _lib_dirs(name="libev")[1] / "lib" / "libev.a",
+ build_directory / _lib_dirs(name="resvg")[1] / "lib" / "libresvg.a",
+ build_directory / _lib_dirs(name="libgd")[1] / "lib" / "libgd.a",
+ ]
+ for path in required_files:
+ if not path.is_file():
+ missing.append(str(path.relative_to(build_directory)))
+
+ magick_compiled: Path = build_directory / _lib_dirs(name="imagemagick")[1] / "lib"
+ magick_libs: list[Path] = list(magick_compiled.glob(pattern="libMagickWand*.a"))
+ if not magick_libs:
+ missing.append(f"{_lib_dirs(name='imagemagick')[1]}/lib/libMagickWand*.a")
+
+ if missing:
+ raise FileNotFoundError(
+ "External libraries are missing but --skip-build-external-libs was used. "
+ + "Run a full build first:\n"
+ + f" python -m orcsome3.libs.build --build-dir {build_directory.parent}\n"
+ + "Missing:\n - "
+ + "\n - ".join(missing)
+ )
+
+
+def install_backend_for_local_dev(library_directory: Path, destination: Path) -> None:
+ """Copy the compiled backend .so next to the project for local imports without extra PYTHONPATH."""
+ destination = destination.resolve()
+ for shared_library in library_directory.glob(pattern="orcsome3_backend*.so"):
+ dst: Path = destination / shared_library.name
+ if dst.resolve() == shared_library.resolve():
+ continue
+ _ = shutil.copy2(src=shared_library, dst=dst)
+
+
+def run_function_in_another_directory(directory: Path, function: Callable[..., Any]) -> Any:
+ """Run `function` with cwd=`directory`, then restore the original cwd even if `function` raises."""
+ tmp: Path = Path.cwd()
+ os.chdir(path=directory)
+ try:
+ return function()
+ finally:
+ os.chdir(path=tmp)
+
+
+def clean_build_directory(build_directory: Path, remove: Optional[list[Path]] = None) -> None:
+ """Clean `build_directory` and remove files specified in the parameter `remove`"""
+ run_function_in_another_directory(
+ directory=build_directory, function=lambda: setup(py_modules=[], script_args=["clean", "--all"])
+ )
+ if remove is not None:
+ rmdir(*remove)
+
+
+def build_base_libraries(build_directory: Path, skip_build_external_libs: bool) -> dict[str, Library]:
+ """
+ Function that builds the base libraries needed to build the core libraries.
+
+ Base libraries:
+ - `zlib`: https://github.com/madler/zlib.git.
+ - `lzo`: https://www.oberhumer.com/opensource/lzo.
+ - `zstd`: https://github.com/facebook/zstd.git.
+ - `libjpeg`: https://github.com/libjpeg-turbo/libjpeg-turbo.git.
+ - `jbig`: JBIG-KIT https://www.cl.cam.ac.uk/~mgk25/jbigkit.
+ - `liblzma`: https://github.com/tukaani-project/xz.git.
+ - `Imath`: https://github.com/AcademySoftwareFoundation/Imath.git.
+ - `openjpeg` (libopenjp2): https://github.com/uclouvain/openjpeg.git.
+ - `pixman`: https://github.com/freedesktop/pixman.git.
+ - `libpng`: https://github.com/glennrp/libpng.git. Depends on `zlib`.
+ - `libwebp`: https://chromium.googlesource.com/webm/libwebp. Dependes on `zlib`, `libpng`, `libtiff` and `libjpeg`.
+ - `libtiff`: https://gitlab.com/libtiff/libtiff.git. Depends on `zlib`, `zstd`, `libjpeg`, `jbig`, `liblzma` and `libwebp`.
+ - `djvulibre`: https://djvu.sourceforge.net. Depends on `libjpeg` and `libtiff`.
+ - `openexr`: https://github.com/AcademySoftwareFoundation/openexr.git. Depends on `zlib` and `Imath`.
+ - `libraw`: https://github.com/LibRaw/LibRaw.git. Depends on `zlib` and `libjpeg`.
+ """
+ ctx: BuildContext = make_build_context(
+ build_directory=build_directory, skip_build_external_libs=skip_build_external_libs, debug=debug
+ )
+
+ def build_zlib(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(ctx=ctx, recipe=CMAKE_GIT_RECIPES["zlib"], skip_build=skip_build)
+
+ def build_lzo(skip_build: bool = skip_build_external_libs) -> Library:
+ """Build lzo statically"""
+ lib_name: str = "lzo"
+ source_folder, compiled_name = _lib_dirs(name=lib_name)
+ lib_base_folder: Path = build_directory.joinpath(source_folder)
+ build_library_folder: Path = build_directory.joinpath(f"{source_folder}-build")
+ compiled_library_folder: Path = build_directory.joinpath(compiled_name)
+
+ # Folders with the compiled library and the include files
+ static_include_folder: Path = compiled_library_folder.joinpath(
+ "include", "lzo"
+ ) # {build_directory}/lzo-{version}-compiled/include/lzo
+ static_lib_folder: Path = compiled_library_folder.joinpath(
+ "lib"
+ ) # {build_directory}/lzo-{version}-compiled/lib
+ pkg_config_folder: Path = static_lib_folder.joinpath(
+ "pkgconfig"
+ ) # {build_directory}/lzo-{version}-compiled/lib/pkgconfig
+ artifacts: list[Path] = [static_lib_folder.joinpath("liblzo2.a")]
+
+ def _build() -> None:
+ file_with_lib_source: Path = Path(
+ urllib.request.urlretrieve(
+ url=LIBRARIES_SOURCES_VERSION[lib_name][0], filename=f"{str(lib_base_folder)}.tar.gz"
+ )[0]
+ )
+ if not file_with_lib_source.is_file():
+ raise Exception(
+ f"An error occurred downloading {lib_name} source from: {LIBRARIES_SOURCES_VERSION[lib_name][0]}"
+ )
+ try:
+ extract_tar_strip_components(
+ tar_path=file_with_lib_source, destination_path=lib_base_folder, strip_components=1
+ )
+ except Exception as e:
+ raise Exception(f"An error occurred extracting the files from file: {str(file_with_lib_source)}", e)
+
+ flags_to_configure_command: list[str] = [
+ f"-DCMAKE_FIND_DEBUG_MODE={'ON' if debug else 'OFF'}",
+ f'-DCMAKE_INSTALL_PREFIX="{str(compiled_library_folder)}"',
+ "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
+ "-DBUILD_SHARED_LIBS=OFF",
+ ]
+ configure_command: str = (
+ f"CMAKE_POLICY_VERSION_MINIMUM=3.5 cmake {' '.join(flags_to_configure_command)} -S"
+ + f" {str(lib_base_folder)}"
+ )
+ commands: str = " && ".join(
+ [
+ f"mkdir -p {str(build_library_folder)}",
+ f"cd {str(build_library_folder)}",
+ configure_command,
+ make_build_cmd(),
+ "make install",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=build_directory)
+ rmdir(file_with_lib_source, lib_base_folder, build_library_folder)
+
+ maybe_build_library(
+ skip_build=skip_build,
+ lib_name=lib_name,
+ version=LIBRARIES_SOURCES_VERSION[lib_name][1],
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=[lib_base_folder, build_library_folder, compiled_library_folder],
+ build_fn=_build,
+ )
+ pkg_config_info: PkgConfigInfo = PkgConfigInfo(name=["lzo2"], pkg_config_path=pkg_config_folder, static=True)
+ return Library(
+ name=lib_name,
+ compiler_args=get_compiler_args(libraries=[pkg_config_info]),
+ pkg_config_info=pkg_config_info,
+ extra_dirs=ExtraDirs(include_dirs=[static_include_folder], lib_dir=static_lib_folder),
+ extra_objects=[static_lib_folder.joinpath("liblzo2.a")],
+ )
+
+ def build_zstd(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(ctx=ctx, recipe=CMAKE_GIT_RECIPES["zstd"], skip_build=skip_build)
+
+ def build_libjpeg(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(ctx=ctx, recipe=CMAKE_GIT_RECIPES["libjpeg"], skip_build=skip_build)
+
+ def build_jbig(skip_build: bool = skip_build_external_libs) -> Library:
+ """Build jbig statically"""
+ lib_name: str = "jbig"
+ source_folder, compiled_name = _lib_dirs(name=lib_name)
+ lib_base_folder: Path = build_directory.joinpath(source_folder)
+ compiled_library_folder: Path = build_directory.joinpath(compiled_name)
+ artifacts: list[Path] = [compiled_library_folder.joinpath("libjbig.a")]
+
+ def _build() -> None:
+ git_clone_command: str = f"git clone {LIBRARIES_SOURCES_VERSION[lib_name][0]} '{str(lib_base_folder)}'"
+ commands: str = " && ".join(
+ [
+ git_clone_command,
+ f"cd '{str(lib_base_folder)}'",
+ f"git switch --detach {LIBRARIES_SOURCES_VERSION[lib_name][1]}",
+ # Only the "lib" target (libjbig.a); "pbm" builds jbigkit's CLI tools (unneeded here)
+ # and their man pages need groff, which isn't a build dependency of this project.
+ f"{make_build_cmd()} lib",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=build_directory)
+ _ = shutil.move(src=lib_base_folder.joinpath(f"lib{lib_name}"), dst=compiled_library_folder)
+ rmdir(lib_base_folder)
+
+ maybe_build_library(
+ skip_build=skip_build,
+ lib_name=lib_name,
+ version=LIBRARIES_SOURCES_VERSION[lib_name][1],
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=[lib_base_folder, compiled_library_folder],
+ build_fn=_build,
+ )
+ return Library(
+ name=lib_name,
+ compiler_args=CompilerArgs(
+ cflags=[f"-I{str(compiled_library_folder)}"], libs=[f"-L{str(compiled_library_folder)}", "-l:libjbig.a"]
+ ),
+ extra_dirs=ExtraDirs(include_dirs=[compiled_library_folder], lib_dir=compiled_library_folder),
+ extra_objects=[compiled_library_folder.joinpath("libjbig.a")],
+ )
+
+ def build_liblzma(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(ctx=ctx, recipe=CMAKE_GIT_RECIPES["liblzma"], skip_build=skip_build)
+
+ def build_Imath(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(ctx=ctx, recipe=CMAKE_GIT_RECIPES["Imath"], skip_build=skip_build)
+
+ def build_openjpeg(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(ctx=ctx, recipe=CMAKE_GIT_RECIPES["openjpeg"], skip_build=skip_build)
+
+ def build_pixman(skip_build: bool = skip_build_external_libs) -> Library:
+ return build_meson_git_library(ctx=ctx, recipe=MESON_GIT_RECIPES["pixman"], skip_build=skip_build)
+
+ def build_libpng(zlib: Library, skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(
+ ctx=ctx, recipe=CMAKE_GIT_RECIPES["libpng"], skip_build=skip_build, dependencies=[zlib]
+ )
+
+ def build_libwebp(build_dependencies: dict[str, Library], skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(
+ ctx=ctx,
+ recipe=CMAKE_GIT_RECIPES["libwebp"],
+ skip_build=skip_build,
+ dependencies=build_dependencies.values(),
+ )
+
+ def build_libtiff(
+ build_dependencies: dict[str, Optional[Library]],
+ skip_build: bool = skip_build_external_libs,
+ *,
+ enable_webp: bool = False,
+ ) -> Library:
+ return build_cmake_git_library(
+ ctx=ctx,
+ recipe=CMAKE_GIT_RECIPES["libtiff"],
+ skip_build=skip_build,
+ dependencies=[dependency for dependency in build_dependencies.values() if dependency is not None],
+ extra_cmake_flags=[f"-Dwebp={'ON' if enable_webp else 'OFF'}"],
+ )
+
+ def build_djvulibre(build_dependencies: dict[str, Library], skip_build: bool = skip_build_external_libs) -> Library:
+ """
+ Buld djvulibre statically.
+
+ Dependencies:
+ - libjpeg
+ - libtiff
+ """
+ lib_name: str = "djvulibre"
+ source_folder, compiled_name = _lib_dirs(name=lib_name)
+ lib_base_folder: Path = build_directory.joinpath(source_folder)
+ compiled_library_folder: Path = build_directory.joinpath(compiled_name)
+
+ # Folders with the compiled library and the include files
+ static_include_folder: Path = compiled_library_folder.joinpath(
+ "include"
+ ) # {build_directory}/djvulibre-{version}-compiled/include
+ static_lib_folder: Path = compiled_library_folder.joinpath(
+ "lib"
+ ) # {build_directory}/djvulibre-{version}-compiled/lib
+ pkg_config_folder: Path = static_lib_folder.joinpath(
+ "pkgconfig"
+ ) # {build_directory}/djvulibre-{version}-compiled/lib/pkgconfig
+ artifacts: list[Path] = [static_lib_folder.joinpath("libdjvulibre.a")]
+
+ def _build() -> None:
+ file_with_lib_source: Path = Path(
+ urllib.request.urlretrieve(
+ url=LIBRARIES_SOURCES_VERSION[lib_name][0], filename=f"{str(lib_base_folder)}.tar.gz"
+ )[0]
+ )
+ if not file_with_lib_source.is_file():
+ raise Exception(
+ f"An error occurred downloading {lib_name} source from: {LIBRARIES_SOURCES_VERSION[lib_name][0]}"
+ )
+ try:
+ extract_tar_strip_components(
+ tar_path=file_with_lib_source, destination_path=lib_base_folder, strip_components=1
+ )
+ except Exception as e:
+ raise Exception(f"An error occurred extracting the files from file: {str(file_with_lib_source)}", e)
+
+ flags_to_configure_command: list[str] = [
+ f"--prefix='{str(compiled_library_folder)}'",
+ "--enable-static",
+ "--disable-shared",
+ "--with-pic=yes",
+ "--disable-xmltools",
+ "--disable-desktopfiles",
+ f"--with-jpeg='{str(cast(ExtraDirs, build_dependencies['libjpeg'].extra_dirs).lib_dir)}'",
+ f"--with-tiff='{str(cast(ExtraDirs, build_dependencies['libtiff'].extra_dirs).lib_dir)}'",
+ ]
+ configure_command: str = f"./configure {' '.join(flags_to_configure_command)}"
+ commands: str = " && ".join(
+ [f"cd '{str(lib_base_folder)}'", configure_command, make_build_cmd(), "make install"]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=build_directory)
+ rmdir(file_with_lib_source, lib_base_folder)
+
+ maybe_build_library(
+ skip_build=skip_build,
+ lib_name=lib_name,
+ version=LIBRARIES_SOURCES_VERSION[lib_name][1],
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=[lib_base_folder, compiled_library_folder],
+ build_fn=_build,
+ )
+ pkg_config_info: PkgConfigInfo = PkgConfigInfo(
+ name=["ddjvuapi"], pkg_config_path=pkg_config_folder, static=True
+ )
+ return Library(
+ name=lib_name,
+ compiler_args=get_compiler_args(
+ libraries=[pkg_config_info],
+ extra_paths_to_search=[
+ x.pkg_config_info.pkg_config_path
+ for x in build_dependencies.values()
+ if x.pkg_config_info is not None and x.pkg_config_info.pkg_config_path is not None
+ ],
+ ),
+ pkg_config_info=pkg_config_info,
+ extra_dirs=ExtraDirs(
+ include_dirs=[static_include_folder, static_include_folder.joinpath("libdjvu")],
+ lib_dir=static_lib_folder,
+ ),
+ extra_objects=[static_lib_folder.joinpath("libdjvulibre.a")],
+ )
+
+ def build_openexr(build_dependencies: dict[str, Library], skip_build: bool = skip_build_external_libs) -> Library:
+ return build_cmake_git_library(
+ ctx=ctx,
+ recipe=CMAKE_GIT_RECIPES["openexr"],
+ skip_build=skip_build,
+ dependencies=build_dependencies.values(),
+ )
+
+ def build_libraw(build_dependencies: dict[str, Library], skip_build: bool = skip_build_external_libs) -> Library:
+ """
+ Build libraw statically.
+
+ Dependencies:
+ - zlib
+ - libjpeg
+ """
+ lib_name: str = "libraw"
+ source_folder, compiled_name = _lib_dirs(name=lib_name)
+ lib_base_folder: Path = build_directory.joinpath(source_folder)
+ compiled_library_folder: Path = build_directory.joinpath(compiled_name)
+
+ # Folders with the compiled library and the include files
+ static_include_folder: Path = compiled_library_folder.joinpath(
+ "include"
+ ) # {build_directory}/libraw-{version}-compiled/include
+ static_lib_folder: Path = compiled_library_folder.joinpath(
+ "lib"
+ ) # {build_directory}/libraw-{version}-compiled/lib
+ pkg_config_folder: Path = static_lib_folder.joinpath(
+ "pkgconfig"
+ ) # {build_directory}/libraw-{version}-compiled/lib/pkgconfig
+ artifacts: list[Path] = [static_lib_folder.joinpath("libraw.a"), static_lib_folder.joinpath("libraw_r.a")]
+
+ def _build() -> None:
+ pkg_config_path: list[str] = []
+ for library_dep in build_dependencies.values():
+ if library_dep.pkg_config_info is not None and library_dep.pkg_config_info.pkg_config_path is not None:
+ pkg_config_path.append(str(library_dep.pkg_config_info.pkg_config_path))
+
+ git_clone_command: str = f"git clone {LIBRARIES_SOURCES_VERSION[lib_name][0]} '{str(lib_base_folder)}'"
+ flags_to_configure_command: list[str] = [
+ f"--prefix='{str(compiled_library_folder)}'",
+ "--enable-static",
+ "--disable-shared",
+ "--with-pic=yes",
+ "--enable-examples=no",
+ "--enable-jpeg=yes",
+ "--enable-zlib=yes",
+ "--enable-openmp=no",
+ "--enable-jasper=no",
+ "--enable-lcms=no",
+ ]
+ configure_command: str = f"./configure {' '.join(flags_to_configure_command)}"
+ build_compiler_args: CompilerArgs = CompilerArgs(
+ cflags=build_dependencies["zlib"].compiler_args.cflags
+ + build_dependencies["libjpeg"].compiler_args.cflags,
+ libs=build_dependencies["zlib"].compiler_args.libs + build_dependencies["libjpeg"].compiler_args.libs,
+ )
+ commands: str = " && ".join(
+ [
+ git_clone_command,
+ f"cd '{str(lib_base_folder)}'",
+ f"git switch --detach {LIBRARIES_SOURCES_VERSION[lib_name][1]}",
+ "autoreconf --install",
+ (
+ # LibRaw's sources are C++ (compiled via g++), so CFLAGS alone never reaches the
+ # actual compile commands; CXXFLAGS/CPPFLAGS are what autotools' C++ rules consume.
+ f'CFLAGS="{" ".join(build_compiler_args.cflags)}"'
+ f' CXXFLAGS="{" ".join(build_compiler_args.cflags)}"'
+ f' CPPFLAGS="{" ".join(build_compiler_args.cflags)}"'
+ f' LDFLAGS="{" ".join(build_compiler_args.libs)}" PKG_CONFIG_PATH="{":".join(pkg_config_path)}"'
+ f" {configure_command}"
+ ),
+ make_build_cmd(),
+ "make install",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=build_directory)
+ rmdir(lib_base_folder)
+
+ maybe_build_library(
+ skip_build=skip_build,
+ lib_name=lib_name,
+ version=LIBRARIES_SOURCES_VERSION[lib_name][1],
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=[lib_base_folder, compiled_library_folder],
+ build_fn=_build,
+ )
+ pkg_config_info: PkgConfigInfo = PkgConfigInfo(
+ name=[lib_name, "libraw_r"], pkg_config_path=pkg_config_folder, static=True
+ )
+ return Library(
+ name=lib_name,
+ compiler_args=get_compiler_args(
+ libraries=[pkg_config_info],
+ extra_paths_to_search=[
+ x.pkg_config_info.pkg_config_path
+ for x in build_dependencies.values()
+ if x.pkg_config_info is not None and x.pkg_config_info.pkg_config_path is not None
+ ],
+ ),
+ pkg_config_info=pkg_config_info,
+ extra_dirs=ExtraDirs(
+ include_dirs=[static_include_folder, static_include_folder.joinpath("libraw")],
+ lib_dir=static_lib_folder,
+ ),
+ extra_objects=[static_lib_folder.joinpath("libraw.a"), static_lib_folder.joinpath("libraw_r.a")],
+ )
+
+ base_libraries: dict[str, Library] = {}
+ base_libraries["zlib"] = build_zlib()
+ base_libraries["lzo"] = build_lzo()
+ base_libraries["zstd"] = build_zstd()
+ base_libraries["libjpeg"] = build_libjpeg()
+ base_libraries["jbig"] = build_jbig()
+ base_libraries["liblzma"] = build_liblzma()
+ base_libraries["Imath"] = build_Imath()
+ base_libraries["openjpeg"] = build_openjpeg()
+ base_libraries["pixman"] = build_pixman()
+ base_libraries["libpng"] = build_libpng(zlib=base_libraries["zlib"])
+ base_libraries["libtiff"] = build_libtiff(
+ build_dependencies=(
+ {x: base_libraries[x] for x in base_libraries if x in ("zlib", "zstd", "libjpeg", "jbig", "liblzma")}
+ ),
+ enable_webp=False,
+ )
+ base_libraries["libwebp"] = build_libwebp(
+ build_dependencies=(
+ {x: base_libraries[x] for x in base_libraries if x in ("zlib", "libpng", "libjpeg", "libtiff")}
+ )
+ )
+ base_libraries["libtiff"] = build_libtiff(
+ build_dependencies=(
+ {
+ x: base_libraries[x]
+ for x in base_libraries
+ if x in ("zlib", "zstd", "libjpeg", "jbig", "liblzma", "libwebp")
+ }
+ ),
+ enable_webp=True,
+ )
+ base_libraries["djvulibre"] = build_djvulibre(
+ build_dependencies={x: base_libraries[x] for x in base_libraries if x in ("libjpeg", "libtiff")}
+ )
+ base_libraries["openexr"] = build_openexr(
+ build_dependencies={x: base_libraries[x] for x in base_libraries if x in ("zlib", "Imath")}
+ )
+ base_libraries["libraw"] = build_libraw(
+ build_dependencies={x: base_libraries[x] for x in base_libraries if x in ("zlib", "libjpeg")}
+ )
+
+ final_base_libraries: dict[str, Library] = {}
+ for library_name, library in base_libraries.items():
+ final_base_libraries[library_name] = library._replace(
+ compiler_args=complete_linker_args(library=library, libraries=base_libraries)
+ )
+
+ return final_base_libraries
+
+
+def build_core_libraries(
+ build_directory: Path, skip_build_external_libs: bool, static: bool, base_libraries: dict[str, Library]
+) -> dict[str, Library]:
+ """
+ Build core libraries needed to build the cython extensions.
+
+ Core libraries:
+ - `X11` (shared using pkg-config `x11`): (Also known as libX11) X Window System Protocol client library.
+ - `Xss` (shared using pkg-config `xscrnsaver`): X Screen Saver extension client library.
+ - `Xtst` (shared using pkg-config `xtst`): XTEST extension (synthetic key/button events).
+ - `libgd`: https://github.com/libgd/libgd.git. Depends on `zlib` and `libpng`.
+ - `MagickWand`: https://github.com/ImageMagick/ImageMagick.git. ImageMagick C library, depends on `djvulibre`, `jbig`, `libjpeg`, `liblzma`,
+ `Imath`, `openexr`, `openjpeg` (`libopenjp2`), `libpng`, `libraw`, `libwebp`, `libtiff`, `zlib` and `zstd`.
+ - `resvg`: https://github.com/RazrFalcon/resvg.git.
+ - `cairo`: https://gitlab.freedesktop.org/cairo/cairo.git. Depends on `zlib`, `libpng`, `pixman` and `lzo`.
+ - `libev`: https://github.com/enki/libev.git.
+ """
+ ctx: BuildContext = make_build_context(
+ build_directory=build_directory, skip_build_external_libs=skip_build_external_libs, static=static, debug=debug
+ )
+
+ def build_libgd(build_dependencies: dict[str, Library], _debug_find_libraries: bool = False) -> Library:
+ if not static:
+ return Library(name="libgd", compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["gdlib"])]))
+ return build_cmake_git_library(
+ ctx=ctx,
+ recipe=CORE_CMAKE_RECIPES["libgd"],
+ skip_build=skip_build_external_libs,
+ dependencies=build_dependencies.values(),
+ )
+
+ def build_imagemagick(build_dependencies: dict[str, Library]) -> Library:
+ """
+ Build imagemagick.
+
+ Dependencies (if static):
+ - djvulibre
+ - jbig
+ - libjpeg
+ - liblzma
+ - Imath
+ - openexr
+ - openjpeg (libopenjp2)
+ - libpng
+ - libraw
+ - libwebp
+ - libtiff
+ - zlib
+ - zstd
+ """
+ lib_name: str = "imagemagick"
+
+ if not static:
+ return Library(
+ name=lib_name, compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["MagickWand"])])
+ ) # shared library
+
+ source_folder, compiled_name = _lib_dirs(name=lib_name)
+ lib_base_folder: Path = build_directory.joinpath(source_folder)
+ compiled_library_folder: Path = build_directory.joinpath(compiled_name)
+
+ # Folders with the compiled library and the include files
+ static_lib_folder: Path = compiled_library_folder.joinpath("lib")
+ pkg_config_folder: Path = static_lib_folder.joinpath("pkgconfig")
+ static_include_folder: Path = compiled_library_folder.joinpath("include")
+ artifacts: list[Path] = [
+ static_lib_folder.joinpath("libMagickCore-7.Q16HDRI.a"),
+ static_lib_folder.joinpath("libMagickWand-7.Q16HDRI.a"),
+ ]
+
+ def _build() -> None:
+ pkg_config_path: list[str] = []
+ for library_dep in build_dependencies.values():
+ if library_dep.pkg_config_info is not None and library_dep.pkg_config_info.pkg_config_path is not None:
+ pkg_config_path.append(str(library_dep.pkg_config_info.pkg_config_path))
+
+ git_clone_command: str = f"git clone {LIBRARIES_SOURCES_VERSION[lib_name][0]} '{str(lib_base_folder)}'"
+ flags_to_configure_command: list[str] = [
+ f'--prefix="{str(compiled_library_folder)}"',
+ "--enable-static",
+ "--disable-shared",
+ "--with-pic=yes",
+ "--with-djvu=yes",
+ "--with-jbig=yes",
+ "--with-jpeg=yes",
+ "--with-lzma=yes",
+ "--with-openexr=yes",
+ "--with-openjp2=yes",
+ "--with-png=yes",
+ "--with-raw=yes",
+ "--with-rsvg=no", # librsvg is not used, instead we use resvg
+ "--with-tiff=yes",
+ "--with-webp=yes",
+ "--with-wmf=no", # not included
+ "--with-zlib=yes",
+ "--with-zstd=yes",
+ "--with-xml=no",
+ "--with-bzlib=no",
+ "--with-autotrace=no",
+ "--with-dps=no",
+ "--with-fftw=no",
+ "--with-flif=no",
+ "--with-fpx=no",
+ "--with-fontconfig=no",
+ "--with-freetype=no",
+ "--with-gslib=no",
+ "--with-gvc=no",
+ "--with-heic=no",
+ "--with-lcms=no",
+ "--with-lqr=no",
+ "--with-magick-plus-plus=no",
+ "--with-pango=no",
+ "--with-perl=no",
+ "--with-raqm=no",
+ "--with-x=no",
+ "--with-zip=no",
+ "--with-jxl=no",
+ ]
+ configure_command: str = f"./configure {' '.join(flags_to_configure_command)}"
+
+ cflags: list[str] = []
+ ldflags: list[str] = []
+ for build_dep in build_dependencies.values():
+ cflags.extend(build_dep.compiler_args.cflags)
+ ldflags.extend(build_dep.compiler_args.libs)
+
+ commands: str = " && ".join(
+ [
+ git_clone_command,
+ f"cd '{str(lib_base_folder)}'",
+ f"git switch --detach {LIBRARIES_SOURCES_VERSION[lib_name][1]}",
+ (
+ f'CFLAGS="{" ".join(cflags)}" LIBS="{" ".join(ldflags)}"'
+ f' PKG_CONFIG_LIBDIR="{":".join(pkg_config_path)}" {configure_command}'
+ ),
+ make_build_cmd(),
+ "make install",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=build_directory)
+ rmdir(lib_base_folder)
+
+ maybe_build_library(
+ skip_build=skip_build_external_libs,
+ lib_name=lib_name,
+ version=LIBRARIES_SOURCES_VERSION[lib_name][1],
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=[lib_base_folder, compiled_library_folder],
+ build_fn=_build,
+ )
+ pkg_config_info: PkgConfigInfo = PkgConfigInfo(
+ name=["MagickWand"], pkg_config_path=pkg_config_folder, static=True
+ )
+ return Library(
+ name=lib_name,
+ compiler_args=get_compiler_args(
+ libraries=[pkg_config_info],
+ extra_paths_to_search=[
+ x.pkg_config_info.pkg_config_path
+ for x in build_dependencies.values()
+ if x.pkg_config_info is not None and x.pkg_config_info.pkg_config_path is not None
+ ],
+ ),
+ pkg_config_info=pkg_config_info,
+ extra_dirs=ExtraDirs(include_dirs=[static_include_folder], lib_dir=static_lib_folder),
+ extra_objects=artifacts,
+ )
+
+ def build_resvg() -> Library:
+ return build_cargo_git_library(ctx=ctx, recipe=CARGO_GIT_RECIPES["resvg"], skip_build=skip_build_external_libs)
+
+ def build_cairo(build_dependencies: dict[str, Library]) -> Library:
+ if not static:
+ return Library(name="cairo", compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["cairo"])]))
+ return build_meson_git_library(
+ ctx=ctx,
+ recipe=CORE_MESON_RECIPES["cairo"],
+ skip_build=skip_build_external_libs,
+ dependencies=build_dependencies.values(),
+ )
+
+ def build_libev() -> Library:
+ """Build libev"""
+
+ lib_name: str = "libev"
+ source_folder, compiled_name = _lib_dirs(name=lib_name)
+ lib_base_folder: Path = build_directory.joinpath(source_folder)
+ compiled_library_folder: Path = build_directory.joinpath(compiled_name)
+
+ # Folders with the compiled library and the include files
+ static_lib_folder: Path = compiled_library_folder.joinpath("lib")
+ static_include_folder: Path = compiled_library_folder.joinpath("include")
+ artifacts: list[Path] = [static_lib_folder.joinpath("libev.a")]
+
+ def _build() -> None:
+ url_of_file: str = LIBRARIES_SOURCES_VERSION[lib_name][0]
+ tar_path: Path = Path(f"{lib_base_folder}.tar.gz")
+ _ = urllib.request.urlretrieve(url=url_of_file, filename=str(tar_path))
+ extract_tar_strip_components(tar_path=tar_path, destination_path=lib_base_folder, strip_components=1)
+
+ flags_to_configure_command: list[str] = [
+ f'--prefix="{str(compiled_library_folder)}"',
+ "--enable-static",
+ "--with-pic=yes",
+ ]
+ configure_command: str = f"./configure {' '.join(flags_to_configure_command)}"
+ commands: str = " && ".join(
+ [
+ f"cd '{str(lib_base_folder)}'",
+ configure_command,
+ make_build_cmd(),
+ "make install",
+ ]
+ )
+ run_command_and_show_output(command=f"printf '\\n' && {commands}", cwd=build_directory)
+ rmdir(tar_path, lib_base_folder)
+
+ maybe_build_library(
+ skip_build=skip_build_external_libs,
+ lib_name=lib_name,
+ version=LIBRARIES_SOURCES_VERSION[lib_name][1],
+ artifacts=artifacts,
+ compiled_library_folder=compiled_library_folder,
+ clean_paths=[lib_base_folder, compiled_library_folder],
+ build_fn=_build,
+ )
+ library: Library = Library(
+ name=lib_name,
+ compiler_args=CompilerArgs(
+ cflags=[f"-I{str(static_include_folder)}"],
+ libs=[f"-L{str(static_lib_folder)} -l:libev{'.a' if static else '.so'}"],
+ ),
+ extra_dirs=ExtraDirs(include_dirs=[static_include_folder], lib_dir=static_lib_folder),
+ )
+ if static:
+ library = library._replace(extra_objects=[static_lib_folder.joinpath("libev.a")])
+ return library
+
+ core_libraries: dict[str, Library] = {}
+ core_libraries["x11"] = Library(
+ name="x11", compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["x11"])])
+ )
+ core_libraries["xscrnsaver"] = Library(
+ name="xscrnsaver", compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["xscrnsaver"])])
+ )
+ core_libraries["xext"] = Library(
+ name="xext", compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["xext"])])
+ )
+ core_libraries["xtst"] = Library(
+ name="xtst", compiler_args=get_compiler_args(libraries=[PkgConfigInfo(name=["xtst"])])
+ )
+ core_libraries["libgd"] = build_libgd(
+ build_dependencies={x: base_libraries[x] for x in base_libraries if x in ("zlib", "libpng")} if static else {}
+ )
+ core_libraries["imagemagick"] = build_imagemagick(
+ build_dependencies=(
+ {
+ x: base_libraries[x]
+ for x in base_libraries
+ if x
+ in (
+ "djvulibre",
+ "jbig", # doesn't have pkg-config files
+ "libjpeg",
+ "liblzma",
+ "Imath",
+ "openexr",
+ "openjpeg",
+ "libpng",
+ "libraw",
+ "libwebp",
+ "libtiff",
+ "zlib",
+ "zstd",
+ )
+ }
+ if static
+ else {}
+ )
+ )
+ core_libraries["resvg"] = build_resvg()
+ core_libraries["cairo"] = build_cairo(
+ build_dependencies=(
+ {x: base_libraries[x] for x in base_libraries if x in ("zlib", "libpng", "pixman", "lzo")} if static else {}
+ )
+ )
+ core_libraries["libev"] = build_libev()
+
+ final_core_libraries: dict[str, Library] = core_libraries
+ if static:
+ final_core_libraries = {}
+ for library_name, library in core_libraries.items():
+ final_core_libraries[library_name] = library._replace(
+ compiler_args=complete_linker_args(
+ library=library, libraries=dict(list(base_libraries.items()) + list(core_libraries.items()))
+ )
+ )
+
+ return final_core_libraries
+
+
+def _cythonize_extensions(module_list: list[Extension], **options: Any) -> list[Extension]:
+ cythonize: Callable[..., list[Extension]] = cast(
+ Callable[..., list[Extension]],
+ __import__(name="Cython.Build.Dependencies", fromlist=["cythonize"]).cythonize,
+ )
+ return cythonize(module_list=module_list, **options)
+
+
+def cythonize_cython_extensions(
+ build_directory: Path,
+ static: bool,
+ skip_build_external_libs: bool,
+ force_cython: bool = False,
+ validate_cached_libs: bool = False,
+) -> list[Extension]:
+ """
+ Function that cythonize all the extensions to use with orcsome3.
+
+ It takes all the cython files and produce a .c file ready to be compiled
+ """
+ extensions: list[Extension] = []
+ try:
+ if skip_build_external_libs and static and validate_cached_libs:
+ validate_skipped_external_build(build_directory=build_directory)
+
+ # Build base libraries, needed to build the core dependencies of the application
+ base_libraries: dict[str, Library] = (
+ build_base_libraries(build_directory=build_directory, skip_build_external_libs=skip_build_external_libs)
+ if static
+ else {}
+ )
+
+ # Build core libraries, these are the actual dependencies of the application
+ core_libraries: dict[str, Library] = build_core_libraries(
+ build_directory=build_directory,
+ skip_build_external_libs=skip_build_external_libs,
+ static=static,
+ base_libraries=base_libraries,
+ )
+
+ # Get compile and link args
+ extra_compile_args: list[str] = []
+ extra_link_args: list[str] = []
+ for library in core_libraries.values():
+ for cflag in library.compiler_args.cflags:
+ extra_compile_args.extend([x.strip() for x in cflag.split(sep=" ")])
+ for ldflag in library.compiler_args.libs:
+ extra_link_args.extend([x.strip() for x in ldflag.split(sep=" ")])
+
+ # Add static libraries to the final .so
+ extra_objects: list[str] = []
+ extra_library_dirs: list[str] = []
+ for library in list(base_libraries.values()) + list(core_libraries.values()):
+ if library.extra_dirs is not None:
+ extra_library_dirs.extend([str(x) for x in library.extra_dirs.iter_dirs()])
+ if len(library.extra_objects):
+ extra_objects.extend([str(x) for x in library.extra_objects])
+
+ if not CYTHON_BACKEND_PYX.is_file():
+ raise FileNotFoundError(f"Cython backend source not found: {CYTHON_BACKEND_PYX}")
+
+ cython_source_files: list[Path] = [CYTHON_BACKEND_PYX]
+ extensions = _cythonize_extensions(
+ module_list=[
+ Extension(
+ name=f"{APPNAME}_backend", # Name of the produced shared library (.so), it should be: orcsome3_backend
+ language="c",
+ library_dirs=extra_library_dirs,
+ sources=[str(x) for x in cython_source_files],
+ extra_compile_args=extra_compile_args,
+ extra_link_args=extra_link_args,
+ extra_objects=extra_objects,
+ )
+ ],
+ compiler_directives={"language_level": "3"},
+ include_path=[str(CYTHON_LIBS_DIR)],
+ force=force_cython,
+ )
+ for index, compiled_extension in enumerate(iterable=extensions):
+ sources: list[str] = [str(s) for s in compiled_extension.sources]
+ for other_index, source_file in enumerate(iterable=sources):
+ path_source_file: Path = Path(source_file)
+ new_path_source_file: Path = build_directory.joinpath(path_source_file.name)
+ _ = shutil.move(src=path_source_file, dst=new_path_source_file)
+ sources[other_index] = str(new_path_source_file)
+ extensions[index].sources = sources
+ return extensions
+ except Exception as e:
+ print("Exception in function 'cythonize_cython_extensions':", e)
+ traceback.print_tb(tb=e.__traceback__)
+ raise
+
+
+def build_shared_library(
+ extensions: list[Extension],
+ directory: Path,
+ keep_c_files: bool,
+ install_dir: Optional[Path] = None,
+) -> None:
+ """Build final .so in `directory`"""
+ install_destination: Optional[Path] = install_dir.resolve() if install_dir is not None else None
+
+ def build_shared_library_() -> None:
+ # Remove .so files
+ files_to_remove: list[Path] = []
+ extension_names: list[str] = [x.name for x in extensions]
+ for shared_library in directory.rglob(pattern="*.so"):
+ if shared_library.name in extension_names or any(
+ substring in shared_library.name for substring in extension_names
+ ):
+ files_to_remove.append(shared_library)
+ clean_build_directory(build_directory=directory, remove=files_to_remove)
+ _ = setup(zip_safe=False, ext_modules=extensions, script_args=["build_ext", "--inplace"])
+ files_to_remove_after_building_extensions: list[Path] = [directory.joinpath("build")]
+ if not keep_c_files:
+ for extension in extensions:
+ for source_file in extension.sources:
+ files_to_remove_after_building_extensions.append(Path(source_file))
+ clean_build_directory(build_directory=directory, remove=files_to_remove_after_building_extensions)
+ if install_destination is not None:
+ install_backend_for_local_dev(library_directory=directory, destination=install_destination)
+
+ run_function_in_another_directory(directory=directory, function=build_shared_library_)
+
+
+def build_extensions(
+ skip_build_external_libs: bool,
+ static: bool = True,
+ build_dir: Optional[Path] = None,
+ force_rebuild: bool = False,
+ force_cython: bool = False,
+ validate_cached_libs: bool = False,
+) -> tuple[Path, list[Extension]]:
+ """
+ Create the build directory and prepare cython extensions.
+
+ - skip_build_external_libs=True: reuse static libraries already in the build directory.
+ - force_rebuild=True: wipe the build directory and rebuild external libraries.
+ - force_cython=True: always regenerate C files from .pyx sources.
+ """
+ # cmake/meson --prefix is relative to their cwd; a relative `--build-dir .` nested the install tree.
+ build_directory: Path = (Path.cwd() if build_dir is None else build_dir).resolve()
+ new_build_directory: Path = build_directory.joinpath(f"{APPNAME}_built_libraries")
+ if skip_build_external_libs:
+ pass
+ elif force_rebuild or env_force_rebuild():
+ rmdir(new_build_directory)
+ if not new_build_directory.is_dir():
+ print("\nThe build directory does not exist, creating it...\n")
+ new_build_directory.mkdir(exist_ok=True, parents=True)
+ return new_build_directory, cythonize_cython_extensions(
+ build_directory=new_build_directory,
+ static=static,
+ skip_build_external_libs=skip_build_external_libs,
+ force_cython=force_cython,
+ validate_cached_libs=validate_cached_libs,
+ )
+
+
+def main() -> None:
+ """
+ Function used to build the shared library (.so) needed for the application.
+
+ It should be used as a module: `python -m orcsome3.libs.build --build-dir /build/dir`.
+
+ The parameter `--build-dir` defaults to current working directory if not specified.
+
+ It requires `git`, `cargo`, `cmake`, `autoreconf`, `meson` and `make` commands to be available on PATH when downloading and building the external libraries statically.
+ """
+ parser: argparse.ArgumentParser = argparse.ArgumentParser()
+ _ = parser.add_argument(
+ "-b",
+ "--build-dir",
+ help="Build directory of the libraries of the application. Defaults to current working directory",
+ type=Path,
+ default=Path.cwd(),
+ )
+ _ = parser.add_argument(
+ "-d",
+ "--dynamic",
+ help=(
+ "Build shared library (.so) using dynamic external libraries. By default, the final shared library is"
+ " compiled with the external libraries statically"
+ ),
+ default=False,
+ action="store_true",
+ )
+ _ = parser.add_argument(
+ "-s",
+ "--skip-build-external-libs",
+ help=(
+ "Skip downloading and building external libraries; only re-cythonize and re-link the backend "
+ "(requires a previous full build in the build directory)"
+ ),
+ default=False,
+ action="store_true",
+ )
+ _ = parser.add_argument(
+ "-f",
+ "--force-rebuild",
+ help="Delete the build directory and rebuild all external libraries from scratch",
+ default=False,
+ action="store_true",
+ )
+ _ = parser.add_argument(
+ "-k",
+ "--keep-c-files",
+ help=(
+ "Keep the generated .c files produced when cythonizing the cython files. By default the generated .c file"
+ + " gets deleted. Defaults to False"
+ ),
+ default=False,
+ action="store_true",
+ )
+ _ = parser.add_argument("--debug", help="Show debug information", default=False, action="store_true")
+ args: argparse.Namespace = parser.parse_args()
+ project_directory: Path = args.build_dir
+ build_using_dynamic_libraries: bool = args.dynamic
+ skip_build_external_libs: bool = args.skip_build_external_libs
+ force_rebuild_libraries: bool = args.force_rebuild
+ if force_rebuild_libraries and skip_build_external_libs:
+ print("Error: --force-rebuild cannot be used together with --skip-build-external-libs.")
+ return
+ if force_rebuild_libraries:
+ # Also bypass the persistent ~/.cache/orcsome3/libs cache (cache.py's force_rebuild()), not just
+ # the build directory — otherwise libraries just get restored from cache, not actually rebuilt.
+ os.environ["ORCSOME3_FORCE_REBUILD"] = "1"
+ if not skip_build_external_libs:
+ needed_apps: list[str] = ["git", "cargo", "make"]
+ if not build_using_dynamic_libraries:
+ needed_apps.extend(["cmake", "autoreconf", "meson"])
+ for needed_command in needed_apps:
+ if not shutil.which(cmd=needed_command):
+ print(f"Error: command {needed_command} not found.")
+ return
+ keep_c_files: bool = args.keep_c_files
+ global debug
+ debug = args.debug
+ # `.so` is two steps: cythonize first (static libs + .pyx → setuptools Extension / .c),
+ # then compile/link that Extension into orcsome3_backend*.so and copy it to the project dir.
+ library_directory, extensions = build_extensions(
+ skip_build_external_libs=skip_build_external_libs,
+ static=not build_using_dynamic_libraries,
+ build_dir=project_directory,
+ force_rebuild=force_rebuild_libraries,
+ force_cython=True,
+ validate_cached_libs=skip_build_external_libs,
+ )
+ build_shared_library(
+ extensions=extensions,
+ directory=library_directory,
+ keep_c_files=keep_c_files,
+ install_dir=project_directory,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/orcsome3/libs/build/paths.py b/orcsome3/libs/build/paths.py
new file mode 100644
index 0000000..a965455
--- /dev/null
+++ b/orcsome3/libs/build/paths.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+"""Locations of the Cython backend sources (`orcsome3/libs/cython_libs`)."""
+
+from pathlib import Path
+
+# orcsome3/libs/build/paths.py -> orcsome3/libs/cython_libs
+CYTHON_LIBS_DIR: Path = Path(__file__).resolve().parent.parent / "cython_libs"
+CYTHON_BACKEND_PYX: Path = CYTHON_LIBS_DIR / "orcsome3_backend.pyx"
diff --git a/orcsome3/libs/build/pkgconfig.py b/orcsome3/libs/build/pkgconfig.py
new file mode 100644
index 0000000..939a164
--- /dev/null
+++ b/orcsome3/libs/build/pkgconfig.py
@@ -0,0 +1,93 @@
+"""Run pkg-config and stitch static linker flags for bundled native libraries."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from orcsome3.libs.build.types import CompilerArgs, ExtraDirs, Library, PkgConfigInfo
+
+
+def get_compiler_args(
+ libraries: list[PkgConfigInfo], extra_paths_to_search: Optional[list[Path]] = None
+) -> CompilerArgs:
+ """Collect `pkg-config --cflags/--libs` for each entry, honoring extra `PKG_CONFIG_PATH` dirs."""
+ result_cflags: list[str] = []
+ result_libs: list[str] = []
+
+ pkg_config_paths: list[str] = []
+ if extra_paths_to_search is not None and len(extra_paths_to_search):
+ pkg_config_paths.extend([str(x) for x in extra_paths_to_search])
+ pkg_config_path_original_value: Optional[str] = os.getenv(key="PKG_CONFIG_PATH")
+
+ for library in libraries:
+ if not len(library.name):
+ continue
+
+ if library.pkg_config_path is not None:
+ pkg_config_paths.append(str(library.pkg_config_path))
+ if pkg_config_path_original_value is not None:
+ pkg_config_paths.append(pkg_config_path_original_value)
+
+ cmd_cflags_command: str = f"pkg-config --cflags {' '.join(library.name)}"
+ if len(pkg_config_paths):
+ cmd_cflags_command = f'PKG_CONFIG_PATH="{":".join(pkg_config_paths)}" {cmd_cflags_command}'
+ process_cflags: subprocess.CompletedProcess[str] = subprocess.run(
+ args=["bash", "-c", cmd_cflags_command], capture_output=True, text=True, encoding="utf-8"
+ )
+ result_cflags.extend([x for x in process_cflags.stdout.replace("\n", "").split(sep=" ") if len(x.strip())])
+
+ cmd_libs_command: str = "pkg-config --libs"
+ if library.static:
+ cmd_libs_command += " --static"
+ cmd_libs_command += f" {' '.join(library.name)}"
+ if len(pkg_config_paths):
+ cmd_libs_command = f'PKG_CONFIG_PATH="{":".join(pkg_config_paths)}" {cmd_libs_command}'
+ process_libs: subprocess.CompletedProcess[str] = subprocess.run(
+ args=["bash", "-c", cmd_libs_command], capture_output=True, text=True, encoding="utf-8"
+ )
+ result_libs.extend([x for x in process_libs.stdout.replace("\n", "").split(sep=" ") if len(x.strip())])
+
+ return CompilerArgs(cflags=result_cflags, libs=result_libs)
+
+
+def complete_linker_args(library: Library, libraries: dict[str, Library]) -> CompilerArgs:
+ """Rewrite `-lfoo` flags to the matching static archive path when we built that dep."""
+ cflags: list[str] = library.compiler_args.cflags
+ ldflags: list[str] = library.compiler_args.libs
+ for index, ldflag in enumerate(iterable=library.compiler_args.libs):
+ if not ldflag.startswith("-l") or index == 0:
+ continue
+ library_info: Optional[ExtraDirs] = None
+ for lib in libraries.values():
+ if not len(lib.extra_objects) or lib.extra_dirs is None:
+ continue
+ for static_lib in lib.extra_objects:
+ stem: str = static_lib.stem
+ ldname: str = stem[3:] if stem.startswith("lib") else stem
+ if f"-l{ldname}" == ldflag:
+ library_info = lib.extra_dirs
+ break
+ if library_info is not None:
+ break
+ if library_info is None:
+ continue
+
+ if len(library_info.include_dirs):
+ for include_dir in library_info.include_dirs:
+ if not include_dir.is_dir():
+ continue
+ if f"-I{str(include_dir)}" not in cflags:
+ cflags.append(f"-I{str(include_dir)}")
+ for inner_include in include_dir.iterdir():
+ if inner_include.is_file() or f"-I{str(inner_include)}" in cflags:
+ continue
+ cflags.append(f"-I{str(inner_include)}")
+
+ if library.compiler_args.libs[index - 1] != f"-L{str(library_info.lib_dir)}":
+ if f"-L{str(library_info.lib_dir)}" not in library.compiler_args.libs[index]:
+ ldflags[index] = f"-L{str(library_info.lib_dir)} {ldflags[index]}"
+
+ return library.compiler_args._replace(cflags=cflags, libs=" ".join(ldflags).split(sep=" "))
diff --git a/orcsome3/libs/build/recipes.py b/orcsome3/libs/build/recipes.py
new file mode 100644
index 0000000..532ac82
--- /dev/null
+++ b/orcsome3/libs/build/recipes.py
@@ -0,0 +1,295 @@
+"""Library source versions and declarative build recipes (used by engine.py)."""
+
+from pathlib import Path
+from typing import Any
+
+from orcsome3.libs.build.engine import (
+ CargoGitRecipe,
+ CMakeGitRecipe,
+ MesonGitRecipe,
+)
+from orcsome3.libs.build.helpers import versioned_folder
+
+LIBRARIES_SOURCES_VERSION: dict[str, tuple[str, str]] = {
+ "zlib": ("https://github.com/madler/zlib.git", "v1.3.1"),
+ "lzo": ("https://www.oberhumer.com/opensource/lzo/download/lzo-2.10.tar.gz", "v2.10"),
+ "zstd": ("https://github.com/facebook/zstd.git", "v1.5.7"),
+ "libjpeg": ("https://github.com/libjpeg-turbo/libjpeg-turbo.git", "3.1.1"),
+ "jbig": ("https://www.cl.cam.ac.uk/~mgk25/git/jbigkit", "v2.1"),
+ "liblzma": ("https://github.com/tukaani-project/xz.git", "v5.8.1"),
+ "Imath": ("https://github.com/AcademySoftwareFoundation/Imath.git", "v3.1.12"),
+ "openjpeg": ("https://github.com/uclouvain/openjpeg.git", "v2.5.3"),
+ "libpng": ("https://github.com/pnggroup/libpng.git", "v1.6.49"),
+ "libwebp": ("https://chromium.googlesource.com/webm/libwebp", "v1.5.0"),
+ "pixman": ("https://gitlab.freedesktop.org/pixman/pixman.git", "pixman-0.46.2"),
+ "libtiff": ("https://gitlab.com/libtiff/libtiff.git", "v4.7.0"),
+ "djvulibre": (
+ "https://sourceforge.net/projects/djvu/files/DjVuLibre/3.5.28/djvulibre-3.5.28.tar.gz/download",
+ "v3.5.28",
+ ),
+ "openexr": ("https://github.com/AcademySoftwareFoundation/openexr", "v3.3.4"),
+ "libraw": ("https://github.com/LibRaw/LibRaw.git", "0.21.4"),
+ "libgd": ("https://github.com/libgd/libgd.git", "gd-2.3.3"),
+ "cairo": ("https://gitlab.freedesktop.org/cairo/cairo.git", "1.17.8"),
+ "resvg": ("https://github.com/RazrFalcon/resvg.git", "v0.30.0"),
+ "libev": ("http://dist.schmorp.de/libev/libev-4.33.tar.gz", "4.33"),
+ "imagemagick": ("https://github.com/ImageMagick/ImageMagick.git", "7.1.1-47"),
+}
+
+
+def _keep_static_archives_only(lib_dir: Path) -> None:
+ for library_file in lib_dir.iterdir():
+ if library_file.is_dir():
+ continue
+ if not library_file.name.endswith(".a"):
+ library_file.unlink()
+
+
+def _patch_libwebp_tiff_static_linking(source_dir: Path) -> None:
+ """Allow libwebp to link statically against libtiff (upstream disables this by default)."""
+ file_to_edit: Path = source_dir / "cmake" / "deps.cmake"
+ lines: list[str] = file_to_edit.read_text().splitlines(keepends=True)
+ for index, line in enumerate(lines):
+ if '"TIFF is disabled when statically linking."' in line:
+ lines[index + 1] = f"#{lines[index + 1]}"
+ break
+ _ = file_to_edit.write_text(data="".join(lines))
+
+
+def _recipe(name: str, **kwargs: Any) -> CMakeGitRecipe:
+ url, tag = LIBRARIES_SOURCES_VERSION[name]
+ folder: str = kwargs.pop("source_folder", versioned_folder(name=name, version=tag))
+ return CMakeGitRecipe(name=name, url=url, tag=tag, source_folder=folder, **kwargs)
+
+
+CMAKE_GIT_RECIPES: dict[str, CMakeGitRecipe] = {
+ "zlib": _recipe(
+ name="zlib",
+ pkg_config_names=["zlib"],
+ pkg_config_subpath=("share", "pkgconfig"),
+ artifacts=["libz.a"],
+ cmake_flags=["-DBUILD_SHARED_LIBS=OFF"],
+ post_install_cleanup=_keep_static_archives_only,
+ ),
+ "Imath": _recipe(
+ name="Imath",
+ pkg_config_names=["Imath"],
+ include_subdirs=["include", "include/Imath"],
+ artifacts=["libImath-3_1.a"],
+ cmake_flags=["-DBUILD_SHARED_LIBS=OFF", "-DPYTHON=OFF", "-DBUILD_TESTING=OFF"],
+ cmake_dirs=["cmake/Imath"],
+ ),
+ "openjpeg": _recipe(
+ name="openjpeg",
+ pkg_config_names=["libopenjp2"],
+ include_subdirs=["include", "include/openjpeg-2.5"],
+ artifacts=["libopenjp2.a"],
+ cmake_flags=[
+ "-DBUILD_SHARED_LIBS=OFF",
+ "-DBUILD_STATIC_LIBS=ON",
+ "-DBUILD_DOC=OFF",
+ "-DBUILD_CODEC=OFF",
+ "-DBUILD_JPIP=OFF",
+ "-DBUILD_VIEWER=OFF",
+ "-DBUILD_JAVA=OFF",
+ ],
+ cmake_dirs=["cmake/openjpeg-2.5"],
+ ),
+ "liblzma": _recipe(
+ name="liblzma",
+ include_subdirs=["include", "include/lzma"],
+ artifacts=["liblzma.a"],
+ cmake_flags=[
+ "-DBUILD_SHARED_LIBS=OFF",
+ "-DXZ_TOOL_XZDEC=OFF",
+ "-DXZ_TOOL_LZMADEC=OFF",
+ "-DXZ_TOOL_LZMAINFO=OFF",
+ "-DXZ_TOOL_XZ=OFF",
+ "-DXZ_TOOL_SYMLINKS_LZMA=OFF",
+ "-DXZ_TOOL_SCRIPTS=OFF",
+ "-DXZ_DOC=OFF",
+ ],
+ cmake_dirs=["cmake/liblzma"],
+ ),
+ "libjpeg": _recipe(
+ name="libjpeg",
+ artifacts=["libjpeg.a"],
+ cmake_flags=[
+ "-DENABLE_SHARED=FALSE",
+ "-DENABLE_STATIC=TRUE",
+ "-DWITH_TURBOJPEG=OFF",
+ # libjpeg-turbo's GNUInstallDirs picks lib64 vs lib based on the distro (e.g. Fedora vs
+ # Debian/Ubuntu); force plain "lib" to match lib_subdir/pkg_config_subpath's default.
+ "-DCMAKE_INSTALL_LIBDIR=lib",
+ ],
+ cmake_dirs=["cmake/libjpeg-turbo"],
+ ),
+ "zstd": _recipe(
+ name="zstd",
+ pkg_config_names=["libzstd"],
+ artifacts=["libzstd.a"],
+ pre_build_steps=["cd build", "cd cmake"],
+ cmake_source=".",
+ post_install_cleanup=_keep_static_archives_only,
+ cmake_dirs=["cmake/zstd"],
+ ),
+ "libpng": _recipe(
+ name="libpng",
+ include_subdirs=["include", "include/libpng16"],
+ artifacts=["libpng.a", "libpng16.a"],
+ cmake_flags=[
+ "-DPNG_SHARED=OFF",
+ "-DPNG_STATIC=ON",
+ "-DPNG_EXECUTABLES=OFF",
+ "-DPNG_TESTS=OFF",
+ ],
+ cmake_dirs=["libpng", "cmake/PNG"],
+ ),
+ "openexr": _recipe(
+ name="openexr",
+ pkg_config_names=["OpenEXR"],
+ include_subdirs=["include", "include/OpenEXR"],
+ artifacts=[
+ "libIex-3_3.a",
+ "libIlmThread-3_3.a",
+ "libOpenEXR-3_3.a",
+ "libOpenEXRCore-3_3.a",
+ "libOpenEXRUtil-3_3.a",
+ ],
+ cmake_flags=["-DBUILD_SHARED_LIBS=OFF", "-DOPENEXR_INSTALL_TOOLS=OFF", "-DBUILD_DOCS=OFF"],
+ cmake_dirs=["cmake"],
+ ),
+ "libwebp": _recipe(
+ name="libwebp",
+ include_subdirs=["include", "include/webp", "include/webp/sharpyuv"],
+ cmake_dirs=["share/WebP/cmake"],
+ artifacts=[
+ "libwebp.a",
+ "libsharpyuv.a",
+ "libwebpdecoder.a",
+ "libwebpdemux.a",
+ "libwebpmux.a",
+ ],
+ cmake_flags=[
+ "-DBUILD_SHARED_LIBS=OFF",
+ "-DWEBP_BUILD_LIBWEBPMUX=ON",
+ "-DWEBP_BUILD_WEBPMUX=ON",
+ "-DWEBP_BUILD_ANIM_UTILS=OFF",
+ "-DWEBP_BUILD_CWEBP=OFF",
+ "-DWEBP_BUILD_DWEBP=OFF",
+ "-DWEBP_BUILD_GIF2WEBP=OFF",
+ "-DWEBP_BUILD_IMG2WEBP=OFF",
+ "-DWEBP_BUILD_VWEBP=OFF",
+ "-DWEBP_BUILD_WEBPINFO=OFF",
+ "-DWEBP_BUILD_EXTRAS=OFF",
+ ],
+ post_clone_hook=_patch_libwebp_tiff_static_linking,
+ ),
+ "libtiff": _recipe(
+ name="libtiff",
+ pkg_config_names=["libtiff-4"],
+ cmake_dirs=["cmake/tiff"],
+ artifacts=["libtiff.a"],
+ cmake_flags=[
+ "-DBUILD_SHARED_LIBS=OFF",
+ "-Dzlib=ON",
+ "-Dzstd=ON",
+ "-Djpeg=ON",
+ "-Djbig=ON",
+ "-Dlzma=ON",
+ "-Dlibdeflate=OFF",
+ "-Dpixarlog=OFF",
+ "-Dlerc=OFF",
+ "-Dcxx=OFF",
+ "-Dtiff-opengl=OFF",
+ "-Dtiff-tools=OFF",
+ "-Dtiff-tests=OFF",
+ "-Dtiff-contrib=OFF",
+ "-Dtiff-docs=OFF",
+ ],
+ ),
+}
+
+MESON_GIT_RECIPES: dict[str, MesonGitRecipe] = {
+ "pixman": MesonGitRecipe(
+ name="pixman",
+ url=LIBRARIES_SOURCES_VERSION["pixman"][0],
+ tag=LIBRARIES_SOURCES_VERSION["pixman"][1],
+ source_folder=versioned_folder(
+ name="pixman", version=LIBRARIES_SOURCES_VERSION["pixman"][1].removeprefix("pixman-")
+ ),
+ pkg_config_names=["pixman-1"],
+ include_subdirs=["include", "include/pixman-1"],
+ artifacts=["libpixman-1.a"],
+ meson_flags=[
+ "--default-library static",
+ # On Debian/Ubuntu, meson defaults libdir to the multiarch path (lib/x86_64-linux-gnu);
+ # pkg_config_subpath (engine.py) assumes plain "lib", so force it to match.
+ "--libdir=lib",
+ "-Db_staticpic=true",
+ "-Dopenmp=disabled",
+ "-Dgtk=disabled",
+ "-Dlibpng=disabled",
+ "-Dtests=disabled",
+ ],
+ ),
+}
+
+CARGO_GIT_RECIPES: dict[str, CargoGitRecipe] = {
+ "resvg": CargoGitRecipe(
+ name="resvg",
+ url=LIBRARIES_SOURCES_VERSION["resvg"][0],
+ tag=LIBRARIES_SOURCES_VERSION["resvg"][1],
+ source_folder=versioned_folder(name="resvg", version=LIBRARIES_SOURCES_VERSION["resvg"][1]),
+ ),
+}
+
+CORE_CMAKE_RECIPES: dict[str, CMakeGitRecipe] = {
+ "libgd": CMakeGitRecipe(
+ name="libgd",
+ url=LIBRARIES_SOURCES_VERSION["libgd"][0],
+ tag=LIBRARIES_SOURCES_VERSION["libgd"][1],
+ source_folder=versioned_folder(name="libgd", version=LIBRARIES_SOURCES_VERSION["libgd"][1]),
+ pkg_config_names=["gdlib"],
+ artifacts=["libgd.a"],
+ cmake_flags=[
+ "-DENABLE_PNG=ON",
+ "-DENABLE_CPP=OFF",
+ "-DBUILD_SHARED_LIBS=OFF",
+ "-DBUILD_STATIC_LIBS=ON",
+ "-DBUILD_TEST=OFF",
+ ],
+ ),
+}
+
+CORE_MESON_RECIPES: dict[str, MesonGitRecipe] = {
+ "cairo": MesonGitRecipe(
+ name="cairo",
+ url=LIBRARIES_SOURCES_VERSION["cairo"][0],
+ tag=LIBRARIES_SOURCES_VERSION["cairo"][1],
+ source_folder=versioned_folder(name="cairo", version=LIBRARIES_SOURCES_VERSION["cairo"][1]),
+ pkg_config_names=["cairo"],
+ artifacts=["libcairo.a"],
+ meson_flags=[
+ "--default-library static",
+ # On Debian/Ubuntu, meson defaults libdir to the multiarch path (lib/x86_64-linux-gnu);
+ # pkg_config_subpath (engine.py) assumes plain "lib", so force it to match.
+ "--libdir=lib",
+ "-Db_staticpic=true",
+ "-Dfontconfig=disabled",
+ "-Dfreetype=disabled",
+ "-Dpng=enabled",
+ "-Dquartz=disabled",
+ "-Dxcb=disabled",
+ "-Dxlib=disabled",
+ "-Dzlib=disabled",
+ "-Dtests=disabled",
+ "-Dpixman:tests=disabled",
+ "-Dgtk2-utils=disabled",
+ "-Dglib=disabled",
+ "-Dspectre=disabled",
+ "-Dsymbol-lookup=disabled",
+ "-Dgtk_doc=false",
+ ],
+ ),
+}
diff --git a/orcsome3/libs/build/types.py b/orcsome3/libs/build/types.py
new file mode 100644
index 0000000..bf8d1b1
--- /dev/null
+++ b/orcsome3/libs/build/types.py
@@ -0,0 +1,48 @@
+"""Named tuples describing a native library (pkg-config names, include/lib dirs, objects)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Generator, NamedTuple, Optional
+
+
+class PkgConfigInfo(NamedTuple):
+ """pkg-config module names, optional extra search path, and whether to request `--static`."""
+
+ name: list[str]
+ pkg_config_path: Optional[Path] = None
+ static: bool = False
+
+
+class CompilerArgs(NamedTuple):
+ """`cflags` and `libs` lists as pkg-config would emit them."""
+
+ cflags: list[str]
+ libs: list[str]
+
+
+class ExtraDirs(NamedTuple):
+ """Include, lib, and optional cmake prefix directories for a built static library."""
+
+ include_dirs: list[Path]
+ lib_dir: Path
+ cmake_dirs: Optional[list[Path]] = None
+
+ def iter_dirs(self) -> Generator[Path, Any, Any]:
+ """Yield include dirs, the lib dir, then optional cmake prefix dirs."""
+ list_of_dirs: list[Path] = list(self.include_dirs)
+ list_of_dirs.append(self.lib_dir)
+ if self.cmake_dirs is not None:
+ list_of_dirs.extend(self.cmake_dirs)
+ for dir_ in list_of_dirs:
+ yield dir_
+
+
+class Library(NamedTuple):
+ """One compiled (or system) library: name, compiler args, optional pkg-config and extra dirs/objects."""
+
+ name: str
+ compiler_args: CompilerArgs
+ pkg_config_info: Optional[PkgConfigInfo] = None
+ extra_dirs: Optional[ExtraDirs] = None
+ extra_objects: list[Path] = []
diff --git a/orcsome3-stubs/orcsome/__init__.py b/orcsome3/libs/cython_libs/__init__.py
similarity index 100%
rename from orcsome3-stubs/orcsome/__init__.py
rename to orcsome3/libs/cython_libs/__init__.py
diff --git a/orcsome3/libs/cython_libs/cairo/__init__.pxd b/orcsome3/libs/cython_libs/cairo/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/orcsome3/libs/cython_libs/cairo/cairo.pxd b/orcsome3/libs/cython_libs/cairo/cairo.pxd
new file mode 100644
index 0000000..007ec30
--- /dev/null
+++ b/orcsome3/libs/cython_libs/cairo/cairo.pxd
@@ -0,0 +1,25 @@
+# External libraries
+
+cdef extern from "cairo.h": # pkg-config: cairo
+ ctypedef struct cairo_surface_t:
+ pass
+ ctypedef enum cairo_format_t:
+ CAIRO_FORMAT_INVALID
+ CAIRO_FORMAT_ARGB32
+ CAIRO_FORMAT_RGB24
+ CAIRO_FORMAT_A8
+ CAIRO_FORMAT_A1
+ CAIRO_FORMAT_RGB16_565
+ CAIRO_FORMAT_RGB30
+ CAIRO_FORMAT_RGB96F
+ CAIRO_FORMAT_RGBA128F
+ ctypedef enum cairo_status_t:
+ CAIRO_STATUS_SUCCESS
+ ctypedef cairo_status_t (*cairo_write_func_t)(void *closure, const unsigned char *data, unsigned int length) # type: ignore
+
+ cairo_surface_t *cairo_image_surface_create(cairo_format_t format, int width, int height)
+ unsigned char *cairo_image_surface_get_data(cairo_surface_t *surface)
+ void cairo_surface_destroy(cairo_surface_t *surface)
+ cairo_status_t cairo_surface_write_to_png_stream(
+ cairo_surface_t *surface, cairo_write_func_t write_func, void *closure
+ )
diff --git a/orcsome3/libs/cython_libs/imagemagick/__init__.pxd b/orcsome3/libs/cython_libs/imagemagick/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/orcsome3/libs/cython_libs/imagemagick/imagemagick.pxd b/orcsome3/libs/cython_libs/imagemagick/imagemagick.pxd
new file mode 100644
index 0000000..8a6af8c
--- /dev/null
+++ b/orcsome3/libs/cython_libs/imagemagick/imagemagick.pxd
@@ -0,0 +1,25 @@
+from libc.stddef cimport size_t
+
+# External libraries
+
+cdef extern from "MagickWand/MagickWand.h": # pkg-config: MagickWand
+ ctypedef struct ImageInfo:
+ pass
+ ctypedef struct MagickWand:
+ pass
+ ctypedef struct PixelWand:
+ pass
+
+ void MagickWandGenesis()
+ void MagickWandTerminus()
+ MagickWand *NewMagickWand()
+ PixelWand *NewPixelWand()
+ MagickWand *DestroyMagickWand(MagickWand *wand)
+ PixelWand *DestroyPixelWand(PixelWand *wand)
+ int PixelSetColor(PixelWand *wand, const char *color)
+ int MagickSetBackgroundColor(MagickWand *wand, const PixelWand *background)
+ int MagickPingImage(MagickWand *wand, const char *filename)
+ int MagickReadImage(MagickWand *wand, const char *filename)
+ int MagickSetImageFormat(MagickWand *wand, const char *format)
+ unsigned char *MagickGetImageBlob(MagickWand *wand, size_t *size)
+ void *MagickRelinquishMemory(void *memory)
diff --git a/orcsome3/libs/cython_libs/libev/__init__.pxd b/orcsome3/libs/cython_libs/libev/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/orcsome3/libs/cython_libs/libev/libev.pxd b/orcsome3/libs/cython_libs/libev/libev.pxd
new file mode 100644
index 0000000..5d5de6b
--- /dev/null
+++ b/orcsome3/libs/cython_libs/libev/libev.pxd
@@ -0,0 +1,67 @@
+# External libraries
+
+cdef extern from "ev.h":
+ int EV_READ
+ int EV_WRITE
+
+ int EVBREAK_ALL
+ int EVBREAK_ONE
+ int EVBREAK_CANCEL
+
+ int EVFLAG_AUTO
+ int EVFLAG_NOENV
+ int EVFLAG_FORKCHECK
+ int EVFLAG_NOINOTIFY
+ int EVFLAG_SIGNALFD
+ int EVFLAG_NOSIGMASK
+ int EVBACKEND_SELECT
+ int EVBACKEND_POLL
+ int EVBACKEND_EPOLL
+ int EVBACKEND_KQUEUE
+ int EVBACKEND_DEVPOLL
+ int EVBACKEND_PORT
+ int EVBACKEND_ALL
+ int EVBACKEND_MASK
+
+ int EVRUN_ONCE
+ int EVRUN_NOWAIT
+
+ ctypedef double ev_tstamp
+ cdef struct ev_loop:
+ pass
+ ctypedef struct ev_io:
+ void *data
+ ctypedef struct ev_signal:
+ void *data
+ ctypedef struct ev_timer:
+ void *data
+ ctypedef struct ev_stat:
+ void *data
+
+ ev_loop *ev_loop_new(unsigned int flags)
+ void ev_loop_destroy(ev_loop *loop)
+ void ev_break(ev_loop *loop, int how)
+ int ev_run(ev_loop *loop, int flags) nogil
+ void ev_io_start(ev_loop *loop, ev_io *watcher)
+ void ev_io_stop(ev_loop *loop, ev_io *watcher)
+ void ev_signal_start(ev_loop *loop, ev_signal *signal)
+ void ev_signal_stop(ev_loop *loop, ev_signal *signal)
+ void ev_timer_set(ev_timer *timer, ev_tstamp after, ev_tstamp repeat)
+ void ev_timer_start(ev_loop *loop, ev_timer *timer)
+ void ev_timer_again(ev_loop *loop, ev_timer *timer)
+ void ev_timer_stop(ev_loop *loop, ev_timer *timer)
+ ev_tstamp ev_timer_remaining(ev_loop *loop, ev_timer *timer)
+ void ev_stat_start(ev_loop *loop, ev_stat *watcher)
+ void ev_stat_stop(ev_loop *loop, ev_stat *watcher)
+
+ctypedef ev_loop evLoop
+ctypedef void (*io_cb)(ev_loop *loop, ev_io *watcher, int revents) noexcept nogil # type: ignore
+ctypedef void (*signal_cb)(ev_loop *loop, ev_signal *watcher, int revents) noexcept nogil # type: ignore
+ctypedef void (*timer_cb)(ev_loop *loop, ev_timer *watcher, int revents) noexcept nogil # type: ignore
+ctypedef void (*stat_cb)(ev_loop *loop, ev_stat *watcher, int revents) noexcept nogil # type: ignore
+
+cdef extern from "ev.h":
+ void ev_io_init(ev_io *ev_io, io_cb callback, int fd, int events)
+ void ev_signal_init(ev_signal *signal, signal_cb callback, int signum)
+ void ev_timer_init(ev_timer *timer, timer_cb callback, ev_tstamp after, ev_tstamp repeat)
+ void ev_stat_init(ev_stat *watcher, stat_cb callback, const char *path, ev_tstamp interval)
diff --git a/orcsome3/libs/cython_libs/libgd/__init__.pxd b/orcsome3/libs/cython_libs/libgd/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/orcsome3/libs/cython_libs/libgd/libgd.pxd b/orcsome3/libs/cython_libs/libgd/libgd.pxd
new file mode 100644
index 0000000..e1e3021
--- /dev/null
+++ b/orcsome3/libs/cython_libs/libgd/libgd.pxd
@@ -0,0 +1,30 @@
+ctypedef unsigned long int CARD32
+
+# External libraries
+
+cdef extern from "gd.h": # pkg-config: gdlib
+ """
+ #define gdImageSX(im) ((im)->sx)
+ #define gdImageSY(im) ((im)->sy)
+
+ #define gdTrueColorGetBlue(c) ((c) & 0x0000FF)
+ #define gdImageBlue(im, c) ((im)->trueColor ? gdTrueColorGetBlue(c) : (im)->blue[(c)])
+ #define gdImageGreen(im, c) ((im)->trueColor ? gdTrueColorGetGreen(c) : (im)->green[(c)])
+ #define gdImageRed(im, c) ((im)->trueColor ? gdTrueColorGetRed(c) : (im)->red[(c)])
+ #define gdImageAlpha(im, c) ((im)->trueColor ? gdTrueColorGetAlpha(c) : (im)->alpha[(c)])
+ """
+ # Defined types
+ ctypedef struct gdImage:
+ pass
+ ctypedef gdImage *gdImagePtr
+
+ # Functions
+ gdImagePtr gdImageCreateFromPngPtr(int size, void *data)
+ int gdImageSX(gdImagePtr im)
+ int gdImageSY(gdImagePtr im)
+ int gdImageGetPixel(gdImagePtr im, int x, int y)
+ int gdImageBlue(gdImagePtr im, int color)
+ int gdImageGreen(gdImagePtr im, int color)
+ int gdImageRed(gdImagePtr im, int color)
+ int gdImageAlpha(gdImagePtr im, int color)
+ void gdImageDestroy(gdImagePtr im)
diff --git a/orcsome3/libs/cython_libs/orcsome3_backend.pyx b/orcsome3/libs/cython_libs/orcsome3_backend.pyx
new file mode 100644
index 0000000..3d77c63
--- /dev/null
+++ b/orcsome3/libs/cython_libs/orcsome3_backend.pyx
@@ -0,0 +1,2041 @@
+import enum as pyenum
+from typing import Any, Callable, Optional, Union, cast
+from cpython cimport array as carray
+import array as pyarray
+cimport cairo.cairo as cairo
+cimport imagemagick.imagemagick as imagemagick
+cimport libgd.libgd as libgd
+cimport libev.libev as libev
+cimport resvg.resvg as resvg
+cimport xlib.xlib as xlib
+from libc.math cimport ceil
+from libc.stddef cimport size_t
+from libc.stdlib cimport calloc, free, malloc, realloc
+from libc.string cimport memcpy, memset
+from pathlib import Path
+from libc.stdint cimport uint32_t, uintptr_t
+from cython.operator cimport dereference
+
+
+# Structs
+
+ctypedef struct IconData:
+ int length
+ unsigned char *data
+ bint magick_memory
+
+
+_c_string_cache: dict[str, bytes] = {}
+_intern_cache: dict[tuple[int, str, bool], int] = {}
+_atom_name_cache: dict[tuple[int, int], str] = {}
+
+
+cdef char *get_char_from_py_string(str string):
+ """Return a stable C string pointer backed by a module-level bytes cache."""
+ cdef bytes py_bytes = _c_string_cache.get(string)
+ if py_bytes is None:
+ py_bytes = string.encode()
+ _c_string_cache[string] = py_bytes
+ return py_bytes
+
+
+cdef void release_icon_data(IconData *icon_data):
+ if icon_data.data == NULL:
+ return
+ if icon_data.magick_memory:
+ imagemagick.MagickRelinquishMemory(icon_data.data)
+ else:
+ free(icon_data.data)
+ icon_data.data = NULL
+ icon_data.length = 0
+ icon_data.magick_memory = False
+
+cdef class PyDisplay:
+ cdef xlib.Display *_display
+
+ @staticmethod
+ cdef PyDisplay _new_(xlib.Display *display):
+ cdef PyDisplay pydisplay = PyDisplay()
+ pydisplay._display = display
+ return pydisplay
+
+cdef class PyXEvent:
+ _type: EVENT_TYPES
+ cdef xlib.XEvent _native_event
+ cdef public unsigned long serial
+ cdef public int send_event
+ cdef public PyDisplay display
+ cdef public xlib.Window window
+
+ @property
+ def type(self) -> EVENT_TYPES:
+ return self._type
+
+ @type.setter
+ def type(self, type: EVENT_TYPES) -> None:
+ self._type = type
+
+ @staticmethod
+ cdef PyXEvent _new_(xlib.XEvent *event):
+ cdef PyXEvent pyXEvent = PyXEvent()
+ pyXEvent._set_attributes_from_event_(
+ event=dereference(event), serial=event.xany.serial, display=event.xany.display, window=event.xany.window
+ )
+ return pyXEvent
+
+ cdef _set_attributes_from_event_(self, xlib.XEvent event, unsigned long serial, xlib.Display *display, xlib.Window window):
+ """
+ Method used to set the common attributes of all XEvents
+ """
+ self._native_event = event
+ self.type = EVENT_TYPES(event.type)
+ self.serial = serial
+ self.send_event = event.xany.send_event
+ self.display = PyDisplay._new_(display=display)
+ self.window = window
+
+ def _get_specific_event_(self) -> PyXEvent:
+ """
+ Method used to retrieve the specific event based in the TYPE
+ """
+ if self.type == EVENT_TYPES.ErrorEvent:
+ return PyXErrorEvent._new_(error_event=&self._native_event.xerror)
+ elif self.type == EVENT_TYPES.KeyPress or self.type == EVENT_TYPES.KeyRelease:
+ return PyXKeyEvent._new_(key_event=&self._native_event.xkey)
+ elif self.type == EVENT_TYPES.ButtonPress or self.type == EVENT_TYPES.ButtonRelease:
+ return PyXButtonEvent._new_(button_event=&self._native_event.xbutton)
+ elif self.type == EVENT_TYPES.FocusIn or self.type == EVENT_TYPES.FocusOut:
+ return PyXFocusChangeEvent._new_(focus_change_event=&self._native_event.xfocus)
+ elif self.type == EVENT_TYPES.CreateNotify:
+ return PyXCreateWindowEvent._new_(create_window_event=&self._native_event.xcreatewindow)
+ elif self.type == EVENT_TYPES.DestroyNotify:
+ return PyXDestroyWindowEvent._new_(destroy_window_event=&self._native_event.xdestroywindow)
+ elif self.type == EVENT_TYPES.MapNotify:
+ return PyXMapEvent._new_(map_event=&self._native_event.xmap)
+ elif self.type == EVENT_TYPES.UnmapNotify:
+ return PyXUnmapEvent._new_(unmap_event=&self._native_event.xunmap)
+ elif self.type == EVENT_TYPES.ConfigureNotify:
+ return PyXConfigureEvent._new_(configure_event=&self._native_event.xconfigure)
+ elif self.type == EVENT_TYPES.PropertyNotify:
+ return PyXPropertyEvent._new_(property_event=&self._native_event.xproperty)
+ elif self.type == EVENT_TYPES.ClientMessage:
+ return PyXClientMessageEvent._new_(client_message_event=&self._native_event.xclient)
+ return self
+
+cdef class PyXErrorEvent(PyXEvent):
+ cdef public xlib.XID resourceid
+ cdef public unsigned char error_code
+ cdef public unsigned char request_code
+ cdef public unsigned char minor_code
+ _msg: str
+
+ @property
+ def msg(self) -> str:
+ return self._msg
+
+ @msg.setter
+ def msg(self, msg: str) -> None:
+ self._msg = msg
+
+ @staticmethod
+ cdef PyXErrorEvent _new_(xlib.XErrorEvent *error_event):
+ cdef PyXErrorEvent pyXErrorEvent = PyXErrorEvent()
+ pyXErrorEvent._set_attributes_from_event_(
+ event=dereference(error_event), serial=error_event.serial, display=error_event.display, window=(error_event).xany.window
+ )
+ pyXErrorEvent.resourceid = error_event.resourceid
+ pyXErrorEvent.error_code = error_event.error_code
+ pyXErrorEvent.request_code = error_event.request_code
+ pyXErrorEvent.minor_code = error_event.minor_code
+ pyXErrorEvent.get_message()
+ return pyXErrorEvent
+
+ cdef get_message(self):
+ cdef int length = 1024
+ cdef char *buffer = malloc(size=(sizeof(char) * length))
+ if buffer == NULL:
+ self.msg = ""
+ return
+ xlib.XGetErrorText(display=self.display._display, code=self.error_code, buffer_return=buffer, length=length)
+ self.msg = buffer[:length].decode(errors="replace")
+ free(buffer)
+
+cdef class PyXKeyEvent(PyXEvent):
+ cdef public xlib.Window root
+ cdef public xlib.Window subwindow
+ cdef public xlib.Time time
+ cdef public int x, y
+ cdef public int x_root, y_root
+ cdef public unsigned int state
+ cdef public unsigned int keycode
+ cdef public int same_screen
+
+ @staticmethod
+ cdef PyXKeyEvent _new_(xlib.XKeyEvent *key_event):
+ cdef PyXKeyEvent pyXKeyEvent = PyXKeyEvent()
+ pyXKeyEvent._set_attributes_from_event_(
+ event=dereference(key_event), serial=key_event.serial, display=key_event.display, window=key_event.window
+ )
+ pyXKeyEvent.root = key_event.root
+ pyXKeyEvent.subwindow = key_event.subwindow
+ pyXKeyEvent.time = key_event.time
+ pyXKeyEvent.x = key_event.x
+ pyXKeyEvent.y = key_event.y
+ pyXKeyEvent.x_root = key_event.x_root
+ pyXKeyEvent.y_root = key_event.y_root
+ pyXKeyEvent.state = key_event.state
+ pyXKeyEvent.keycode = key_event.keycode
+ pyXKeyEvent.same_screen = key_event.same_screen
+ return pyXKeyEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window window,
+ xlib.Window root,
+ xlib.Window subwindow,
+ xlib.Time time,
+ int x,
+ int y,
+ int x_root,
+ int y_root,
+ unsigned int state,
+ unsigned int keycode,
+ same_screen: bool
+ ) -> PyXKeyEvent:
+ cdef xlib.XKeyEvent xKeyEvent = xlib.XKeyEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ window=window,
+ root=root,
+ subwindow=subwindow,
+ time=time,
+ x=x,
+ y=y,
+ x_root=x_root,
+ y_root=y_root,
+ state=state,
+ keycode=keycode,
+ same_screen=same_screen
+ )
+ return PyXKeyEvent._new_(key_event=&xKeyEvent)
+
+cdef class PyXButtonEvent(PyXEvent):
+ cdef public xlib.Window root
+ cdef public xlib.Window subwindow
+ cdef public xlib.Time time
+ cdef public int x, y
+ cdef public int x_root, y_root
+ cdef public unsigned int state
+ _button: BUTTONS
+ cdef public int same_screen
+
+ @property
+ def button(self) -> BUTTONS:
+ return self._button
+
+ @button.setter
+ def button(self, button: BUTTONS) -> None:
+ self._button = button
+
+ @staticmethod
+ cdef PyXButtonEvent _new_(xlib.XButtonEvent *button_event):
+ cdef PyXButtonEvent pyXButtonEvent = PyXButtonEvent()
+ pyXButtonEvent._set_attributes_from_event_(
+ event=dereference(button_event), serial=button_event.serial, display=button_event.display, window=button_event.window
+ )
+ pyXButtonEvent.root = button_event.root
+ pyXButtonEvent.subwindow = button_event.subwindow
+ pyXButtonEvent.time = button_event.time
+ pyXButtonEvent.x = button_event.x
+ pyXButtonEvent.y = button_event.y
+ pyXButtonEvent.x_root = button_event.x_root
+ pyXButtonEvent.y_root = button_event.y_root
+ pyXButtonEvent.state = button_event.state
+ pyXButtonEvent.button = BUTTONS(button_event.button)
+ pyXButtonEvent.same_screen = button_event.same_screen
+ return pyXButtonEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window window,
+ xlib.Window root,
+ xlib.Window subwindow,
+ xlib.Time time,
+ int x,
+ int y,
+ int x_root,
+ int y_root,
+ unsigned int state,
+ unsigned int button,
+ same_screen: bool
+ ) -> PyXButtonEvent:
+ cdef xlib.XButtonEvent xButtonEvent = xlib.XButtonEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ window=window,
+ root=root,
+ subwindow=subwindow,
+ time=time,
+ x=x,
+ y=y,
+ x_root=x_root,
+ y_root=y_root,
+ state=state,
+ button=button,
+ same_screen=same_screen
+ )
+ return PyXButtonEvent._new_(button_event=&xButtonEvent)
+
+cdef class PyXFocusChangeEvent(PyXEvent):
+ _mode: NOTIFY_MODES
+ _detail: NOTIFY_DETAILS
+
+ @property
+ def mode(self) -> NOTIFY_MODES:
+ return self._mode
+
+ @mode.setter
+ def mode(self, mode: NOTIFY_MODES) -> None:
+ self._mode = mode
+
+ @property
+ def detail(self) -> NOTIFY_DETAILS:
+ return self._detail
+
+ @detail.setter
+ def detail(self, detail: NOTIFY_DETAILS) -> None:
+ self._detail = detail
+
+ @staticmethod
+ cdef PyXFocusChangeEvent _new_(xlib.XFocusChangeEvent *focus_change_event):
+ cdef PyXFocusChangeEvent pyXFocusChangeEvent = PyXFocusChangeEvent()
+ pyXFocusChangeEvent._set_attributes_from_event_(
+ event=dereference(focus_change_event), serial=focus_change_event.serial, display=focus_change_event.display, window=focus_change_event.window
+ )
+ pyXFocusChangeEvent.mode = NOTIFY_MODES(focus_change_event.mode)
+ pyXFocusChangeEvent.detail = NOTIFY_DETAILS(focus_change_event.detail)
+ return pyXFocusChangeEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window window,
+ int mode,
+ int detail
+ ) -> PyXFocusChangeEvent:
+ cdef xlib.XFocusChangeEvent xFocusChangeEvent = xlib.XFocusChangeEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ window=window,
+ mode=mode,
+ detail=detail
+ )
+ return PyXFocusChangeEvent._new_(focus_change_event=&xFocusChangeEvent)
+
+cdef class PyXCreateWindowEvent(PyXEvent):
+ cdef public xlib.Window parent
+ cdef public int x, y
+ cdef public int width, height
+ cdef public int border_width
+ cdef public int override_redirect
+
+ @staticmethod
+ cdef PyXCreateWindowEvent _new_(xlib.XCreateWindowEvent *create_window_event):
+ cdef PyXCreateWindowEvent pyXCreateWindowEvent = PyXCreateWindowEvent()
+ pyXCreateWindowEvent._set_attributes_from_event_(
+ event=dereference(create_window_event), serial=create_window_event.serial, display=create_window_event.display, window=create_window_event.window
+ )
+ pyXCreateWindowEvent.parent = create_window_event.parent
+ pyXCreateWindowEvent.x = create_window_event.x
+ pyXCreateWindowEvent.y = create_window_event.y
+ pyXCreateWindowEvent.width = create_window_event.width
+ pyXCreateWindowEvent.height = create_window_event.height
+ pyXCreateWindowEvent.border_width = create_window_event.border_width
+ pyXCreateWindowEvent.override_redirect = create_window_event.override_redirect
+ return pyXCreateWindowEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window parent,
+ xlib.Window window,
+ int x,
+ int y,
+ int width,
+ int height,
+ int border_width,
+ override_redirect: bool
+ ) -> PyXCreateWindowEvent:
+ cdef xlib.XCreateWindowEvent xCreateWindowEvent = xlib.XCreateWindowEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ parent=parent,
+ window=window,
+ x=x,
+ y=y,
+ width=width,
+ height=height,
+ border_width=border_width,
+ override_redirect=override_redirect
+ )
+ return PyXCreateWindowEvent._new_(create_window_event=&xCreateWindowEvent)
+
+cdef class PyXDestroyWindowEvent(PyXEvent):
+ cdef public xlib.Window event
+
+ @staticmethod
+ cdef PyXDestroyWindowEvent _new_(xlib.XDestroyWindowEvent *destroy_window_event):
+ cdef PyXDestroyWindowEvent pyXDestroyWindowEvent = PyXDestroyWindowEvent()
+ pyXDestroyWindowEvent._set_attributes_from_event_(
+ event=dereference(destroy_window_event), serial=destroy_window_event.serial, display=destroy_window_event.display, window=destroy_window_event.window
+ )
+ pyXDestroyWindowEvent.event = destroy_window_event.event
+ return pyXDestroyWindowEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window event,
+ xlib.Window window
+ ) -> PyXDestroyWindowEvent:
+ cdef xlib.XDestroyWindowEvent xDestroyWindowEvent = xlib.XDestroyWindowEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ event=event,
+ window=window
+ )
+ return PyXDestroyWindowEvent._new_(destroy_window_event=&xDestroyWindowEvent)
+
+cdef class PyXMapEvent(PyXEvent):
+ cdef public xlib.Window event
+ cdef public int override_redirect
+
+ @staticmethod
+ cdef PyXMapEvent _new_(xlib.XMapEvent *map_event):
+ cdef PyXMapEvent pyXMapEvent = PyXMapEvent()
+ pyXMapEvent._set_attributes_from_event_(
+ event=dereference(map_event), serial=map_event.serial, display=map_event.display, window=map_event.window
+ )
+ pyXMapEvent.event = map_event.event
+ pyXMapEvent.override_redirect = map_event.override_redirect
+ return pyXMapEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window event,
+ xlib.Window window,
+ override_redirect: bool
+ ) -> PyXMapEvent:
+ cdef xlib.XMapEvent xMapEvent = xlib.XMapEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ event=event,
+ window=window,
+ override_redirect=override_redirect
+ )
+ return PyXMapEvent._new_(map_event=&xMapEvent)
+
+cdef class PyXUnmapEvent(PyXEvent):
+ cdef public xlib.Window event
+ cdef public int from_configure
+
+ @staticmethod
+ cdef PyXUnmapEvent _new_(xlib.XUnmapEvent *unmap_event):
+ cdef PyXUnmapEvent pyXUnmapEvent = PyXUnmapEvent()
+ pyXUnmapEvent._set_attributes_from_event_(
+ event=dereference(unmap_event), serial=unmap_event.serial, display=unmap_event.display, window=unmap_event.window
+ )
+ pyXUnmapEvent.event = unmap_event.event
+ pyXUnmapEvent.from_configure = unmap_event.from_configure
+ return pyXUnmapEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window event,
+ xlib.Window window,
+ from_configure: bool
+ ) -> PyXUnmapEvent:
+ cdef xlib.XUnmapEvent xUnmapEvent = xlib.XUnmapEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ event=event,
+ window=window,
+ from_configure=from_configure
+ )
+ return PyXUnmapEvent._new_(unmap_event=&xUnmapEvent)
+
+cdef class PyXConfigureEvent(PyXEvent):
+ cdef public xlib.Window event
+ cdef public int x, y
+ cdef public int width, height
+ cdef public int border_width
+ cdef public xlib.Window above
+ cdef public int override_redirect
+
+ @staticmethod
+ cdef PyXConfigureEvent _new_(xlib.XConfigureEvent *configure_event):
+ cdef PyXConfigureEvent pyXConfigureEvent = PyXConfigureEvent()
+ pyXConfigureEvent._set_attributes_from_event_(
+ event=dereference(configure_event), serial=configure_event.serial, display=configure_event.display, window=configure_event.window
+ )
+ pyXConfigureEvent.event = configure_event.event
+ pyXConfigureEvent.x = configure_event.x
+ pyXConfigureEvent.y = configure_event.y
+ pyXConfigureEvent.width = configure_event.width
+ pyXConfigureEvent.height = configure_event.height
+ pyXConfigureEvent.border_width = configure_event.border_width
+ pyXConfigureEvent.above = configure_event.above
+ pyXConfigureEvent.override_redirect = configure_event.override_redirect
+ return pyXConfigureEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window event,
+ xlib.Window window,
+ int x,
+ int y,
+ int width,
+ int height,
+ int border_width,
+ xlib.Window above,
+ override_redirect: bool
+ ) -> PyXConfigureEvent:
+ cdef xlib.XConfigureEvent xConfigureEvent = xlib.XConfigureEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ event=event,
+ window=window,
+ x=x,
+ y=y,
+ width=width,
+ height=height,
+ border_width=border_width,
+ above=above,
+ override_redirect=override_redirect
+ )
+ return PyXConfigureEvent._new_(configure_event=&xConfigureEvent)
+
+cdef class PyXPropertyEvent(PyXEvent):
+ cdef public xlib.Atom atom
+ cdef public xlib.Time time
+ _state: PROPERTY_NOTIFICATION
+
+ @property
+ def state(self) -> PROPERTY_NOTIFICATION:
+ return self._state
+
+ @state.setter
+ def state(self, state: PROPERTY_NOTIFICATION) -> None:
+ self._state = state
+
+ @staticmethod
+ cdef PyXPropertyEvent _new_(xlib.XPropertyEvent *property_event):
+ cdef PyXPropertyEvent pyXPropertyEvent = PyXPropertyEvent()
+ pyXPropertyEvent._set_attributes_from_event_(
+ event=dereference(property_event), serial=property_event.serial, display=property_event.display, window=property_event.window
+ )
+ pyXPropertyEvent.atom = property_event.atom
+ pyXPropertyEvent.time = property_event.time
+ pyXPropertyEvent.state = PROPERTY_NOTIFICATION(property_event.state)
+ return pyXPropertyEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window window,
+ xlib.Atom atom,
+ xlib.Time time,
+ int state
+ ) -> PyXPropertyEvent:
+ cdef xlib.XPropertyEvent xPropertyEvent = xlib.XPropertyEvent( # type: ignore
+ type=type,
+ serial=serial,
+ send_event=send_event,
+ display=display._display,
+ window=window,
+ atom=atom,
+ time=time,
+ state=state,
+ )
+ return PyXPropertyEvent._new_(property_event=&xPropertyEvent)
+
+cdef class PyXClientMessageEvent(PyXEvent):
+ cdef public xlib.Atom message_type
+ _format: PROPERTY_FORMAT
+ cdef public carray.array data
+
+ @property
+ def format(self) -> PROPERTY_FORMAT:
+ return self._format
+
+ @format.setter
+ def format(self, format: PROPERTY_FORMAT) -> None:
+ self._format = format
+
+ @staticmethod
+ cdef PyXClientMessageEvent _new_(xlib.XClientMessageEvent *client_message_event):
+ cdef PyXClientMessageEvent pyXClientMessageEvent = PyXClientMessageEvent()
+ pyXClientMessageEvent._set_attributes_from_event_(
+ event=dereference(client_message_event), serial=client_message_event.serial, display=client_message_event.display, window=client_message_event.window
+ )
+ pyXClientMessageEvent.message_type = client_message_event.message_type
+ pyXClientMessageEvent.format = PROPERTY_FORMAT.new_from_value(value=client_message_event.format)
+ list_with_data: list[int] = []
+ if pyXClientMessageEvent.format == PROPERTY_FORMAT.CHAR:
+ for i in range(20):
+ list_with_data.append(client_message_event.b[i])
+ elif pyXClientMessageEvent.format == PROPERTY_FORMAT.SHORT:
+ for i in range(10):
+ list_with_data.append(client_message_event.s[i])
+ elif pyXClientMessageEvent.format == PROPERTY_FORMAT.LONG:
+ for i in range(5):
+ list_with_data.append(client_message_event.l[i])
+ pyXClientMessageEvent.data = carray.array(pyXClientMessageEvent.format.value[1], list_with_data)
+ return pyXClientMessageEvent
+
+ @staticmethod
+ def _new_from_python_(
+ int type,
+ unsigned long serial,
+ send_event: bool,
+ PyDisplay display,
+ xlib.Window window,
+ xlib.Atom message_type,
+ int format,
+ data: pyarray.array[int]
+ ) -> PyXClientMessageEvent:
+ cdef xlib.XClientMessageEvent xClientMessageEvent
+ memset(&xClientMessageEvent, 0, sizeof(xClientMessageEvent))
+ xClientMessageEvent.type = type
+ xClientMessageEvent.serial = serial
+ xClientMessageEvent.send_event = send_event
+ xClientMessageEvent.display = display._display
+ xClientMessageEvent.window = window
+ xClientMessageEvent.message_type = message_type
+ xClientMessageEvent.format = format
+ pyformat: PROPERTY_FORMAT = PROPERTY_FORMAT.new_from_value(value=format)
+ if pyformat == PROPERTY_FORMAT.CHAR:
+ for i in range(min(20, len(data))):
+ xClientMessageEvent.b[i] = data[i]
+ elif pyformat == PROPERTY_FORMAT.SHORT:
+ for i in range(min(10, len(data))):
+ xClientMessageEvent.s[i] = data[i]
+ elif pyformat == PROPERTY_FORMAT.LONG:
+ for i in range(min(5, len(data))):
+ xClientMessageEvent.l[i] = data[i]
+ return PyXClientMessageEvent._new_(client_message_event=&xClientMessageEvent)
+
+cdef class PyXWindowChanges:
+ cdef xlib.XWindowChanges _xwindowchanges
+ cdef public int x
+ cdef public int y
+ cdef public int width
+ cdef public int height
+ cdef public int border_width
+ cdef public int sibling_window
+ _stack_mode: WINDOW_STACKING_METHOD
+
+ @property
+ def stack_mode(self) -> WINDOW_STACKING_METHOD:
+ return self._stack_mode
+
+ @stack_mode.setter
+ def stack_mode(self, stack_mode: WINDOW_STACKING_METHOD) -> None:
+ self._stack_mode = stack_mode
+
+ @staticmethod
+ cdef PyXWindowChanges _new_(xlib.XWindowChanges window_changes):
+ cdef PyXWindowChanges pyXWindowChanges = PyXWindowChanges()
+ pyXWindowChanges._xwindowchanges = window_changes
+ pyXWindowChanges.x = window_changes.x
+ pyXWindowChanges.y = window_changes.y
+ pyXWindowChanges.width = window_changes.width
+ pyXWindowChanges.height = window_changes.height
+ pyXWindowChanges.border_width = window_changes.border_width
+ pyXWindowChanges.sibling_window = window_changes.sibling
+ pyXWindowChanges.stack_mode = WINDOW_STACKING_METHOD(window_changes.stack_mode)
+ return pyXWindowChanges
+
+ @staticmethod
+ def _new_from_python_(int x, int y, int width, int height, int border_width, int sibling_window, int stack_mode) -> PyXWindowChanges:
+ cdef xlib.XWindowChanges xWindowChanges = xlib.XWindowChanges( # type: ignore
+ x=x,
+ y=y,
+ width=width,
+ height=height,
+ border_width=border_width,
+ sibling=sibling_window,
+ stack_mode=stack_mode
+ )
+ return PyXWindowChanges._new_(window_changes=xWindowChanges)
+
+cdef class PyXWindowAttributes:
+ cdef public int x
+ cdef public int y
+ cdef public int width
+ cdef public int height
+ cdef public int border_width
+ cdef public int depth
+ cdef public xlib.Window root
+ cdef public int map_state
+ cdef public int override_redirect
+
+ @staticmethod
+ cdef PyXWindowAttributes _new_(xlib.XWindowAttributes window_attributes):
+ cdef PyXWindowAttributes pyXWindowAttributes = PyXWindowAttributes()
+ pyXWindowAttributes.x = window_attributes.x
+ pyXWindowAttributes.y = window_attributes.y
+ pyXWindowAttributes.width = window_attributes.width
+ pyXWindowAttributes.height = window_attributes.height
+ pyXWindowAttributes.border_width = window_attributes.border_width
+ pyXWindowAttributes.depth = window_attributes.depth
+ pyXWindowAttributes.root = window_attributes.root
+ pyXWindowAttributes.map_state = window_attributes.map_state
+ pyXWindowAttributes.override_redirect = window_attributes.override_redirect
+ return pyXWindowAttributes
+
+cdef class PyXWindowTree:
+ cdef public xlib.Window window
+ cdef public xlib.Window root
+ cdef public xlib.Window parent
+ _children: list[int]
+
+ @property
+ def children(self) -> list[int]:
+ return self._children
+
+ @children.setter
+ def children(self, children: list[int]) -> None:
+ self._children = children
+
+ @staticmethod
+ cdef PyXWindowTree _new_(xlib.Window window, xlib.Window root, xlib.Window parent, children: list[int]):
+ cdef PyXWindowTree pyXWindowTree = PyXWindowTree()
+ pyXWindowTree.window = window
+ pyXWindowTree.root = root
+ pyXWindowTree.parent = parent
+ pyXWindowTree.children = children
+ return pyXWindowTree
+
+cdef class PyXWindowGeometry:
+ cdef public xlib.Window window
+ cdef public xlib.Window root
+ cdef public int x, y
+ cdef public unsigned int width, height
+ cdef public unsigned int border_width
+ cdef public unsigned int depth
+
+ @staticmethod
+ cdef PyXWindowGeometry _new_(
+ xlib.Window window,
+ xlib.Window root,
+ int x, int y,
+ unsigned int width, unsigned int height,
+ unsigned int border_width,
+ unsigned int depth
+ ):
+ cdef PyXWindowGeometry pyXWindowGeometry = PyXWindowGeometry()
+ pyXWindowGeometry.window = window
+ pyXWindowGeometry.root = root
+ pyXWindowGeometry.x = x
+ pyXWindowGeometry.y = y
+ pyXWindowGeometry.width = width
+ pyXWindowGeometry.height = height
+ pyXWindowGeometry.border_width = border_width
+ pyXWindowGeometry.depth = depth
+ return pyXWindowGeometry
+
+cdef class PyXScreenSaverInfo:
+ cdef public xlib.Window window
+ cdef public int state
+ cdef public int kind
+ cdef public unsigned long til_or_since
+ cdef public unsigned long idle
+ cdef public unsigned long event_mask
+
+ @staticmethod
+ cdef PyXScreenSaverInfo _new_(xlib.XScreenSaverInfo info):
+ cdef PyXScreenSaverInfo pyXScreenSaverInfo = PyXScreenSaverInfo()
+ pyXScreenSaverInfo.window = info.window
+ pyXScreenSaverInfo.state = info.state
+ pyXScreenSaverInfo.kind = info.kind
+ pyXScreenSaverInfo.til_or_since = info.til_or_since
+ pyXScreenSaverInfo.idle = info.idle
+ pyXScreenSaverInfo.event_mask = info.eventMask
+ return pyXScreenSaverInfo
+
+cdef class PyXkbStateRec:
+ cdef public unsigned char group
+ cdef public unsigned char locked_group
+ cdef public unsigned short base_group
+ cdef public unsigned short latched_group
+ cdef public unsigned char mods
+ cdef public unsigned char base_mods
+ cdef public unsigned char latched_mods
+ cdef public unsigned char locked_mods
+ cdef public unsigned char compat_state
+ cdef public unsigned char grab_mods
+ cdef public unsigned char compat_grab_mods
+ cdef public unsigned char lookup_mods
+ cdef public unsigned char compat_lookup_mods
+ cdef public unsigned short ptr_buttons
+
+ @staticmethod
+ cdef PyXkbStateRec _new_(xlib.XkbStateRec kb_state):
+ cdef PyXkbStateRec pyXkbStateRec = PyXkbStateRec()
+ pyXkbStateRec.group = kb_state.group
+ pyXkbStateRec.locked_group = kb_state.locked_group
+ pyXkbStateRec.base_group = kb_state.base_group
+ pyXkbStateRec.latched_group = kb_state.latched_group
+ pyXkbStateRec.mods = kb_state.mods
+ pyXkbStateRec.base_mods = kb_state.base_mods
+ pyXkbStateRec.latched_mods = kb_state.latched_mods
+ pyXkbStateRec.locked_mods = kb_state.locked_mods
+ pyXkbStateRec.compat_state = kb_state.compat_state
+ pyXkbStateRec.grab_mods = kb_state.grab_mods
+ pyXkbStateRec.compat_grab_mods = kb_state.compat_grab_mods
+ pyXkbStateRec.lookup_mods = kb_state.lookup_mods
+ pyXkbStateRec.compat_lookup_mods = kb_state.compat_lookup_mods
+ pyXkbStateRec.ptr_buttons = kb_state.ptr_buttons
+ return pyXkbStateRec
+
+cdef class PyDPMSInfo:
+ cdef public xlib.BOOL state
+ _power_level: DPMS_POWER_LEVEL
+
+ @property
+ def power_level(self) -> DPMS_POWER_LEVEL:
+ return self._power_level
+
+ @power_level.setter
+ def power_level(self, power_level: DPMS_POWER_LEVEL) -> None:
+ self._power_level = power_level
+
+ @staticmethod
+ cdef PyDPMSInfo _new_(xlib.BOOL state, xlib.CARD16 power_level):
+ cdef PyDPMSInfo pyDPMSInfo = PyDPMSInfo()
+ pyDPMSInfo.state = state
+ pyDPMSInfo.power_level = DPMS_POWER_LEVEL(power_level)
+ return pyDPMSInfo
+
+cdef class PyLoop:
+ cdef libev.evLoop *loop
+
+ @staticmethod
+ cdef PyLoop _new_(libev.ev_loop *loop):
+ cdef PyLoop pyloop = PyLoop()
+ pyloop.loop = loop
+ return pyloop
+
+ @staticmethod
+ def _new_from_python_(flags: Union[int, list[int]]) -> PyLoop:
+ cdef int new_flags = PYLOOP_NEW_LOOP_FLAGS.EVFLAG_AUTO.value
+ if isinstance(flags, list):
+ new_flags = flags[0]
+ for flag in flags:
+ new_flags |= flag
+ else:
+ new_flags = flags
+ cdef libev.evLoop *loop = libev.ev_loop_new(flags=new_flags)
+ return PyLoop._new_(loop=loop)
+
+ def run(self, flags: Union[int, list[int]]) -> None:
+ cdef int run_flags = PYLOOP_RUN_LOOP_FLAGS.EVRUN_ALWAYS.value
+ if isinstance(flags, list):
+ run_flags = flags[0]
+ for flag in flags:
+ run_flags |= flag
+ else:
+ run_flags = flags
+ cdef libev.ev_loop *loop_ptr = self.loop
+ # Release the GIL while blocked waiting for the next event, otherwise no other Python
+ # thread (e.g. the NotificationBus/tray asyncio thread) can run until one arrives.
+ # default_callback (below, for every watcher type) reacquires the GIL before touching
+ # any Python object, since libev calls it directly from C while nogil is in effect.
+ with nogil:
+ libev.ev_run(loop=loop_ptr, flags=run_flags)
+
+ def break_(self, how_to_break_flag: int) -> None:
+ libev.ev_break(loop=self.loop, how=how_to_break_flag)
+
+ def destroy(self) -> None:
+ libev.ev_loop_destroy(loop=self.loop)
+
+cdef class PyIOWatcher:
+ cdef libev.ev_io *io_watcher
+ cdef bint _owns_watcher
+ _callbacks: dict[str, Callable[..., Any]]
+
+ @property
+ def callbacks(self) -> dict[str, Callable[..., Any]]:
+ return self._callbacks
+
+ @callbacks.setter
+ def callbacks(self, callbacks: dict[str, Callable[..., Any]]) -> None:
+ self._callbacks = callbacks
+
+ @staticmethod
+ cdef PyIOWatcher _new_(libev.ev_io *io_watcher = NULL, callbacks: Optional[dict[str, Callable[..., Any]]] = None):
+ cdef PyIOWatcher py_io_watcher = PyIOWatcher()
+ if io_watcher == NULL:
+ io_watcher = malloc(size=sizeof(libev.ev_io))
+ py_io_watcher._owns_watcher = True
+ else:
+ py_io_watcher._owns_watcher = False
+ py_io_watcher.io_watcher = io_watcher
+ if callbacks is not None:
+ py_io_watcher.callbacks = callbacks
+ return py_io_watcher
+
+ def __dealloc__(self):
+ if self._owns_watcher and self.io_watcher != NULL:
+ free(self.io_watcher)
+ self.io_watcher = NULL
+
+ @staticmethod
+ def _new_from_python_() -> PyIOWatcher:
+ return PyIOWatcher._new_()
+
+ def init(self, callbacks: dict[str, Callable[..., Any]], file_descriptor: int, events: int) -> None:
+ self.callbacks = callbacks
+ self.io_watcher.data = self.callbacks
+ libev.ev_io_init(
+ ev_io=self.io_watcher, callback=PyIOWatcher.default_callback, fd=file_descriptor, events=events
+ )
+
+ def start(self, loop: PyLoop) -> None:
+ libev.ev_io_start(loop=loop.loop, watcher=self.io_watcher)
+
+ def stop(self, loop: PyLoop) -> None:
+ libev.ev_io_stop(loop=loop.loop, watcher=self.io_watcher)
+
+ @staticmethod
+ cdef void default_callback(libev.ev_loop *ev_loop, libev.ev_io *io_watcher, int revents) noexcept nogil:
+ # ev_run() runs with the GIL released (see PyLoop.run); reacquire it before touching
+ # any Python object, since this callback is invoked directly from libev's C code.
+ with gil:
+ callbacks_dict: dict[str, Callable[..., Any]] = io_watcher.data
+ callbacks_dict["default"](
+ PyLoop._new_(loop=ev_loop),
+ PyIOWatcher._new_(io_watcher=io_watcher, callbacks=callbacks_dict),
+ revents
+ )
+
+cdef class PySignalWatcher:
+ cdef libev.ev_signal *signal_watcher
+ cdef bint _owns_watcher
+ _callbacks: dict[str, Callable[..., Any]]
+
+ @property
+ def callbacks(self) -> dict[str, Callable[..., Any]]:
+ return self._callbacks
+
+ @callbacks.setter
+ def callbacks(self, callbacks: dict[str, Callable[..., Any]]) -> None:
+ self._callbacks = callbacks
+
+ @staticmethod
+ cdef PySignalWatcher _new_(libev.ev_signal *signal_watcher = NULL, callbacks: Optional[dict[str, Callable[..., Any]]] = None):
+ cdef PySignalWatcher py_signal_watcher = PySignalWatcher()
+ if signal_watcher == NULL:
+ signal_watcher = malloc(size=sizeof(libev.ev_signal))
+ py_signal_watcher._owns_watcher = True
+ else:
+ py_signal_watcher._owns_watcher = False
+ py_signal_watcher.signal_watcher = signal_watcher
+ if callbacks is not None:
+ py_signal_watcher.callbacks = callbacks
+ return py_signal_watcher
+
+ def __dealloc__(self):
+ if self._owns_watcher and self.signal_watcher != NULL:
+ free(self.signal_watcher)
+ self.signal_watcher = NULL
+
+ @staticmethod
+ def _new_from_python_() -> PySignalWatcher:
+ return PySignalWatcher._new_()
+
+ def init(self, callbacks: dict[str, Callable[..., Any]], signal_number: int) -> None:
+ self.callbacks = callbacks
+ self.signal_watcher.data = self.callbacks
+ libev.ev_signal_init(
+ signal=self.signal_watcher,
+ callback=PySignalWatcher.default_callback,
+ signum=signal_number,
+ )
+
+ def start(self, loop: PyLoop) -> None:
+ libev.ev_signal_start(loop=loop.loop, signal=self.signal_watcher)
+
+ def stop(self, loop: PyLoop) -> None:
+ libev.ev_signal_stop(loop=loop.loop, signal=self.signal_watcher)
+
+ @staticmethod
+ cdef void default_callback(libev.ev_loop *ev_loop, libev.ev_signal *signal_watcher, int revents) noexcept nogil:
+ # ev_run() runs with the GIL released (see PyLoop.run); reacquire it before touching
+ # any Python object, since this callback is invoked directly from libev's C code.
+ with gil:
+ callbacks_dict: dict[str, Callable[..., Any]] = signal_watcher.data
+ callbacks_dict["default"](
+ PyLoop._new_(loop=ev_loop),
+ PySignalWatcher._new_(signal_watcher=signal_watcher, callbacks=callbacks_dict),
+ revents
+ )
+
+cdef class PyTimerWatcher:
+ cdef libev.ev_timer *timer_watcher
+ cdef bint _owns_watcher
+ _callbacks: dict[str, Callable[..., Any]]
+
+ @property
+ def callbacks(self) -> dict[str, Callable[..., Any]]:
+ return self._callbacks
+
+ @callbacks.setter
+ def callbacks(self, callbacks: dict[str, Callable[..., Any]]) -> None:
+ self._callbacks = callbacks
+
+ @staticmethod
+ cdef PyTimerWatcher _new_(libev.ev_timer *timer_watcher = NULL, callbacks: Optional[dict[str, Callable[..., Any]]] = None):
+ cdef PyTimerWatcher py_timer_watcher = PyTimerWatcher()
+ if timer_watcher == NULL:
+ timer_watcher = malloc(size=sizeof(libev.ev_timer))
+ py_timer_watcher._owns_watcher = True
+ else:
+ py_timer_watcher._owns_watcher = False
+ py_timer_watcher.timer_watcher = timer_watcher
+ if callbacks is not None:
+ py_timer_watcher.callbacks = callbacks
+ return py_timer_watcher
+
+ def __dealloc__(self):
+ if self._owns_watcher and self.timer_watcher != NULL:
+ free(self.timer_watcher)
+ self.timer_watcher = NULL
+
+ @staticmethod
+ def _new_from_python_() -> PyTimerWatcher:
+ return PyTimerWatcher._new_()
+
+ def init(self, callbacks: dict[str, Callable[..., Any]], after: float, repeat: float) -> None:
+ self.callbacks = callbacks
+ self.timer_watcher.data = self.callbacks
+ libev.ev_timer_init(
+ timer=self.timer_watcher,
+ callback=PyTimerWatcher.default_callback,
+ after=after,
+ repeat=repeat,
+ )
+
+ def set_timer(self, after: float, repeat: float) -> None:
+ libev.ev_timer_set(timer=self.timer_watcher, after=after, repeat=repeat)
+
+ def start(self, loop: PyLoop) -> None:
+ libev.ev_timer_start(loop=loop.loop, timer=self.timer_watcher)
+
+ def stop(self, loop: PyLoop) -> None:
+ libev.ev_timer_stop(loop=loop.loop, timer=self.timer_watcher)
+
+ def again(self, loop: PyLoop) -> None:
+ libev.ev_timer_again(loop=loop.loop, timer=self.timer_watcher)
+
+ def remaining(self, loop: PyLoop) -> float:
+ return float(libev.ev_timer_remaining(loop=loop.loop, timer=self.timer_watcher))
+
+ @staticmethod
+ cdef void default_callback(libev.ev_loop *ev_loop, libev.ev_timer *timer_watcher, int revents) noexcept nogil:
+ # ev_run() runs with the GIL released (see PyLoop.run); reacquire it before touching
+ # any Python object, since this callback is invoked directly from libev's C code.
+ with gil:
+ callbacks_dict: dict[str, Callable[..., Any]] = timer_watcher.data
+ callbacks_dict["default"](
+ PyLoop._new_(loop=ev_loop),
+ PyTimerWatcher._new_(timer_watcher=timer_watcher, callbacks=callbacks_dict),
+ revents
+ )
+
+
+cdef class PyStatWatcher:
+ cdef libev.ev_stat *stat_watcher
+ cdef bint _owns_watcher
+ _callbacks: dict[str, Callable[..., Any]]
+
+ @property
+ def callbacks(self) -> dict[str, Callable[..., Any]]:
+ return self._callbacks
+
+ @callbacks.setter
+ def callbacks(self, callbacks: dict[str, Callable[..., Any]]) -> None:
+ self._callbacks = callbacks
+
+ @staticmethod
+ cdef PyStatWatcher _new_(libev.ev_stat *stat_watcher = NULL, callbacks: Optional[dict[str, Callable[..., Any]]] = None):
+ cdef PyStatWatcher py_stat_watcher = PyStatWatcher()
+ if stat_watcher == NULL:
+ stat_watcher = malloc(size=sizeof(libev.ev_stat))
+ py_stat_watcher._owns_watcher = True
+ else:
+ py_stat_watcher._owns_watcher = False
+ py_stat_watcher.stat_watcher = stat_watcher
+ if callbacks is not None:
+ py_stat_watcher.callbacks = callbacks
+ return py_stat_watcher
+
+ def __dealloc__(self):
+ if self._owns_watcher and self.stat_watcher != NULL:
+ free(self.stat_watcher)
+ self.stat_watcher = NULL
+
+ @staticmethod
+ def _new_from_python_() -> PyStatWatcher:
+ return PyStatWatcher._new_()
+
+ def init(self, callbacks: dict[str, Callable[..., Any]], path: str, interval: float) -> None:
+ self.callbacks = callbacks
+ self.stat_watcher.data = self.callbacks
+ libev.ev_stat_init(
+ watcher=self.stat_watcher,
+ callback=PyStatWatcher.default_callback,
+ path=get_char_from_py_string(string=path),
+ interval=interval,
+ )
+
+ def start(self, loop: PyLoop) -> None:
+ libev.ev_stat_start(loop=loop.loop, watcher=self.stat_watcher)
+
+ def stop(self, loop: PyLoop) -> None:
+ libev.ev_stat_stop(loop=loop.loop, watcher=self.stat_watcher)
+
+ @staticmethod
+ cdef void default_callback(libev.ev_loop *ev_loop, libev.ev_stat *stat_watcher, int revents) noexcept nogil:
+ # ev_run() runs with the GIL released (see PyLoop.run); reacquire it before touching
+ # any Python object, since this callback is invoked directly from libev's C code.
+ with gil:
+ callbacks_dict: dict[str, Callable[..., Any]] = stat_watcher.data
+ callbacks_dict["default"](
+ PyLoop._new_(loop=ev_loop),
+ PyStatWatcher._new_(stat_watcher=stat_watcher, callbacks=callbacks_dict),
+ revents
+ )
+
+
+# Enums
+
+class CONSTANTS(pyenum.IntEnum):
+ CurrentTime = xlib.CurrentTime
+ AnyPropertyType = xlib.AnyPropertyType
+ NoSymbol = xlib.NoSymbol
+ AnyKey = xlib.AnyKey
+ XkbUseCoreKbd = xlib.XkbUseCoreKbd
+ RevertToNone = xlib.RevertToNone
+ RevertToPointerRoot = xlib.RevertToPointerRoot
+ RevertToParent = xlib.RevertToParent
+
+class EVENT_TYPES(pyenum.IntEnum):
+ # Generic event
+ GenericEvent = xlib.GenericEvent
+ # XErrorEvent
+ ErrorEvent = 0
+ # XKeyEvent
+ KeyPress = xlib.KeyPress
+ KeyRelease = xlib.KeyRelease
+ # XButtonEvent
+ ButtonPress = xlib.ButtonPress
+ ButtonRelease = xlib.ButtonRelease
+ # XMotionEvent
+ MotionNotify = xlib.MotionNotify
+ # XCrossingEvent
+ EnterNotify = xlib.EnterNotify
+ LeaveNotify = xlib.LeaveNotify
+ # XFocusChangeEvent
+ FocusIn = xlib.FocusIn
+ FocusOut = xlib.FocusOut
+ # XExposeEvent
+ Expose = xlib.Expose
+ # XGraphicsExposeEvent
+ GraphicsExpose = xlib.GraphicsExpose
+ # XNoExposeEvent
+ NoExpose = xlib.NoExpose
+ # XVisibilityEvent
+ VisibilityNotify = xlib.VisibilityNotify
+ # XCreateWindowEvent
+ CreateNotify = xlib.CreateNotify
+ # XDestroyWindowEvent
+ DestroyNotify = xlib.DestroyNotify
+ # XUnmapEvent
+ UnmapNotify = xlib.UnmapNotify
+ # XMapEvent
+ MapNotify = xlib.MapNotify
+ # XMapRequestEvent
+ MapRequest = xlib.MapRequest
+ # XReparentEvent
+ ReparentNotify = xlib.ReparentNotify
+ # XConfigureEvent
+ ConfigureNotify = xlib.ConfigureNotify
+ # XGravityEvent
+ GravityNotify = xlib.GravityNotify
+ # XResizeRequestEvent
+ ResizeRequest = xlib.ResizeRequest
+ # XConfigureRequestEvent
+ ConfigureRequest = xlib.ConfigureRequest
+ # XCirculateEvent
+ CirculateNotify = xlib.CirculateNotify
+ # XCirculateRequestEvent
+ CirculateRequest = xlib.CirculateRequest
+ # XPropertyEvent
+ PropertyNotify = xlib.PropertyNotify
+ # XSelectionClearEvent
+ SelectionClear = xlib.SelectionClear
+ # XSelectionRequestEvent
+ SelectionRequest = xlib.SelectionRequest
+ # XSelectionEvent
+ SelectionNotify = xlib.SelectionNotify
+ # XColormapEvent
+ ColormapNotify = xlib.ColormapNotify
+ # XClientMessageEvent
+ ClientMessage = xlib.ClientMessage
+ # XMappingEvent
+ MappingNotify = xlib.MappingNotify
+ # XKeymapEvent
+ KeymapNotify = xlib.KeymapNotify
+
+class INPUT_EVENT_MASKS(pyenum.IntEnum):
+ NoEventMask = xlib.NoEventMask
+ StructureNotifyMask = xlib.StructureNotifyMask
+ SubstructureNotifyMask = xlib.SubstructureNotifyMask
+ SubstructureRedirectMask = xlib.SubstructureRedirectMask
+ PropertyChangeMask = xlib.PropertyChangeMask
+ FocusChangeMask = xlib.FocusChangeMask
+ KeyPressMask = xlib.KeyPressMask
+ KeyReleaseMask = xlib.KeyReleaseMask
+ ButtonPressMask = xlib.ButtonPressMask
+ ButtonReleaseMask = xlib.ButtonReleaseMask
+
+class KEY_MASKS(pyenum.IntEnum):
+ AnyModifier = xlib.AnyModifier
+ Mod1Mask = xlib.Mod1Mask
+ ControlMask = xlib.ControlMask
+ ShiftMask = xlib.ShiftMask
+ Mod2Mask = xlib.Mod2Mask
+ Mod4Mask = xlib.Mod4Mask
+ LockMask = xlib.LockMask
+
+class BUTTON_MASKS(pyenum.IntEnum):
+ AnyModifier = xlib.AnyModifier
+ Button1Mask = xlib.Button1Mask
+ Button2Mask = xlib.Button2Mask
+ Button3Mask = xlib.Button3Mask
+ Button4Mask = xlib.Button4Mask
+ Button5Mask = xlib.Button5Mask
+
+class BUTTONS(pyenum.IntEnum):
+ AnyButton = xlib.AnyButton
+ Button1 = xlib.Button1
+ Button2 = xlib.Button2
+ Button3 = xlib.Button3
+ Button4 = xlib.Button4
+ Button5 = xlib.Button5
+
+class GRAB_MODE(pyenum.IntEnum):
+ GrabModeSync = xlib.GrabModeSync
+ GrabModeAsync = xlib.GrabModeAsync
+
+class WINDOW_VALUE_MASK(pyenum.IntEnum):
+ CWX = xlib.CWX
+ CWY = xlib.CWY
+ CWWidth = xlib.CWWidth
+ CWHeight = xlib.CWHeight
+ CWBorderWidth = xlib.CWBorderWidth
+ CWSibling = xlib.CWSibling
+ CWStackMode = xlib.CWStackMode
+
+class WINDOW_STACKING_METHOD(pyenum.IntEnum):
+ Above = xlib.Above
+ Below = xlib.Below
+ TopIf = xlib.TopIf
+ BottomIf = xlib.BottomIf
+ Opposite = xlib.Opposite
+
+class SET_PROPERTY_MODE(pyenum.IntEnum):
+ PropModeReplace = xlib.PropModeReplace
+ PropModePrepend = xlib.PropModePrepend
+ PropModeAppend = xlib.PropModeAppend
+
+class WINDOW_MAP_STATE(pyenum.IntEnum):
+ IsUnmapped = xlib.IsUnmapped
+ IsUnviewable = xlib.IsUnviewable
+ IsViewable = xlib.IsViewable
+
+class SCREENSAVER_STATE(pyenum.IntEnum):
+ ScreenSaverOff = xlib.ScreenSaverOff
+ ScreenSaverOn = xlib.ScreenSaverOn
+ ScreenSaverCycle = xlib.ScreenSaverCycle
+ ScreenSaverDisabled = xlib.ScreenSaverDisabled
+
+class SCREENSAVER_KIND(pyenum.IntEnum):
+ ScreenSaverBlanked = xlib.ScreenSaverBlanked
+ ScreenSaverInternal = xlib.ScreenSaverInternal
+ ScreenSaverExternal = xlib.ScreenSaverExternal
+
+class KB_GROUP_INDEX(pyenum.IntEnum):
+ XkbGroup1Index = xlib.XkbGroup1Index
+ XkbGroup2Index = xlib.XkbGroup2Index
+ XkbGroup3Index = xlib.XkbGroup3Index
+ XkbGroup4Index = xlib.XkbGroup4Index
+ XkbAnyGroup = xlib.XkbAnyGroup
+ XkbAllGroups = xlib.XkbAllGroups
+
+class NOTIFY_MODES(pyenum.IntEnum):
+ NotifyNormal = xlib.NotifyNormal
+ NotifyGrab = xlib.NotifyGrab
+ NotifyUngrab = xlib.NotifyUngrab
+ NotifyWhileGrabbed = xlib.NotifyWhileGrabbed
+
+class NOTIFY_DETAILS(pyenum.IntEnum):
+ NotifyAncestor = xlib.NotifyAncestor
+ NotifyVirtual = xlib.NotifyVirtual
+ NotifyInferior = xlib.NotifyInferior
+ NotifyNonlinear = xlib.NotifyNonlinear
+ NotifyNonlinearVirtual = xlib.NotifyNonlinearVirtual
+ NotifyPointer = xlib.NotifyPointer
+ NotifyPointerRoot = xlib.NotifyPointerRoot
+ NotifyDetailNone = xlib.NotifyDetailNone
+
+class PROPERTY_NOTIFICATION(pyenum.IntEnum):
+ PropertyNewValue = xlib.PropertyNewValue
+ PropertyDelete = xlib.PropertyDelete
+
+class PROPERTY_FORMAT(pyenum.Enum):
+ """
+ Enum representing the valid formats when getting/setting a property.
+
+ - CHAR: Format with value 8. The value of the property is a char array
+ - SHORT: Format with value 16. The value of the property is a short array
+ - LONG: Format with value 32. The value of the property is a long array
+ """
+
+ CHAR = (8, "b")
+ SHORT = (16, "h")
+ LONG = (32, "l")
+
+ @classmethod
+ def new_from_value(cls, value: Union[str, int]) -> "PROPERTY_FORMAT":
+ for member in cls:
+ if value in member.value:
+ return member
+ raise ValueError(f"The value {value} is not in the enum members")
+
+class DPMS_POWER_LEVEL(pyenum.IntEnum):
+ DPMSModeOn = xlib.DPMSModeOn
+ DPMSModeStandby = xlib.DPMSModeStandby
+ DPMSModeSuspend = xlib.DPMSModeSuspend
+ DPMSModeOff = xlib.DPMSModeOff
+
+class PYLOOP_NEW_LOOP_FLAGS(pyenum.IntEnum):
+ EVFLAG_AUTO = libev.EVFLAG_AUTO
+ EVFLAG_NOENV = libev.EVFLAG_NOENV
+ EVFLAG_FORKCHECK = libev.EVFLAG_FORKCHECK
+ EVFLAG_NOINOTIFY = libev.EVFLAG_NOINOTIFY
+ EVFLAG_SIGNALFD = libev.EVFLAG_SIGNALFD
+ EVFLAG_NOSIGMASK = libev.EVFLAG_NOSIGMASK
+ EVBACKEND_SELECT = libev.EVBACKEND_SELECT
+ EVBACKEND_POLL = libev.EVBACKEND_POLL
+ EVBACKEND_EPOLL = libev.EVBACKEND_EPOLL
+ EVBACKEND_KQUEUE = libev.EVBACKEND_KQUEUE
+ EVBACKEND_DEVPOLL = libev.EVBACKEND_DEVPOLL
+ EVBACKEND_PORT = libev.EVBACKEND_PORT
+ EVBACKEND_ALL = libev.EVBACKEND_ALL
+ EVBACKEND_MASK = libev.EVBACKEND_MASK
+
+class PYLOOP_RUN_LOOP_FLAGS(pyenum.IntEnum):
+ EVRUN_ALWAYS = 0 # Keep handling events until either no event watchers are active anymore or "ev_break" was called
+ EVRUN_ONCE = libev.EVRUN_ONCE
+ EVRUN_NOWAIT = libev.EVRUN_NOWAIT
+
+class PYLOOP_BREAK_LOOP_FLAGS(pyenum.IntEnum):
+ EVBREAK_ALL = libev.EVBREAK_ALL
+ EVBREAK_ONE = libev.EVBREAK_ONE
+ EVBREAK_CANCEL = libev.EVBREAK_CANCEL
+
+class PYIOWATCHER_INIT_FLAGS(pyenum.IntEnum):
+ EV_READ = libev.EV_READ
+ EV_WRITE = libev.EV_WRITE
+ EV_READ_WRITE = libev.EV_READ | libev.EV_WRITE
+
+
+# Functions
+
+def PyXOpenDisplay(display_name: Optional[str]) -> Optional[PyDisplay]:
+ cdef xlib.Display *display
+ cdef char *display_name_c = NULL
+ if display_name is not None:
+ display_name_c = get_char_from_py_string(string=display_name)
+ display = xlib.XOpenDisplay(display_name=display_name_c)
+ if display == NULL:
+ return None
+ return PyDisplay._new_(display=display)
+
+def PyXCloseDisplay(display: PyDisplay) -> None:
+ if display._display == NULL:
+ return
+ xlib.XCloseDisplay(display=display._display)
+
+def PyXDefaultRootWindow(display: PyDisplay) -> int:
+ return xlib.XDefaultRootWindow(display=display._display)
+
+def PyXConnectionNumber(display: PyDisplay) -> int:
+ return xlib.XConnectionNumber(display=display._display)
+
+def PyXGrabKey(
+ display: PyDisplay,
+ keycode: int,
+ modifiers: int,
+ window: int,
+ owner_events: bool,
+ pointer_mode: int,
+ keyboard_mode: int,
+) -> int:
+ return xlib.XGrabKey(
+ display=display._display,
+ keycode=keycode,
+ modifiers=modifiers,
+ grab_window=window,
+ owner_events=owner_events,
+ pointer_mode=pointer_mode,
+ keyboard_mode=keyboard_mode,
+ )
+
+def PyXUngrabKey(display: PyDisplay, keycode: int, modifiers: int, window: int) -> int:
+ return xlib.XUngrabKey(display=display._display, keycode=keycode, modifiers=modifiers, window=window)
+
+def PyXCreateOverrideRedirectWindow(display: PyDisplay, parent: int, width: int, height: int) -> int:
+ cdef xlib.XSetWindowAttributes attrs
+ memset(&attrs, 0, sizeof(attrs))
+ attrs.override_redirect = True
+ return xlib.XCreateWindow(
+ display._display,
+ parent,
+ 0, 0, width, height, 0,
+ 0,
+ xlib.InputOnly,
+ 0,
+ xlib.CWOverrideRedirect,
+ &attrs,
+ )
+
+def PyXMapWindow(display: PyDisplay, window: int) -> int:
+ return xlib.XMapWindow(display=display._display, window=window)
+
+def PyXDestroyWindow(display: PyDisplay, window: int) -> int:
+ return xlib.XDestroyWindow(display=display._display, window=window)
+
+def PyXSetInputFocus(display: PyDisplay, window: int, revert_to: int) -> int:
+ return xlib.XSetInputFocus(
+ display=display._display,
+ focus=window,
+ revert_to=revert_to,
+ time=xlib.CurrentTime,
+ )
+
+def PyXGetInputFocus(display: PyDisplay) -> tuple[int, int]:
+ cdef xlib.Window focus = 0
+ cdef int revert_to = 0
+ _ = xlib.XGetInputFocus(display=display._display, focus_return=&focus, revert_to_return=&revert_to)
+ return (focus, revert_to)
+
+def PyXGrabButton(
+ display: PyDisplay,
+ button: int,
+ modifiers: int,
+ window: int,
+ owner_events: bool,
+ event_mask: int,
+ pointer_mode: int,
+ keyboard_mode: int,
+ confine_to: int,
+ cursor: int,
+) -> int:
+ return xlib.XGrabButton(
+ display=display._display,
+ button=button,
+ modifiers=modifiers,
+ grab_window=window,
+ owner_events=owner_events,
+ event_mask=event_mask,
+ pointer_mode=pointer_mode,
+ keyboard_mode=keyboard_mode,
+ confine_to=confine_to,
+ cursor=cursor,
+ )
+
+def PyXUngrabButton(display: PyDisplay, button: int, modifiers: int, window: int) -> int:
+ return xlib.XUngrabButton(display=display._display, button=button, modifiers=modifiers, grab_window=window)
+
+def PyXSelectInput(display: PyDisplay, window: int, event_mask: int) -> int:
+ return xlib.XSelectInput(display=display._display, window=window, event_mask=event_mask)
+
+def PyXConfigureWindow(display: PyDisplay, window: int, value_mask: int, window_changes: PyXWindowChanges) -> int:
+ return xlib.XConfigureWindow(
+ display=display._display, window=window, value_mask=value_mask, values=&window_changes._xwindowchanges
+ )
+
+def PyXSync(display: PyDisplay, discard: bool) -> int:
+ return xlib.XSync(display=display._display, discard=discard)
+
+# Global reference to python callback
+_pydefault_x_error_handler: Callable[[PyDisplay, PyXErrorEvent], None] = lambda __display__, __event__: None
+
+cdef int default_x_error_handler(xlib.Display *display, xlib.XErrorEvent *error_event):
+ _pydefault_x_error_handler(
+ PyDisplay._new_(display=display), PyXErrorEvent._new_(error_event=error_event)
+ )
+ return 0
+
+def PyXSetErrorHandler(
+ handler: Callable[[PyDisplay, PyXErrorEvent], None]
+) -> Callable[[PyDisplay, PyXErrorEvent], None]:
+ global _pydefault_x_error_handler
+ _pydefault_x_error_handler = handler
+
+ xlib.XSetErrorHandler(handler=default_x_error_handler)
+ return handler
+
+def PyXInternAtom(display: PyDisplay, atom_name: str, only_if_exists: bool) -> int:
+ key: tuple[int, str, bool] = (display._display, atom_name, only_if_exists)
+ cached: Optional[int] = _intern_cache.get(key)
+ if cached is not None:
+ return cached
+ cdef xlib.Atom atom = xlib.XInternAtom(
+ display=display._display, atom_name=get_char_from_py_string(string=atom_name), only_if_exists=only_if_exists
+ )
+ if atom != 0 or not only_if_exists:
+ _intern_cache[key] = atom
+ return atom
+
+def PyXGetWindowProperty(
+ display: PyDisplay, window: int, property_: str
+) -> Optional[tuple[pyarray.array[int], int, str]]:
+ cdef long offset = 0
+ cdef long length = 1024
+ cdef int delete = False
+ cdef xlib.Atom actual_type_return
+ cdef int actual_format_return
+ cdef unsigned long nitems_return
+ cdef unsigned long bytes_after_return
+ cdef unsigned char *prop_return
+ cdef xlib.Atom property_atom = PyXInternAtom(display=display, atom_name=property_, only_if_exists=False)
+ cdef Py_ssize_t filled = 0
+ cdef Py_ssize_t nitems
+ cdef Py_ssize_t itemsize
+ result_buf: Optional[carray.array] = None
+ pyformat: Optional[PROPERTY_FORMAT] = None
+
+ while True:
+ xlib.XGetWindowProperty(
+ display=display._display,
+ window=window,
+ property=property_atom,
+ offset=offset,
+ length=length,
+ delete=delete,
+ req_type=xlib.AnyPropertyType,
+ actual_type_return=&actual_type_return,
+ actual_format_return=&actual_format_return,
+ nitems_return=&nitems_return,
+ bytes_after_return=&bytes_after_return,
+ prop_return=&prop_return
+ )
+ if not nitems_return:
+ return None
+ try:
+ pyformat = PROPERTY_FORMAT.new_from_value(value=actual_format_return)
+ if result_buf is None:
+ result_buf = pyarray.array(pyformat.value[1])
+ nitems = nitems_return
+ itemsize = result_buf.itemsize
+ filled += nitems
+ carray.resize(result_buf, filled)
+ memcpy(
+ result_buf.data.as_voidptr + (filled - nitems) * itemsize,
+ prop_return,
+ (nitems * itemsize),
+ )
+ xlib.XFree(data=prop_return)
+ prop_return = NULL
+ except ValueError:
+ if prop_return != NULL:
+ xlib.XFree(data=prop_return)
+ return None
+ if bytes_after_return:
+ offset += ((nitems_return * actual_format_return + 31) // 32)
+ length = ceil(x=(bytes_after_return / 4.0))
+ if length < 1:
+ length = 1
+ else:
+ break
+ return (result_buf, actual_type_return, "")
+
+def PyXChangeProperty(
+ display: PyDisplay,
+ window: int,
+ property_name: str,
+ atom_type: int,
+ format_: int,
+ mode: int,
+ property_data: pyarray.array[int],
+) -> int:
+ pyformat = PROPERTY_FORMAT.new_from_value(value=format_)
+ cdef carray.array data_ = carray.array(pyformat.value[1], property_data)
+ return xlib.XChangeProperty(
+ display=display._display,
+ window=window,
+ property=PyXInternAtom(display=display, atom_name=property_name, only_if_exists=False),
+ type=atom_type,
+ format=format_,
+ mode=mode,
+ data=data_.data.as_voidptr,
+ nelements=len(data_)
+ )
+
+def PyXGetWindowAttributes(display: PyDisplay, window: int) -> Optional[PyXWindowAttributes]:
+ cdef xlib.XWindowAttributes window_attributes
+ cdef int result = xlib.XGetWindowAttributes(
+ display=display._display, window=window, window_attributes_return=&window_attributes
+ )
+ if not result:
+ return None
+ return PyXWindowAttributes._new_(window_attributes=window_attributes)
+
+def PyXQueryTree(display: PyDisplay, window: int) -> Optional[PyXWindowTree]:
+ cdef xlib.Window root_return
+ cdef xlib.Window parent_return
+ cdef xlib.Window *children_return
+ cdef unsigned int nchildren_return
+
+ cdef int result = xlib.XQueryTree(
+ display=display._display,
+ window=window,
+ root_return=&root_return,
+ parent_return=&parent_return,
+ children_return=&children_return,
+ nchildren_return=&nchildren_return
+ )
+ if not result:
+ return None
+ children: list[int] = []
+ for i in range(nchildren_return):
+ children.append(children_return[i])
+ xlib.XFree(data=children_return)
+ return PyXWindowTree._new_(
+ window=window, root=root_return, parent=parent_return, children=children
+ )
+
+def PyXGetGeometry(display: PyDisplay, window: int) -> Optional[PyXWindowGeometry]:
+ cdef xlib.Window root_return
+ cdef int x_return, y_return
+ cdef unsigned int width_return, height_return
+ cdef unsigned int border_width_return
+ cdef unsigned int depth_return
+
+ cdef int result = xlib.XGetGeometry(
+ display=display._display,
+ window=window,
+ root_return=&root_return,
+ x_return=&x_return, y_return=&y_return,
+ width_return=&width_return, height_return=&height_return,
+ border_width_return=&border_width_return,
+ depth_return=&depth_return
+ )
+ if not result:
+ return None
+ return PyXWindowGeometry._new_(
+ window=window,
+ root=root_return,
+ x=x_return, y=y_return,
+ width=width_return, height=height_return,
+ border_width=border_width_return,
+ depth=depth_return
+ )
+
+def PyXScreenSaverQueryInfo(display: PyDisplay, window: int) -> Optional[PyXScreenSaverInfo]:
+ cdef xlib.XScreenSaverInfo info
+ cdef int result = xlib.XScreenSaverQueryInfo(
+ display=display._display, drawable=window, saver_info=&info
+ )
+ if not result:
+ return None
+ return PyXScreenSaverInfo._new_(info=info)
+
+def PyXkbGetState(display: PyDisplay, device_spec: int) -> PyXkbStateRec:
+ cdef xlib.XkbStateRec state
+ xlib.XkbGetState(
+ display=display._display, device_spec=device_spec, state_return=&state
+ )
+ return PyXkbStateRec._new_(kb_state=state)
+
+def PyXkbLockGroup(display: PyDisplay, device_spec: int, group: int) -> int:
+ return xlib.XkbLockGroup(display=display._display, device_spec=device_spec, group=group)
+
+def PyXFlush(display: PyDisplay) -> int:
+ return xlib.XFlush(display=display._display)
+
+def PyXTestQueryExtension(display: PyDisplay) -> bool:
+ cdef int event_base = 0
+ cdef int error_base = 0
+ cdef int major = 0
+ cdef int minor = 0
+ return bool(
+ xlib.XTestQueryExtension(
+ display._display, &event_base, &error_base, &major, &minor
+ )
+ )
+
+def PyXTestFakeKeyEvent(display: PyDisplay, keycode: int, press: bool, delay: int) -> int:
+ return xlib.XTestFakeKeyEvent(display._display, keycode, press, delay)
+
+def PyXTestFakeButtonEvent(display: PyDisplay, button: int, press: bool, delay: int) -> int:
+ return xlib.XTestFakeButtonEvent(display._display, button, press, delay)
+
+def PyXTestFakeMotionEvent(display: PyDisplay, screen: int, x: int, y: int, delay: int) -> int:
+ return xlib.XTestFakeMotionEvent(display._display, screen, x, y, delay)
+
+def PyXGetAtomName(display: PyDisplay, atom: int) -> str:
+ key: tuple[int, int] = (display._display, atom)
+ cached: Optional[str] = _atom_name_cache.get(key)
+ if cached is not None:
+ return cached
+ cdef char *name = xlib.XGetAtomName(display=display._display, atom=atom)
+ if name == NULL:
+ return ""
+ cdef bytes pybytes = name
+ cdef str result = pybytes.decode().strip()
+ xlib.XFree(data=name)
+ _atom_name_cache[key] = result
+ return result
+
+def PyXSendEvent(
+ display: PyDisplay, window: int, propagate: bool, event_mask: int, xevent: PyXEvent
+) -> int:
+ return xlib.XSendEvent(
+ display=display._display,
+ window=window,
+ propagate=propagate,
+ event_mask=event_mask,
+ event_send=&xevent._native_event,
+ )
+
+cdef cairo.cairo_status_t writePngStream(void *closure, const unsigned char *data, unsigned int length):
+ cdef IconData *png_buffer = closure
+ png_buffer.data = realloc(ptr=png_buffer.data, newsize=(sizeof(unsigned char) * (png_buffer.length + length)))
+ memcpy(pto=&png_buffer.data[png_buffer.length], pfrom=data, size=(sizeof(unsigned char) * length))
+ png_buffer.length += length
+ return cairo.cairo_status_t.CAIRO_STATUS_SUCCESS
+
+cdef IconData readSvgIcon(str iconPath):
+ cdef IconData iconData = IconData(length=0, data=NULL, magick_memory=False) # type: ignore
+ cdef resvg.resvg_options *options = resvg.resvg_options_create()
+ cdef resvg.resvg_render_tree *tree = NULL
+ cdef cairo.cairo_surface_t *surface = NULL
+ cdef int result = resvg.resvg_parse_tree_from_file(
+ file_path=get_char_from_py_string(string=iconPath), opt=options, tree=&tree
+ )
+ resvg.resvg_options_destroy(opt=options)
+ if result != resvg.resvg_error.RESVG_OK:
+ print(f"An error occurred reading the .svg file {iconPath}")
+ return iconData
+
+ cdef resvg.resvg_size size = resvg.resvg_get_image_size(tree=tree)
+ surface = cairo.cairo_image_surface_create(
+ format=cairo.cairo_format_t.CAIRO_FORMAT_ARGB32, width=size.width, height=size.height
+ )
+ cdef unsigned char *surface_data = cairo.cairo_image_surface_get_data(surface=surface)
+ resvg.resvg_render(
+ tree=tree,
+ fit_to=resvg.resvg_fit_to(type=resvg.resvg_fit_to_type.RESVG_FIT_TO_TYPE_ORIGINAL, value=1.0), # type: ignore
+ transform=resvg.resvg_transform_identity(),
+ width=size.width,
+ height=size.height,
+ pixmap=surface_data
+ )
+
+ # Transform RGBA to BGRA
+ cdef unsigned char R
+ cdef unsigned char B
+ for i in range(0, (size.width * size.height * 4), 4):
+ R = surface_data[i]
+ B = surface_data[i + 2]
+ surface_data[i] = B
+ surface_data[i + 2] = R
+
+ if cairo.cairo_surface_write_to_png_stream(
+ surface=surface, write_func=writePngStream, closure=&iconData
+ ) != cairo.cairo_status_t.CAIRO_STATUS_SUCCESS:
+ release_icon_data(&iconData)
+
+ resvg.resvg_tree_destroy(tree=tree)
+ cairo.cairo_surface_destroy(surface=surface)
+ return iconData
+
+def PyRenderSvgToArgb(filepath: str, size: int) -> Optional[bytes]:
+ """Rasterize an SVG to `size`x`size` raw ARGB32 pixels, network byte order (A,R,G,B per pixel).
+
+ No cairo/PNG involved: resvg renders straight into our own buffer, for callers (the tray icon)
+ that want pixels to send over D-Bus rather than a file on disk. `None` on a parse failure.
+ """
+ cdef resvg.resvg_options *options = resvg.resvg_options_create()
+ cdef resvg.resvg_render_tree *tree = NULL
+ cdef int result = resvg.resvg_parse_tree_from_file(
+ file_path=get_char_from_py_string(string=filepath), opt=options, tree=&tree
+ )
+ resvg.resvg_options_destroy(opt=options)
+ if result != resvg.resvg_error.RESVG_OK:
+ return None
+
+ cdef uint32_t dimension = size
+ cdef size_t buffer_size = (dimension * dimension * 4)
+ cdef unsigned char *buffer = malloc(size=buffer_size)
+ memset(buffer, 0, buffer_size)
+
+ resvg.resvg_render(
+ tree=tree,
+ fit_to=resvg.resvg_fit_to(type=resvg.resvg_fit_to_type.RESVG_FIT_TO_TYPE_WIDTH, value=size),
+ transform=resvg.resvg_transform_identity(),
+ width=dimension,
+ height=dimension,
+ pixmap=buffer,
+ )
+ resvg.resvg_tree_destroy(tree=tree)
+
+ # resvg writes RGBA per pixel; the SNI tray protocol wants ARGB (network byte order: A,R,G,B).
+ argb: bytearray = bytearray(buffer_size)
+ cdef size_t i
+ for i in range(0, buffer_size, 4):
+ argb[i] = buffer[i + 3]
+ argb[i + 1] = buffer[i]
+ argb[i + 2] = buffer[i + 1]
+ argb[i + 3] = buffer[i + 2]
+ free(buffer)
+ return bytes(argb)
+
+cdef IconData readIconWithImageMagick(str iconPath):
+ cdef IconData iconData = IconData(length=0, data=NULL, magick_memory=False) # type: ignore
+ imagemagick.MagickWandGenesis()
+ cdef imagemagick.MagickWand *wand = imagemagick.NewMagickWand()
+ cdef imagemagick.PixelWand *pixelWand = imagemagick.NewPixelWand()
+ imagemagick.PixelSetColor(
+ wand=pixelWand, color=get_char_from_py_string(string="transparent") # Color for the background
+ )
+ # The background has to be assigned before loading the file image to avoid white backgrounds
+ imagemagick.MagickSetBackgroundColor(wand=wand, background=pixelWand)
+
+ cdef int result = imagemagick.MagickPingImage(
+ wand=wand, filename=get_char_from_py_string(string=iconPath)
+ )
+ if not result:
+ print(f"An error occurred pinging the file {iconPath}")
+ readIconWithImageMagickFinalizer(wand=wand, pixelWand=pixelWand)
+ return iconData
+ result = imagemagick.MagickReadImage(
+ wand=wand, filename=get_char_from_py_string(string=iconPath) # Read the file
+ )
+ if not result:
+ print(f"An error occurred reading the file {iconPath}")
+ readIconWithImageMagickFinalizer(wand=wand, pixelWand=pixelWand)
+ return iconData
+ result = imagemagick.MagickSetImageFormat(
+ wand=wand, format=get_char_from_py_string(string="png") # Convert the wand (image) to PNG format
+ )
+ if not result:
+ print("An error occurred converting the file to png format")
+ readIconWithImageMagickFinalizer(wand=wand, pixelWand=pixelWand)
+ return iconData
+
+ cdef size_t iconRawDataSize
+ cdef unsigned char *iconRawData = imagemagick.MagickGetImageBlob(wand=wand, size=&iconRawDataSize)
+ readIconWithImageMagickFinalizer(wand=wand, pixelWand=pixelWand)
+
+ iconData.length = iconRawDataSize
+ iconData.data = iconRawData
+ iconData.magick_memory = True
+ return iconData
+
+cdef readIconWithImageMagickFinalizer(imagemagick.MagickWand *wand, imagemagick.PixelWand *pixelWand):
+ if wand != NULL:
+ imagemagick.DestroyMagickWand(wand=wand)
+ if pixelWand != NULL:
+ imagemagick.DestroyPixelWand(wand=pixelWand)
+ imagemagick.MagickWandTerminus()
+
+def PySetWindowIcon(display: PyDisplay, window: int, filepath: Path) -> bool:
+ cdef libgd.gdImagePtr imagePtr = NULL
+ cdef IconData iconData = IconData(length=0, data=NULL, magick_memory=False) # type: ignore
+ icon_read: bool = False
+ if filepath.suffix == ".svg": # Read image with resvg if it has .svg extension or with MagickWand (ImageMagick) for all other extensions
+ try:
+ iconData = readSvgIcon(iconPath=str(filepath))
+ if iconData.length:
+ icon_read = True
+ except:
+ icon_read = False
+ if not icon_read:
+ try:
+ iconData = readIconWithImageMagick(iconPath=str(filepath))
+ if iconData.length:
+ icon_read = True
+ except:
+ pass
+
+ if not icon_read:
+ return False
+ if iconData.length and iconData.data != NULL:
+ imagePtr = libgd.gdImageCreateFromPngPtr(size=iconData.length, data=iconData.data)
+ release_icon_data(&iconData)
+ if imagePtr == NULL:
+ return False
+
+ cdef int width = libgd.gdImageSX(im=imagePtr)
+ cdef int height = libgd.gdImageSY(im=imagePtr)
+ cdef unsigned int ndata = (width * height) + 2
+ cdef libgd.CARD32 *_net_wm_icon_data = calloc(count=ndata, eltsize=sizeof(libgd.CARD32))
+ if _net_wm_icon_data == NULL:
+ libgd.gdImageDestroy(im=imagePtr)
+ return False
+ _net_wm_icon_data[0] = width
+ _net_wm_icon_data[1] = height
+ cdef unsigned char *cols = NULL
+ cdef int pixcolour
+ cdef int alpha
+ cdef int index = 2 # Start at 2 cause 0 contains the width and 1 the height
+ # Transform data from RGBA to BGRA
+ for y in range(height):
+ for x in range(width):
+ cols = &_net_wm_icon_data[index]
+ index += 1
+ pixcolour = libgd.gdImageGetPixel(im=imagePtr, x=x, y=y)
+ cols[0] = libgd.gdImageBlue(im=imagePtr, color=pixcolour)
+ cols[1] = libgd.gdImageGreen(im=imagePtr, color=pixcolour)
+ cols[2] = libgd.gdImageRed(im=imagePtr, color=pixcolour)
+
+ # gd alpha: 0 opaque, 127 transparent -> scale to 0..255 for _NET_WM_ICON
+ alpha = 127 - libgd.gdImageAlpha(im=imagePtr, color=pixcolour)
+ cols[3] = (255 if alpha >= 127 else (alpha * 255) // 127)
+ libgd.gdImageDestroy(im=imagePtr)
+
+ # Change icon using _NET_WM_ICON property, data is BGRA
+ cdef xlib.Atom property_ = PyXInternAtom(display=display, atom_name="_NET_WM_ICON", only_if_exists=False)
+ cdef xlib.Atom type_ = PyXInternAtom(display=display, atom_name="CARDINAL", only_if_exists=False)
+ cdef int result = xlib.XChangeProperty(
+ display=display._display,
+ window=window,
+ property=property_,
+ type=type_,
+ format=32,
+ mode=xlib.PropModeReplace,
+ data=_net_wm_icon_data,
+ nelements=ndata
+ )
+ if result:
+ xlib.XFlush(display=display._display)
+ free(_net_wm_icon_data)
+ return True
+
+def PyXStringToKeysym(string: str) -> int:
+ return xlib.XStringToKeysym(string=get_char_from_py_string(string=string))
+
+def PyXKeysymToKeycode(display: PyDisplay, keysym: int) -> int:
+ return xlib.XKeysymToKeycode(display=display._display, keysym=keysym)
+
+def PyXPending(display: PyDisplay) -> int:
+ return xlib.XPending(display=display._display)
+
+def PyXNextEvent(display: PyDisplay) -> PyXEvent:
+ cdef xlib.XEvent event
+ xlib.XNextEvent(display=display._display, event_return=&event)
+ try:
+ event_type: EVENT_TYPES = EVENT_TYPES(event.type)
+ if event_type == EVENT_TYPES.ErrorEvent:
+ return PyXErrorEvent._new_(error_event=&event.xerror)
+ elif event_type == EVENT_TYPES.KeyPress or event_type == EVENT_TYPES.KeyRelease:
+ return PyXKeyEvent._new_(key_event=&event.xkey)
+ elif event_type == EVENT_TYPES.ButtonPress or event_type == EVENT_TYPES.ButtonRelease:
+ return PyXButtonEvent._new_(button_event=&event.xbutton)
+ elif event_type == EVENT_TYPES.FocusIn or event_type == EVENT_TYPES.FocusOut:
+ return PyXFocusChangeEvent._new_(focus_change_event=&event.xfocus)
+ elif event_type == EVENT_TYPES.CreateNotify:
+ return PyXCreateWindowEvent._new_(create_window_event=&event.xcreatewindow)
+ elif event_type == EVENT_TYPES.DestroyNotify:
+ return PyXDestroyWindowEvent._new_(destroy_window_event=&event.xdestroywindow)
+ elif event_type == EVENT_TYPES.MapNotify:
+ return PyXMapEvent._new_(map_event=&event.xmap)
+ elif event_type == EVENT_TYPES.UnmapNotify:
+ return PyXUnmapEvent._new_(unmap_event=&event.xunmap)
+ elif event_type == EVENT_TYPES.ConfigureNotify:
+ return PyXConfigureEvent._new_(configure_event=&event.xconfigure)
+ elif event_type == EVENT_TYPES.PropertyNotify:
+ return PyXPropertyEvent._new_(property_event=&event.xproperty)
+ elif event_type == EVENT_TYPES.ClientMessage:
+ return PyXClientMessageEvent._new_(client_message_event=&event.xclient)
+ return PyXEvent._new_(event=&event)
+ except ValueError:
+ return PyXEvent._new_(event=&event)
+
+def PyGetDPMSInfo(display: PyDisplay) -> PyDPMSInfo:
+ cdef xlib.BOOL state
+ cdef xlib.CARD16 power_level
+ xlib.DPMSInfo(display=display._display, power_level=&power_level, state=&state)
+ return PyDPMSInfo._new_(state=state, power_level=power_level)
+
+def PyDPMSEnable(display: PyDisplay) -> bool:
+ return bool(xlib.DPMSEnable(display=display._display))
+
+def PyDPMSDisable(display: PyDisplay) -> bool:
+ return bool(xlib.DPMSDisable(display=display._display))
\ No newline at end of file
diff --git a/orcsome3/libs/cython_libs/resvg/__init__.pxd b/orcsome3/libs/cython_libs/resvg/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/orcsome3/libs/cython_libs/resvg/resvg.pxd b/orcsome3/libs/cython_libs/resvg/resvg.pxd
new file mode 100644
index 0000000..a7ba1f4
--- /dev/null
+++ b/orcsome3/libs/cython_libs/resvg/resvg.pxd
@@ -0,0 +1,40 @@
+from libc.stdint cimport uint32_t
+
+# External libraries
+
+cdef extern from "resvg.h":
+ # Defined types
+ ctypedef struct resvg_options:
+ pass
+ ctypedef struct resvg_render_tree:
+ pass
+ ctypedef struct resvg_transform:
+ pass
+ ctypedef struct resvg_size:
+ double width
+ double height
+ ctypedef enum resvg_error:
+ RESVG_OK
+ RESVG_ERROR_NOT_AN_UTF8_STR
+ RESVG_ERROR_FILE_OPEN_FAILED
+ RESVG_ERROR_MALFORMED_GZIP
+ RESVG_ERROR_ELEMENTS_LIMIT_REACHED
+ RESVG_ERROR_INVALID_SIZE
+ RESVG_ERROR_PARSING_FAILED
+ ctypedef enum resvg_fit_to_type:
+ RESVG_FIT_TO_TYPE_ORIGINAL
+ RESVG_FIT_TO_TYPE_WIDTH
+ RESVG_FIT_TO_TYPE_HEIGHT
+ RESVG_FIT_TO_TYPE_ZOOM
+ ctypedef struct resvg_fit_to:
+ resvg_fit_to_type type
+ float value
+
+ # Functions
+ resvg_options *resvg_options_create()
+ int resvg_parse_tree_from_file(const char *file_path, const resvg_options *opt, resvg_render_tree **tree)
+ void resvg_options_destroy(resvg_options *opt)
+ resvg_size resvg_get_image_size(const resvg_render_tree *tree)
+ resvg_transform resvg_transform_identity()
+ void resvg_render(const resvg_render_tree *tree, resvg_fit_to fit_to, resvg_transform transform, uint32_t width, uint32_t height, char *pixmap)
+ void resvg_tree_destroy(resvg_render_tree *tree)
\ No newline at end of file
diff --git a/orcsome3/libs/cython_libs/xlib/__init__.pxd b/orcsome3/libs/cython_libs/xlib/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/orcsome3/libs/cython_libs/xlib/xlib.pxd b/orcsome3/libs/cython_libs/xlib/xlib.pxd
new file mode 100644
index 0000000..4f54cef
--- /dev/null
+++ b/orcsome3/libs/cython_libs/xlib/xlib.pxd
@@ -0,0 +1,520 @@
+# External libraries
+
+cdef extern from "": # pkg-config: x11
+ # This library includes automatically:
+ # -
+
+ # Constants
+ long CurrentTime
+ long AnyPropertyType
+ long NoSymbol # special keysym
+ long AnyKey # special keycode
+
+ # Input Event Masks
+ long NoEventMask
+ long StructureNotifyMask
+ long SubstructureNotifyMask
+ long SubstructureRedirectMask
+ long PropertyChangeMask
+ long FocusChangeMask
+ long KeyPressMask
+ long KeyReleaseMask
+ long ButtonPressMask
+ long ButtonReleaseMask
+
+ # GrabPointer, GrabButton, GrabKeyboard, GrabKey Modes
+ int GrabModeSync
+ int GrabModeAsync
+
+ # Key Masks
+ int AnyModifier
+ int Mod1Mask
+ int ControlMask
+ int ShiftMask
+ int Mod2Mask
+ int Mod4Mask
+ int LockMask
+
+ # Button Masks
+ int Button1Mask
+ int Button2Mask
+ int Button3Mask
+ int Button4Mask
+ int Button5Mask
+
+ # Button names
+ int AnyButton
+ int Button1
+ int Button2
+ int Button3
+ int Button4
+ int Button5
+
+ # ConfigureWindow Window value mask bits
+ int CWX
+ int CWY
+ int CWWidth
+ int CWHeight
+ int CWBorderWidth
+ int CWSibling
+ int CWStackMode
+
+ # Window stacking method (in configureWindow)
+ int Above
+ int Below
+ int TopIf
+ int BottomIf
+ int Opposite
+
+ # Property modes
+ int PropModeReplace
+ int PropModePrepend
+ int PropModeAppend
+
+ # Map State Used in GetWindowAttributes reply
+ int IsUnmapped
+ int IsUnviewable
+ int IsViewable
+
+ # CreateWindow
+ int InputOutput
+ int InputOnly
+ long CopyFromParent
+ unsigned long CWOverrideRedirect
+
+ # SetInputFocus / GetInputFocus
+ int RevertToNone
+ int RevertToPointerRoot
+ int RevertToParent
+
+ # Event names. Used in "type" field in XEvent structures
+ int KeyPress
+ int KeyRelease
+ int ButtonPress
+ int ButtonRelease
+ int MotionNotify
+ int EnterNotify
+ int LeaveNotify
+ int FocusIn
+ int FocusOut
+ int Expose
+ int GraphicsExpose
+ int NoExpose
+ int VisibilityNotify
+ int CreateNotify
+ int DestroyNotify
+ int UnmapNotify
+ int MapNotify
+ int MapRequest
+ int ReparentNotify
+ int ConfigureNotify
+ int GravityNotify
+ int ResizeRequest
+ int ConfigureRequest
+ int CirculateNotify
+ int CirculateRequest
+ int PropertyNotify
+ int SelectionClear
+ int SelectionRequest
+ int SelectionNotify
+ int ColormapNotify
+ int ClientMessage
+ int MappingNotify
+ int KeymapNotify
+ int GenericEvent
+
+ # Notify Modes
+ int NotifyNormal
+ int NotifyGrab
+ int NotifyUngrab
+ int NotifyWhileGrabbed
+
+ # Notify Details
+ int NotifyAncestor
+ int NotifyVirtual
+ int NotifyInferior
+ int NotifyNonlinear
+ int NotifyNonlinearVirtual
+ int NotifyPointer
+ int NotifyPointerRoot
+ int NotifyDetailNone
+
+ # Property notification
+ int PropertyNewValue
+ int PropertyDelete
+
+ # Defined types
+ ctypedef struct Display:
+ pass
+ ctypedef unsigned long Time
+ ctypedef unsigned long XID
+ ctypedef XID Window
+ ctypedef XID Cursor
+ ctypedef XID Drawable
+ ctypedef XID Pixmap
+ ctypedef XID Colormap
+ ctypedef struct Visual:
+ pass
+ ctypedef struct XSetWindowAttributes:
+ Pixmap background_pixmap
+ unsigned long background_pixel
+ Pixmap border_pixmap
+ unsigned long border_pixel
+ int bit_gravity
+ int win_gravity
+ int backing_store
+ unsigned long backing_planes
+ unsigned long backing_pixel
+ int save_under
+ long event_mask
+ long do_not_propagate_mask
+ int override_redirect
+ Colormap colormap
+ Cursor cursor
+ ctypedef XID KeySym
+ ctypedef unsigned char KeyCode
+ ctypedef unsigned long Atom
+ ctypedef struct XWindowChanges:
+ int x, y
+ int width, height
+ int border_width
+ Window sibling
+ int stack_mode
+ ctypedef struct XAnyEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window window
+ ctypedef struct XKeyEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window window
+ Window root
+ Window subwindow
+ Time time
+ int x, y
+ int x_root, y_root
+ unsigned int state
+ unsigned int keycode
+ int same_screen
+ ctypedef struct XButtonEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window window
+ Window root
+ Window subwindow
+ Time time
+ int x, y
+ int x_root, y_root
+ unsigned int state
+ unsigned int button
+ int same_screen
+ ctypedef struct XFocusChangeEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window window
+ int mode
+ int detail
+ ctypedef struct XCreateWindowEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window parent
+ Window window
+ int x, y
+ int width, height
+ int border_width
+ int override_redirect
+ ctypedef struct XDestroyWindowEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window event
+ Window window
+ ctypedef struct XMapEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window event
+ Window window
+ int override_redirect
+ ctypedef struct XUnmapEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window event
+ Window window
+ int from_configure
+ ctypedef struct XConfigureEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window event
+ Window window
+ int x, y
+ int width, height
+ int border_width
+ Window above
+ int override_redirect
+ ctypedef struct XPropertyEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window window
+ Atom atom
+ Time time
+ int state
+ ctypedef struct XClientMessageEvent:
+ int type
+ unsigned long serial
+ int send_event
+ Display *display
+ Window window
+ Atom message_type
+ int format
+ char[20] b "data.b"
+ short[10] s "data.s"
+ long[5] l "data.l"
+ ctypedef struct XErrorEvent:
+ int type
+ Display *display
+ XID resourceid
+ unsigned long serial
+ unsigned char error_code
+ unsigned char request_code
+ unsigned char minor_code
+ ctypedef union XEvent:
+ int type
+ XAnyEvent xany
+ XKeyEvent xkey
+ XButtonEvent xbutton
+ XFocusChangeEvent xfocus
+ XCreateWindowEvent xcreatewindow
+ XDestroyWindowEvent xdestroywindow
+ XMapEvent xmap
+ XUnmapEvent xunmap
+ XConfigureEvent xconfigure
+ XPropertyEvent xproperty
+ XErrorEvent xerror
+ XClientMessageEvent xclient
+ ctypedef int (*XErrorHandler)(Display *display, XErrorEvent *error_event) # type: ignore
+ ctypedef struct XWindowAttributes:
+ int x, y
+ int width, height
+ int border_width
+ int depth
+ Window root
+ int map_state
+ long all_event_masks
+ long your_event_mask
+ long do_not_propagate_mask
+ int override_redirect
+
+ # Functions
+ Display *XOpenDisplay(char *display_name)
+ int XCloseDisplay(Display *display)
+ Window XDefaultRootWindow(Display *display)
+ int XConnectionNumber(Display *display)
+ Window XCreateWindow(
+ Display *display,
+ Window parent,
+ int x,
+ int y,
+ unsigned int width,
+ unsigned int height,
+ unsigned int border_width,
+ int depth,
+ unsigned int clazz,
+ Visual *visual,
+ unsigned long valuemask,
+ XSetWindowAttributes *attributes
+ )
+ int XMapWindow(Display *display, Window window)
+ int XDestroyWindow(Display *display, Window window)
+ int XSetInputFocus(Display *display, Window focus, int revert_to, Time time)
+ int XGetInputFocus(Display *display, Window *focus_return, int *revert_to_return)
+ int XGrabKey(
+ Display *display,
+ int keycode,
+ unsigned int modifiers,
+ Window grab_window,
+ int owner_events,
+ int pointer_mode,
+ int keyboard_mode
+ )
+ int XUngrabKey(Display *display, int keycode, unsigned int modifiers, Window window)
+ int XGrabButton(
+ Display *display,
+ unsigned int button,
+ unsigned int modifiers,
+ Window grab_window,
+ int owner_events,
+ unsigned int event_mask,
+ int pointer_mode,
+ int keyboard_mode,
+ Window confine_to,
+ Cursor cursor
+ )
+ int XUngrabButton(Display *display, unsigned int button, unsigned int modifiers, Window grab_window)
+ int XSelectInput(Display *display, Window window, long event_mask)
+ int XConfigureWindow(
+ Display *display,
+ Window window,
+ unsigned int value_mask,
+ XWindowChanges *values
+ )
+ int XSync(Display *display, int discard)
+ XErrorHandler XSetErrorHandler(XErrorHandler handler)
+ int XGetWindowProperty(
+ Display *display,
+ Window window,
+ Atom property,
+ long offset, long length,
+ int delete,
+ Atom req_type,
+ Atom *actual_type_return,
+ int *actual_format_return,
+ unsigned long *nitems_return,
+ unsigned long *bytes_after_return,
+ unsigned char **prop_return
+ )
+ int XChangeProperty(
+ Display *display,
+ Window window,
+ Atom property,
+ Atom type,
+ int format,
+ int mode,
+ const unsigned char *data,
+ int nelements
+ )
+ Atom XInternAtom(Display *display, char *atom_name, int only_if_exists)
+ int XFree(void *data)
+ int XGetWindowAttributes(
+ Display *display, Window window, XWindowAttributes *window_attributes_return
+ )
+ int XQueryTree(
+ Display *display,
+ Window window,
+ Window *root_return,
+ Window *parent_return,
+ Window **children_return,
+ unsigned int *nchildren_return
+ )
+ int XGetGeometry(
+ Display *display,
+ Drawable window,
+ Window *root_return,
+ int *x_return,
+ int *y_return,
+ unsigned int *width_return,
+ unsigned int *height_return,
+ unsigned int *border_width_return,
+ unsigned int *depth_return
+ )
+ int XFlush(Display *display)
+ char *XGetAtomName(Display *display, Atom atom)
+ int XSendEvent(Display *display, Window window, int propagate, long event_mask, XEvent *event_send)
+ KeySym XStringToKeysym(const char *string)
+ KeyCode XKeysymToKeycode(Display *display, KeySym keysym)
+ int XPending(Display *display)
+ int XNextEvent(Display *display, XEvent *event_return)
+ int XGetErrorText(Display *display, int code, char *buffer_return, int length)
+
+cdef extern from "": # pkg-config: xscrnsaver
+ # Constants
+ # State of screensaver
+ int ScreenSaverOff
+ int ScreenSaverOn
+ int ScreenSaverCycle
+ int ScreenSaverDisabled
+
+ # Kind of screensaver
+ int ScreenSaverBlanked
+ int ScreenSaverInternal
+ int ScreenSaverExternal
+
+ # Defined types
+ ctypedef struct XScreenSaverInfo:
+ Window window
+ int state
+ int kind
+ unsigned long til_or_since
+ unsigned long idle
+ unsigned long eventMask
+
+ # Functions
+ int XScreenSaverQueryInfo(Display *display, Drawable drawable, XScreenSaverInfo *saver_info)
+
+cdef extern from "": # pkg-config: xext
+ # This library includes automatically:
+ # -
+ # -
+
+ # Constants
+ int XkbUseCoreKbd
+
+ # Group index
+ int XkbGroup1Index
+ int XkbGroup2Index
+ int XkbGroup3Index
+ int XkbGroup4Index
+ int XkbAnyGroup
+ int XkbAllGroups
+
+ # Defined types
+ ctypedef struct XkbStateRec:
+ unsigned char group
+ unsigned char locked_group
+ unsigned short base_group
+ unsigned short latched_group
+ unsigned char mods
+ unsigned char base_mods
+ unsigned char latched_mods
+ unsigned char locked_mods
+ unsigned char compat_state
+ unsigned char grab_mods
+ unsigned char compat_grab_mods
+ unsigned char lookup_mods
+ unsigned char compat_lookup_mods
+ unsigned short ptr_buttons
+
+ # Functions
+ int XkbGetState(Display *display, unsigned int device_spec, XkbStateRec *state_return)
+ int XkbLockGroup(Display *display, unsigned int device_spec, unsigned int group)
+
+cdef extern from "": # pkg-config: xext
+ # Constants
+ int DPMSModeOn
+ int DPMSModeStandby
+ int DPMSModeSuspend
+ int DPMSModeOff
+
+ # Defined types
+ ctypedef unsigned short CARD16
+ ctypedef unsigned char CARD8
+ ctypedef CARD8 BOOL
+
+ # Functions
+ int DPMSInfo(Display *display, CARD16 *power_level, BOOL *state)
+ int DPMSEnable(Display *display)
+ int DPMSDisable(Display *display)
+
+cdef extern from "": # pkg-config: xtst
+ int XTestQueryExtension(Display *display, int *event_basep, int *error_basep, int *majorp, int *minorp)
+ int XTestFakeKeyEvent(Display *display, unsigned int keycode, int is_press, unsigned long delay)
+ int XTestFakeButtonEvent(Display *display, unsigned int button, int is_press, unsigned long delay)
+ int XTestFakeMotionEvent(Display *display, int screen, int x, int y, unsigned long delay)
diff --git a/orcsome3/libs/ev.py b/orcsome3/libs/ev.py
new file mode 100644
index 0000000..dbed1c8
--- /dev/null
+++ b/orcsome3/libs/ev.py
@@ -0,0 +1,336 @@
+"""Thin Python wrappers around libev (loop + IO / signal / timer watchers) via orcsome3_backend."""
+
+from __future__ import annotations
+
+import time
+from enum import Enum
+from signal import Signals
+from typing import Callable, Optional, Union
+
+import orcsome3_backend
+from orcsome3.utils import Final
+
+
+class TYPES(metaclass=Final):
+ """Type aliases for Cython objects."""
+
+ Cython_Loop: type = orcsome3_backend.PyLoop
+ Cython_IOWatcher: type = orcsome3_backend.PyIOWatcher
+ Cython_SignalWatcher: type = orcsome3_backend.PySignalWatcher
+ Cython_TimerWatcher: type = orcsome3_backend.PyTimerWatcher
+ Cython_StatWatcher: type = orcsome3_backend.PyStatWatcher
+
+
+class Loop:
+ """libev event loop wrapper."""
+
+ cython_loop: Optional[orcsome3_backend.PyLoop] = None
+
+ class NewLoopFlags(int, Enum):
+ EVFLAG_AUTO = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVFLAG_AUTO
+ EVFLAG_NOENV = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVFLAG_NOENV
+ EVFLAG_FORKCHECK = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVFLAG_FORKCHECK
+ EVFLAG_NOINOTIFY = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVFLAG_NOINOTIFY
+ EVFLAG_SIGNALFD = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVFLAG_SIGNALFD
+ EVFLAG_NOSIGMASK = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVFLAG_NOSIGMASK
+ EVBACKEND_SELECT = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_SELECT
+ EVBACKEND_POLL = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_POLL
+ EVBACKEND_EPOLL = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_EPOLL
+ EVBACKEND_KQUEUE = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_KQUEUE
+ EVBACKEND_DEVPOLL = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_DEVPOLL
+ EVBACKEND_PORT = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_PORT
+ EVBACKEND_ALL = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_ALL
+ EVBACKEND_MASK = orcsome3_backend.PYLOOP_NEW_LOOP_FLAGS.EVBACKEND_MASK
+
+ class RunFlags(int, Enum):
+ EVRUN_ALWAYS = orcsome3_backend.PYLOOP_RUN_LOOP_FLAGS.EVRUN_ALWAYS
+ EVRUN_ONCE = orcsome3_backend.PYLOOP_RUN_LOOP_FLAGS.EVRUN_ONCE
+ EVRUN_NOWAIT = orcsome3_backend.PYLOOP_RUN_LOOP_FLAGS.EVRUN_NOWAIT
+
+ class BreakFlags(int, Enum):
+ EVBREAK_ALL = orcsome3_backend.PYLOOP_BREAK_LOOP_FLAGS.EVBREAK_ALL
+ EVBREAK_ONE = orcsome3_backend.PYLOOP_BREAK_LOOP_FLAGS.EVBREAK_ONE
+ EVBREAK_CANCEL = orcsome3_backend.PYLOOP_BREAK_LOOP_FLAGS.EVBREAK_CANCEL
+
+ @classmethod
+ def new(cls, init_flags: Union[NewLoopFlags, list[NewLoopFlags]] = NewLoopFlags.EVFLAG_AUTO) -> Loop:
+ """Allocate a libev loop. Default backend is `EVFLAG_AUTO` (epoll on Linux)."""
+ flags: Union[int, list[int]] = (
+ [x.value for x in init_flags] if isinstance(init_flags, list) else init_flags.value
+ )
+ cython_loop: orcsome3_backend.PyLoop = orcsome3_backend.PyLoop._new_from_python_(flags=flags)
+ return cls.new_from_cython_loop(cython_loop=cython_loop)
+
+ @classmethod
+ def new_from_cython_loop(cls, cython_loop: orcsome3_backend.PyLoop) -> Loop:
+ """Wrap an existing Cython loop object (used from watcher callbacks)."""
+ loop: Loop = cls()
+ loop.cython_loop = cython_loop
+ return loop
+
+ def run(self, run_flags: Union[RunFlags, list[RunFlags]] = RunFlags.EVRUN_ALWAYS) -> None:
+ """Enter the loop. `EVRUN_ALWAYS` blocks until `break_()`."""
+ if self.cython_loop is not None:
+ flags: Union[int, list[int]] = (
+ [x.value for x in run_flags] if isinstance(run_flags, list) else run_flags.value
+ )
+ self.cython_loop.run(flags=flags)
+
+ def break_(self, how: BreakFlags) -> None:
+ """Stop `run()`. Named `break_` because `break` is a keyword."""
+ if self.cython_loop is not None:
+ self.cython_loop.break_(how_to_break_flag=how.value)
+
+ def destroy(self) -> None:
+ """Free the native loop. Further `run()` / `break_()` calls are no-ops."""
+ if self.cython_loop is not None:
+ self.cython_loop.destroy()
+ self.cython_loop = None
+
+
+class IOWatcher:
+ """File-descriptor watcher."""
+
+ cython_io_watcher: Optional[orcsome3_backend.PyIOWatcher] = None
+
+ class Events(int, Enum):
+ EV_READ = orcsome3_backend.PYIOWATCHER_INIT_FLAGS.EV_READ
+ EV_WRITE = orcsome3_backend.PYIOWATCHER_INIT_FLAGS.EV_WRITE
+ EV_READ_WRITE = orcsome3_backend.PYIOWATCHER_INIT_FLAGS.EV_READ_WRITE
+
+ @classmethod
+ def new(cls, callback: Callable[[Loop, IOWatcher, int], None], file_descriptor: int, event: Events) -> IOWatcher:
+ """Watch `file_descriptor` for `event` (read/write). `callback(loop, watcher, revents)`."""
+ cython_io_watcher: orcsome3_backend.PyIOWatcher = orcsome3_backend.PyIOWatcher._new_from_python_()
+ cython_io_watcher.init(
+ callbacks={"default": IOWatcher.default_callback, "user_callback": callback},
+ file_descriptor=file_descriptor,
+ events=event.value,
+ )
+ return cls.new_from_cython_io_watcher(cython_io_watcher=cython_io_watcher)
+
+ @classmethod
+ def new_from_cython_io_watcher(cls, cython_io_watcher: orcsome3_backend.PyIOWatcher) -> IOWatcher:
+ """Wrap an existing Cython IO watcher."""
+ io_watcher: IOWatcher = cls()
+ io_watcher.cython_io_watcher = cython_io_watcher
+ return io_watcher
+
+ @staticmethod
+ def default_callback(
+ native_cython_loop: orcsome3_backend.PyLoop,
+ native_cython_io_watcher: orcsome3_backend.PyIOWatcher,
+ revents: int,
+ ) -> None:
+ """Native → Python adapter; invokes the user callback stored on the Cython watcher."""
+ py_loop: Loop = Loop.new_from_cython_loop(cython_loop=native_cython_loop)
+ py_io_watcher: IOWatcher = IOWatcher.new_from_cython_io_watcher(cython_io_watcher=native_cython_io_watcher)
+ watcher: Optional[orcsome3_backend.PyIOWatcher] = py_io_watcher.cython_io_watcher
+ if watcher is not None:
+ watcher.callbacks["user_callback"](py_loop, py_io_watcher, revents)
+
+ def start(self, loop: Loop) -> None:
+ """Start watching on `loop`."""
+ if self.cython_io_watcher is not None and loop.cython_loop is not None:
+ self.cython_io_watcher.start(loop=loop.cython_loop)
+
+ def stop(self, loop: Loop) -> None:
+ """Stop watching on `loop` (watcher can be started again)."""
+ if self.cython_io_watcher is not None and loop.cython_loop is not None:
+ self.cython_io_watcher.stop(loop=loop.cython_loop)
+
+ def close(self, loop: Loop) -> None:
+ """Stop and drop the native watcher."""
+ self.stop(loop=loop)
+ self.cython_io_watcher = None
+
+
+class SignalWatcher:
+ """Signal watcher."""
+
+ cython_signal_watcher: Optional[orcsome3_backend.PySignalWatcher] = None
+
+ @classmethod
+ def new(cls, callback: Callable[[Loop, SignalWatcher, int], None], signal_number: Signals) -> SignalWatcher:
+ """Watch POSIX `signal_number`. `callback(loop, watcher, revents)`."""
+ cython_signal_watcher: orcsome3_backend.PySignalWatcher = orcsome3_backend.PySignalWatcher._new_from_python_()
+ cython_signal_watcher.init(
+ callbacks={"default": SignalWatcher.default_callback, "user_callback": callback},
+ signal_number=signal_number.value,
+ )
+ return cls.new_from_cython_signal_watcher(cython_signal_watcher=cython_signal_watcher)
+
+ @classmethod
+ def new_from_cython_signal_watcher(cls, cython_signal_watcher: orcsome3_backend.PySignalWatcher) -> SignalWatcher:
+ """Wrap an existing Cython signal watcher."""
+ signal_watcher: SignalWatcher = cls()
+ signal_watcher.cython_signal_watcher = cython_signal_watcher
+ return signal_watcher
+
+ @staticmethod
+ def default_callback(
+ native_cython_loop: orcsome3_backend.PyLoop,
+ native_cython_signal_watcher: orcsome3_backend.PySignalWatcher,
+ revents: int,
+ ) -> None:
+ """Native → Python adapter; invokes the user callback stored on the Cython watcher."""
+ py_loop: Loop = Loop.new_from_cython_loop(cython_loop=native_cython_loop)
+ py_signal_watcher: SignalWatcher = SignalWatcher.new_from_cython_signal_watcher(
+ cython_signal_watcher=native_cython_signal_watcher
+ )
+ watcher: Optional[orcsome3_backend.PySignalWatcher] = py_signal_watcher.cython_signal_watcher
+ if watcher is not None:
+ watcher.callbacks["user_callback"](py_loop, py_signal_watcher, revents)
+
+ def start(self, loop: Loop) -> None:
+ """Start watching on `loop`."""
+ if self.cython_signal_watcher is not None and loop.cython_loop is not None:
+ self.cython_signal_watcher.start(loop=loop.cython_loop)
+
+ def stop(self, loop: Loop) -> None:
+ """Stop watching on `loop`."""
+ if self.cython_signal_watcher is not None and loop.cython_loop is not None:
+ self.cython_signal_watcher.stop(loop=loop.cython_loop)
+
+ def close(self, loop: Loop) -> None:
+ """Stop and drop the native watcher."""
+ self.stop(loop=loop)
+ self.cython_signal_watcher = None
+
+
+class TimerWatcher:
+ """Timer watcher."""
+
+ cython_timer_watcher: Optional[orcsome3_backend.PyTimerWatcher] = None
+ after: float = 0.0
+ repeat: float = 0.0
+ next_stop: float = 0.0
+
+ @classmethod
+ def new(
+ cls, callback: Callable[[Loop, TimerWatcher, int], None], after: float = 0.0, repeat: float = 0.0
+ ) -> TimerWatcher:
+ """Fire `callback` after `after` seconds, then every `repeat` seconds if `repeat` > 0."""
+ cython_timer_watcher: orcsome3_backend.PyTimerWatcher = orcsome3_backend.PyTimerWatcher._new_from_python_()
+ cython_timer_watcher.init(
+ callbacks={"default": TimerWatcher.default_callback, "user_callback": callback},
+ after=after,
+ repeat=repeat,
+ )
+ return cls.new_from_cython_timer_watcher(cython_timer_watcher=cython_timer_watcher)
+
+ @classmethod
+ def new_from_cython_timer_watcher(cls, cython_timer_watcher: orcsome3_backend.PyTimerWatcher) -> TimerWatcher:
+ """Wrap an existing Cython timer watcher."""
+ timer_watcher: TimerWatcher = cls()
+ timer_watcher.cython_timer_watcher = cython_timer_watcher
+ return timer_watcher
+
+ @staticmethod
+ def default_callback(
+ native_cython_loop: orcsome3_backend.PyLoop,
+ native_cython_timer_watcher: orcsome3_backend.PyTimerWatcher,
+ revents: int,
+ ) -> None:
+ """Native → Python adapter; invokes the user callback stored on the Cython watcher."""
+ py_loop: Loop = Loop.new_from_cython_loop(cython_loop=native_cython_loop)
+ py_timer_watcher: TimerWatcher = TimerWatcher.new_from_cython_timer_watcher(
+ cython_timer_watcher=native_cython_timer_watcher
+ )
+ watcher: Optional[orcsome3_backend.PyTimerWatcher] = py_timer_watcher.cython_timer_watcher
+ if watcher is not None:
+ watcher.callbacks["user_callback"](py_loop, py_timer_watcher, revents)
+
+ def start(self, loop: Loop, after: float = 0.0, repeat: float = 0.0) -> None:
+ """Start the timer. Non-zero `after`/`repeat` retune it before starting."""
+ if self.cython_timer_watcher is not None and loop.cython_loop is not None:
+ if after or repeat:
+ self.after = after or self.after
+ self.repeat = repeat or self.repeat
+ self.cython_timer_watcher.set_timer(after=self.after, repeat=self.repeat)
+ self.next_stop = time.time() + self.after
+ self.cython_timer_watcher.start(loop=loop.cython_loop)
+
+ def stop(self, loop: Loop) -> None:
+ """Stop the timer without destroying it."""
+ if self.cython_timer_watcher is not None and loop.cython_loop is not None:
+ self.cython_timer_watcher.stop(loop=loop.cython_loop)
+
+ def close(self, loop: Loop) -> None:
+ """Stop and drop the native watcher."""
+ self.stop(loop=loop)
+ self.cython_timer_watcher = None
+
+ def again(self, loop: Loop) -> None:
+ """Restart the repeating timer from now (`ev_timer_again`)."""
+ if self.cython_timer_watcher is not None and loop.cython_loop is not None:
+ self.next_stop = time.time() + self.repeat
+ self.cython_timer_watcher.again(loop=loop.cython_loop)
+
+ def remaining(self, loop: Loop) -> float:
+ """Seconds until the next fire, or 0 if the native watcher is gone."""
+ if self.cython_timer_watcher is not None and loop.cython_loop is not None:
+ return float(self.cython_timer_watcher.remaining(loop=loop.cython_loop))
+ return 0.0
+
+ def update_next_stop(self) -> None:
+ """Set `next_stop` to now + `repeat` (used after a timer callback that keeps running)."""
+ self.next_stop = time.time() + self.repeat
+
+ def overdue(self, timeout: float) -> bool:
+ """True if the scheduled `next_stop` was more than `timeout` seconds ago."""
+ return time.time() > self.next_stop + timeout
+
+
+class StatWatcher:
+ """Path watcher (`ev_stat`: inotify on Linux, with a periodic `stat` fallback)."""
+
+ cython_stat_watcher: Optional[orcsome3_backend.PyStatWatcher] = None
+
+ @classmethod
+ def new(cls, callback: Callable[[Loop, StatWatcher, int], None], path: str, interval: float = 0.0) -> StatWatcher:
+ """Watch `path`. `interval` 0 lets libev pick the fallback poll (~5s). `callback(loop, watcher, revents)`."""
+ cython_stat_watcher: orcsome3_backend.PyStatWatcher = orcsome3_backend.PyStatWatcher._new_from_python_()
+ cython_stat_watcher.init(
+ callbacks={"default": StatWatcher.default_callback, "user_callback": callback},
+ path=path,
+ interval=interval,
+ )
+ return cls.new_from_cython_stat_watcher(cython_stat_watcher=cython_stat_watcher)
+
+ @classmethod
+ def new_from_cython_stat_watcher(cls, cython_stat_watcher: orcsome3_backend.PyStatWatcher) -> StatWatcher:
+ """Wrap an existing Cython stat watcher."""
+ stat_watcher: StatWatcher = cls()
+ stat_watcher.cython_stat_watcher = cython_stat_watcher
+ return stat_watcher
+
+ @staticmethod
+ def default_callback(
+ native_cython_loop: orcsome3_backend.PyLoop,
+ native_cython_stat_watcher: orcsome3_backend.PyStatWatcher,
+ revents: int,
+ ) -> None:
+ """Native → Python adapter; invokes the user callback stored on the Cython watcher."""
+ py_loop: Loop = Loop.new_from_cython_loop(cython_loop=native_cython_loop)
+ py_stat_watcher: StatWatcher = StatWatcher.new_from_cython_stat_watcher(
+ cython_stat_watcher=native_cython_stat_watcher
+ )
+ watcher: Optional[orcsome3_backend.PyStatWatcher] = py_stat_watcher.cython_stat_watcher
+ if watcher is not None:
+ watcher.callbacks["user_callback"](py_loop, py_stat_watcher, revents)
+
+ def start(self, loop: Loop) -> None:
+ """Start watching on `loop`."""
+ if self.cython_stat_watcher is not None and loop.cython_loop is not None:
+ self.cython_stat_watcher.start(loop=loop.cython_loop)
+
+ def stop(self, loop: Loop) -> None:
+ """Stop watching on `loop`."""
+ if self.cython_stat_watcher is not None and loop.cython_loop is not None:
+ self.cython_stat_watcher.stop(loop=loop.cython_loop)
+
+ def close(self, loop: Loop) -> None:
+ """Stop and drop the native watcher."""
+ self.stop(loop=loop)
+ self.cython_stat_watcher = None
diff --git a/orcsome3/libs/xlib.py b/orcsome3/libs/xlib.py
new file mode 100644
index 0000000..084535f
--- /dev/null
+++ b/orcsome3/libs/xlib.py
@@ -0,0 +1,2077 @@
+"""Thin Python wrappers around Xlib / Xss / Xext / DPMS via the compiled `orcsome3_backend` extension.
+
+Only this module and `orcsome3.libs.ev` should import `orcsome3_backend`.
+The `.so` is looked up on `sys.path`; set `PYTHONPATH` if it lives elsewhere.
+"""
+
+from __future__ import annotations
+
+import logging
+from array import array
+from enum import Enum
+from pathlib import Path
+from typing import Callable, NamedTuple, Optional, Union, cast
+
+# Import TypeAlias from module typing_extensions to make it compatible with python 3.8
+from typing_extensions import TypeAlias, override
+
+# Try to import shared library orcsome3_backend.cpython-xxx-x86_64-linux-gnu.so, it looks for standard locations at sys.path,
+# to specify another location use env var PYTHONPATH (PYTHONPATH="/custom/dir:$PYTHONPATH")
+import orcsome3_backend
+from orcsome3.utils import Final
+
+# Globals
+_logger: logging.Logger = logging.getLogger(name=__name__)
+
+
+class TYPES(metaclass=Final):
+ """Type aliases for Cython data types."""
+
+ Cython_Atom: TypeAlias = int
+ Cython_Window: TypeAlias = int
+ Cython_KeySym: TypeAlias = int
+ Cython_KeyCode: TypeAlias = int
+ Cython_Time: TypeAlias = int
+ Cython_Display: TypeAlias = orcsome3_backend.PyDisplay
+ Cython_XWindowChanges: TypeAlias = orcsome3_backend.PyXWindowChanges
+ Cython_XWindowAttributes: TypeAlias = orcsome3_backend.PyXWindowAttributes
+ Cython_XWindowTree: TypeAlias = orcsome3_backend.PyXWindowTree
+ Cython_XWindowGeometry: TypeAlias = orcsome3_backend.PyXWindowGeometry
+ Cython_XScreenSaverInfo: TypeAlias = orcsome3_backend.PyXScreenSaverInfo
+ Cython_XkbStateRec: TypeAlias = orcsome3_backend.PyXkbStateRec
+ Cython_DPMSInfo: TypeAlias = orcsome3_backend.PyDPMSInfo
+
+ class EVENTS(metaclass=Final):
+ Cython_XEvent: TypeAlias = orcsome3_backend.PyXEvent
+ Cython_XButtonEvent: TypeAlias = orcsome3_backend.PyXButtonEvent
+ Cython_XKeyEvent: TypeAlias = orcsome3_backend.PyXKeyEvent
+ Cython_XFocusChangeEvent: TypeAlias = orcsome3_backend.PyXFocusChangeEvent
+ Cython_XCreateWindowEvent: TypeAlias = orcsome3_backend.PyXCreateWindowEvent
+ Cython_XDestroyWindowEvent: TypeAlias = orcsome3_backend.PyXDestroyWindowEvent
+ Cython_XMapEvent: TypeAlias = orcsome3_backend.PyXMapEvent
+ Cython_XUnmapEvent: TypeAlias = orcsome3_backend.PyXUnmapEvent
+ Cython_XConfigureEvent: TypeAlias = orcsome3_backend.PyXConfigureEvent
+ Cython_XPropertyEvent: TypeAlias = orcsome3_backend.PyXPropertyEvent
+ Cython_XClientMessageEvent: TypeAlias = orcsome3_backend.PyXClientMessageEvent
+ Cython_XErrorEvent: TypeAlias = orcsome3_backend.PyXErrorEvent
+
+
+class CONSTANTS(metaclass=Final):
+ """Constants"""
+
+ CURRENT_TIME: TYPES.Cython_Time = TYPES.Cython_Time(orcsome3_backend.CONSTANTS.CurrentTime)
+ ANY_PROPERTY_TYPE: TYPES.Cython_Atom = TYPES.Cython_Atom(orcsome3_backend.CONSTANTS.AnyPropertyType)
+
+ class KB(metaclass=Final):
+ """Keyboard Constants"""
+
+ NO_SYMBOL: TYPES.Cython_KeySym = TYPES.Cython_KeySym(orcsome3_backend.CONSTANTS.NoSymbol)
+ ANY_KEY: TYPES.Cython_KeyCode = TYPES.Cython_KeyCode(orcsome3_backend.CONSTANTS.AnyKey)
+ X_KB_USE_CORE_KBD: int = orcsome3_backend.CONSTANTS.XkbUseCoreKbd
+
+ REVERT_TO_NONE: int = orcsome3_backend.CONSTANTS.RevertToNone
+ REVERT_TO_POINTER_ROOT: int = orcsome3_backend.CONSTANTS.RevertToPointerRoot
+ REVERT_TO_PARENT: int = orcsome3_backend.CONSTANTS.RevertToParent
+
+
+class INPUT_EVENT_MASKS(int, Enum):
+ """
+ Input Event Masks. Used as event-mask window attribute and as arguments to grab requests
+ """
+
+ NoEventMask = orcsome3_backend.INPUT_EVENT_MASKS.NoEventMask
+ StructureNotifyMask = orcsome3_backend.INPUT_EVENT_MASKS.StructureNotifyMask
+ SubstructureNotifyMask = orcsome3_backend.INPUT_EVENT_MASKS.SubstructureNotifyMask
+ SubstructureRedirectMask = orcsome3_backend.INPUT_EVENT_MASKS.SubstructureRedirectMask
+ PropertyChangeMask = orcsome3_backend.INPUT_EVENT_MASKS.PropertyChangeMask
+ FocusChangeMask = orcsome3_backend.INPUT_EVENT_MASKS.FocusChangeMask
+ KeyPressMask = orcsome3_backend.INPUT_EVENT_MASKS.KeyPressMask
+ KeyReleaseMask = orcsome3_backend.INPUT_EVENT_MASKS.KeyReleaseMask
+ ButtonPressMask = orcsome3_backend.INPUT_EVENT_MASKS.ButtonPressMask
+ ButtonReleaseMask = orcsome3_backend.INPUT_EVENT_MASKS.ButtonReleaseMask
+
+
+class KEY_MASKS(int, Enum):
+ """
+ Key masks. Used as modifiers to GrabButton and GrabKey, results of QueryPointer,
+ state in various key-, mouse-, and button-related events.
+ """
+
+ NoModifiers = 0 # No modifiers
+ AnyModifier = orcsome3_backend.KEY_MASKS.AnyModifier # Any modifier
+ Mod1Mask = orcsome3_backend.KEY_MASKS.Mod1Mask # Alt
+ ControlMask = orcsome3_backend.KEY_MASKS.ControlMask # Ctrl
+ ShiftMask = orcsome3_backend.KEY_MASKS.ShiftMask # Shift
+ Mod2Mask = orcsome3_backend.KEY_MASKS.Mod2Mask # Num Lock
+ Mod4Mask = orcsome3_backend.KEY_MASKS.Mod4Mask # Windows
+ LockMask = orcsome3_backend.KEY_MASKS.LockMask # Caps Lock
+
+
+class BUTTON_MASKS(int, Enum):
+ """Button masks"""
+
+ NoModifiers = 0 # No modifiers
+ AnyModifier = orcsome3_backend.BUTTON_MASKS.AnyModifier # Any modifier
+ Button1Mask = orcsome3_backend.BUTTON_MASKS.Button1Mask
+ Button2Mask = orcsome3_backend.BUTTON_MASKS.Button2Mask
+ Button3Mask = orcsome3_backend.BUTTON_MASKS.Button3Mask
+ Button4Mask = orcsome3_backend.BUTTON_MASKS.Button4Mask
+ Button5Mask = orcsome3_backend.BUTTON_MASKS.Button5Mask
+
+
+class BUTTONS(int, Enum):
+ """Button names"""
+
+ AnyButton = orcsome3_backend.BUTTONS.AnyButton
+ Button1 = orcsome3_backend.BUTTONS.Button1
+ Button2 = orcsome3_backend.BUTTONS.Button2
+ Button3 = orcsome3_backend.BUTTONS.Button3
+ Button4 = orcsome3_backend.BUTTONS.Button4
+ Button5 = orcsome3_backend.BUTTONS.Button5
+
+
+class WINDOW_VALUE_MASK(int, Enum):
+ """Window value mask bits. Enum used by function `x_configure_window()`"""
+
+ CWX = orcsome3_backend.WINDOW_VALUE_MASK.CWX
+ CWY = orcsome3_backend.WINDOW_VALUE_MASK.CWY
+ CWWidth = orcsome3_backend.WINDOW_VALUE_MASK.CWWidth
+ CWHeight = orcsome3_backend.WINDOW_VALUE_MASK.CWHeight
+ CWBorderWidth = orcsome3_backend.WINDOW_VALUE_MASK.CWBorderWidth
+ CWSibling = orcsome3_backend.WINDOW_VALUE_MASK.CWSibling
+ CWStackMode = orcsome3_backend.WINDOW_VALUE_MASK.CWStackMode
+
+
+class GRAB_MODE(int, Enum):
+ """GrabPointer, GrabButton, GrabKeyboard, GrabKey Modes"""
+
+ GrabModeSync = orcsome3_backend.GRAB_MODE.GrabModeSync
+ GrabModeAsync = orcsome3_backend.GRAB_MODE.GrabModeAsync
+
+
+class SET_PROPERTY_MODE(int, Enum):
+ """Specifies the mode of the operation. Enum used by function `x_change_window_property()`"""
+
+ # Discards the previous property value and stores the new data
+ PropModeReplace = orcsome3_backend.SET_PROPERTY_MODE.PropModeReplace
+ # Inserts the specified data before the beginning of the existing data
+ PropModePrepend = orcsome3_backend.SET_PROPERTY_MODE.PropModePrepend
+ # Inserts the specified data onto the end of the existing data
+ PropModeAppend = orcsome3_backend.SET_PROPERTY_MODE.PropModeAppend
+
+
+class KEYSYM_GROUPS(int, Enum):
+ """Specifies the index of the keysym group to lock. Enum used by function `x_kb_lock_group()`"""
+
+ XkbGroup1Index = orcsome3_backend.KB_GROUP_INDEX.XkbGroup1Index
+ XkbGroup2Index = orcsome3_backend.KB_GROUP_INDEX.XkbGroup2Index
+ XkbGroup3Index = orcsome3_backend.KB_GROUP_INDEX.XkbGroup3Index
+ XkbGroup4Index = orcsome3_backend.KB_GROUP_INDEX.XkbGroup4Index
+
+
+class XWindowChanges:
+ """Values for `XConfigureWindow` (`XWindowChanges`). Unset fields stay `None` and are omitted from the mask."""
+
+ class StackMode(int, Enum):
+ Above = orcsome3_backend.WINDOW_STACKING_METHOD.Above
+ Below = orcsome3_backend.WINDOW_STACKING_METHOD.Below
+ TopIf = orcsome3_backend.WINDOW_STACKING_METHOD.TopIf
+ BottomIf = orcsome3_backend.WINDOW_STACKING_METHOD.BottomIf
+ Opposite = orcsome3_backend.WINDOW_STACKING_METHOD.Opposite
+
+ def __init__(
+ self,
+ x: Optional[int] = None,
+ y: Optional[int] = None,
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+ border_width: Optional[int] = None,
+ sibling: Optional[TYPES.Cython_Window] = None,
+ stack_mode: Optional[StackMode] = None,
+ ) -> None:
+ self._cython_xwindowchanges: TYPES.Cython_XWindowChanges = orcsome3_backend.PyXWindowChanges._new_from_python_(
+ x=x if x is not None else 0,
+ y=y if y is not None else 0,
+ width=width if width is not None else 0,
+ height=height if height is not None else 0,
+ border_width=border_width if border_width is not None else 0,
+ sibling_window=sibling if sibling is not None else 0,
+ stack_mode=stack_mode.value if stack_mode is not None else 0,
+ )
+ self.x: Optional[int] = x
+ self.y: Optional[int] = y
+ self.width: Optional[int] = width
+ self.height: Optional[int] = height
+ self.border_width: Optional[int] = border_width
+ self.sibling: Optional[TYPES.Cython_Window] = sibling
+ self.stack_mode: Optional[XWindowChanges.StackMode] = stack_mode
+
+
+class XWindowAttributes:
+ """Python view of `XWindowAttributes` (geometry, map state, override-redirect)."""
+
+ class MapState(int, Enum):
+ """
+ Enum representing the map state of a window.
+
+ - IsUnmapped
+ - IsUnviewable
+ - IsViewable
+ """
+
+ IsUnmapped = orcsome3_backend.WINDOW_MAP_STATE.IsUnmapped
+ IsUnviewable = orcsome3_backend.WINDOW_MAP_STATE.IsUnviewable
+ IsViewable = orcsome3_backend.WINDOW_MAP_STATE.IsViewable
+
+ def __init__(
+ self,
+ window: TYPES.Cython_Window,
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ border_width: int,
+ depth: int,
+ root: TYPES.Cython_Window,
+ override_redirect: bool,
+ map_state: XWindowAttributes.MapState,
+ ) -> None:
+ self.window: TYPES.Cython_Window = window
+ self.x: int = x # location of window
+ self.y: int = y # location of window
+ self.width: int = width # width of window
+ self.height: int = height # height of window
+ self.border_width: int = border_width # border width of window
+ self.depth: int = depth # depth of window
+ self.root: TYPES.Cython_Window = root # root of screen containing window
+ self.override_redirect: bool = override_redirect # boolean value for override-redirect
+ self.map_state: XWindowAttributes.MapState = map_state # map state of the window
+
+ @classmethod
+ def _new_from_cython_xwindowattributes_(
+ cls,
+ window: TYPES.Cython_Window,
+ cython_xwindowattributes: TYPES.Cython_XWindowAttributes,
+ ) -> XWindowAttributes:
+ x_window_attributes: XWindowAttributes = cls(
+ window=window,
+ x=int(cython_xwindowattributes.x),
+ y=int(cython_xwindowattributes.y),
+ width=int(cython_xwindowattributes.width),
+ height=int(cython_xwindowattributes.height),
+ border_width=int(cython_xwindowattributes.border_width),
+ depth=int(cython_xwindowattributes.depth),
+ root=TYPES.Cython_Window(cython_xwindowattributes.root),
+ override_redirect=bool(cython_xwindowattributes.override_redirect),
+ map_state=XWindowAttributes.MapState(int(cython_xwindowattributes.map_state)),
+ )
+ return x_window_attributes
+
+ @override
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({', '.join([f'{k}={v!r}' for k, v in self.__dict__.items() if not k.startswith('_')])})"
+
+
+class EventType(NamedTuple):
+ """
+ Class representation of an event type.
+
+ Attrs:
+ - `id`: Id of the event
+ - `cython_class`: Cython class representing the event
+ - `python_class`: Lambda that returns the python class representing the event
+ """
+
+ id: int
+ cython_class: Optional[object]
+ python_class: Optional[Callable[[], object]]
+
+
+class XEvent:
+ """Base class of all X events."""
+
+ class EVENT_TYPES(Enum):
+ """Enum representing the different types of events"""
+
+ # XErrorEvent
+ ErrorEvent = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ErrorEvent,
+ cython_class=orcsome3_backend.PyXErrorEvent,
+ python_class=lambda: XErrorEvent,
+ )
+ # XKeyEvent
+ KeyPress = EventType(
+ id=orcsome3_backend.EVENT_TYPES.KeyPress,
+ cython_class=orcsome3_backend.PyXKeyEvent,
+ python_class=lambda: XKeyEvent,
+ )
+ KeyRelease = EventType(
+ id=orcsome3_backend.EVENT_TYPES.KeyRelease,
+ cython_class=orcsome3_backend.PyXKeyEvent,
+ python_class=lambda: XKeyEvent,
+ )
+ # XButtonEvent
+ ButtonPress = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ButtonPress,
+ cython_class=orcsome3_backend.PyXButtonEvent,
+ python_class=lambda: XButtonEvent,
+ )
+ ButtonRelease = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ButtonRelease,
+ cython_class=orcsome3_backend.PyXButtonEvent,
+ python_class=lambda: XButtonEvent,
+ )
+ # XFocusChangeEvent
+ FocusIn = EventType(
+ id=orcsome3_backend.EVENT_TYPES.FocusIn,
+ cython_class=orcsome3_backend.PyXFocusChangeEvent,
+ python_class=lambda: XFocusChangeEvent,
+ )
+ FocusOut = EventType(
+ id=orcsome3_backend.EVENT_TYPES.FocusOut,
+ cython_class=orcsome3_backend.PyXFocusChangeEvent,
+ python_class=lambda: XFocusChangeEvent,
+ )
+ # XCreateWindowEvent
+ CreateNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.CreateNotify,
+ cython_class=orcsome3_backend.PyXCreateWindowEvent,
+ python_class=lambda: XCreateWindowEvent,
+ )
+ # XDestroyWindowEvent
+ DestroyNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.DestroyNotify,
+ cython_class=orcsome3_backend.PyXDestroyWindowEvent,
+ python_class=lambda: XDestroyWindowEvent,
+ )
+ # XPropertyEvent
+ PropertyNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.PropertyNotify,
+ cython_class=orcsome3_backend.PyXPropertyEvent,
+ python_class=lambda: XPropertyEvent,
+ )
+ # XClientMessageEvent
+ ClientMessage = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ClientMessage,
+ cython_class=orcsome3_backend.PyXClientMessageEvent,
+ python_class=lambda: XClientMessageEvent,
+ )
+ # Generic event
+ GenericEvent = EventType(
+ id=orcsome3_backend.EVENT_TYPES.GenericEvent,
+ cython_class=None,
+ python_class=None,
+ )
+ # XMotionEvent
+ MotionNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.MotionNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XCrossingEvent
+ EnterNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.EnterNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ LeaveNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.LeaveNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XExposeEvent
+ Expose = EventType(
+ id=orcsome3_backend.EVENT_TYPES.Expose,
+ cython_class=None,
+ python_class=None,
+ )
+ # XGraphicsExposeEvent
+ GraphicsExpose = EventType(
+ id=orcsome3_backend.EVENT_TYPES.GraphicsExpose,
+ cython_class=None,
+ python_class=None,
+ )
+ # XNoExposeEvent
+ NoExpose = EventType(
+ id=orcsome3_backend.EVENT_TYPES.NoExpose,
+ cython_class=None,
+ python_class=None,
+ )
+ # XVisibilityEvent
+ VisibilityNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.VisibilityNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XUnmapEvent
+ UnmapNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.UnmapNotify,
+ cython_class=orcsome3_backend.PyXUnmapEvent,
+ python_class=lambda: XUnmapEvent,
+ )
+ # XMapEvent
+ MapNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.MapNotify,
+ cython_class=orcsome3_backend.PyXMapEvent,
+ python_class=lambda: XMapEvent,
+ )
+ # XMapRequestEvent
+ MapRequest = EventType(
+ id=orcsome3_backend.EVENT_TYPES.MapRequest,
+ cython_class=None,
+ python_class=None,
+ )
+ # XReparentEvent
+ ReparentNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ReparentNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XConfigureEvent
+ ConfigureNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ConfigureNotify,
+ cython_class=orcsome3_backend.PyXConfigureEvent,
+ python_class=lambda: XConfigureEvent,
+ )
+ # XGravityEvent
+ GravityNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.GravityNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XResizeRequestEvent
+ ResizeRequest = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ResizeRequest,
+ cython_class=None,
+ python_class=None,
+ )
+ # XConfigureRequestEvent
+ ConfigureRequest = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ConfigureRequest,
+ cython_class=None,
+ python_class=None,
+ )
+ # XCirculateEvent
+ CirculateNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.CirculateNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XCirculateRequestEvent
+ CirculateRequest = EventType(
+ id=orcsome3_backend.EVENT_TYPES.CirculateRequest,
+ cython_class=None,
+ python_class=None,
+ )
+ # XSelectionClearEvent
+ SelectionClear = EventType(
+ id=orcsome3_backend.EVENT_TYPES.SelectionClear,
+ cython_class=None,
+ python_class=None,
+ )
+ # XSelectionRequestEvent
+ SelectionRequest = EventType(
+ id=orcsome3_backend.EVENT_TYPES.SelectionRequest,
+ cython_class=None,
+ python_class=None,
+ )
+ # XSelectionEvent
+ SelectionNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.SelectionNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XColormapEvent
+ ColormapNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.ColormapNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XMappingEvent
+ MappingNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.MappingNotify,
+ cython_class=None,
+ python_class=None,
+ )
+ # XKeymapEvent
+ KeymapNotify = EventType(
+ id=orcsome3_backend.EVENT_TYPES.KeymapNotify,
+ cython_class=None,
+ python_class=None,
+ )
+
+ @classmethod
+ def from_id(cls, id_: int) -> XEvent.EVENT_TYPES:
+ cache: Optional[dict[int, XEvent.EVENT_TYPES]] = getattr(cls, "_by_id", None)
+ if cache is None:
+ cache = {member.value.id: member for member in cls}
+ type.__setattr__(cls, "_by_id", cache)
+ try:
+ return cache[id_]
+ except KeyError:
+ raise ValueError(f"Invalid id {id_}")
+
+ _cython_event: Optional[TYPES.EVENTS.Cython_XEvent] = None
+ _type: Optional[XEvent.EVENT_TYPES] = None
+ _serial: Optional[int] = None
+ _send_event: Optional[bool] = None
+ _display: Optional[TYPES.Cython_Display] = None
+ _window: Optional[TYPES.Cython_Window] = None
+
+ @property
+ def type(self) -> XEvent.EVENT_TYPES:
+ return cast(XEvent.EVENT_TYPES, self._type)
+
+ @type.setter
+ def type(self, type: XEvent.EVENT_TYPES) -> None:
+ self._type = type
+
+ @property
+ def serial(self) -> int:
+ return cast(int, self._serial)
+
+ @serial.setter
+ def serial(self, serial: int) -> None:
+ self._serial = serial
+
+ @property
+ def send_event(self) -> bool:
+ return cast(bool, self._send_event)
+
+ @send_event.setter
+ def send_event(self, send_event: bool) -> None:
+ self._send_event = send_event
+
+ @property
+ def display(self) -> TYPES.Cython_Display:
+ return cast(TYPES.Cython_Display, self._display)
+
+ @display.setter
+ def display(self, display: TYPES.Cython_Display) -> None:
+ self._display = display
+
+ @property
+ def window(self) -> TYPES.Cython_Window:
+ return cast(TYPES.Cython_Window, self._window)
+
+ @window.setter
+ def window(self, window: TYPES.Cython_Window) -> None:
+ self._window = window
+
+ def _set_attributes_from_cython_event_(self, cython_event: TYPES.EVENTS.Cython_XEvent) -> None:
+ """
+ Set common attributes from the `cython_event`:
+
+ - `_cython_event`
+ - `type`
+ - `serial`
+ - `send_event`
+ - `display`
+ - `window`
+ """
+ self._cython_event = cython_event
+ self._set_attributes_(
+ type=XEvent.EVENT_TYPES.from_id(id_=int(cython_event.type)),
+ serial=int(cython_event.serial),
+ send_event=bool(cython_event.send_event),
+ display=cython_event.display,
+ window=TYPES.Cython_Window(cython_event.window),
+ )
+
+ def _set_attributes_(
+ self,
+ type: XEvent.EVENT_TYPES,
+ serial: int,
+ send_event: bool,
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ ) -> None:
+ """Set common attributes for the event"""
+ self.type = type
+ self.serial = serial
+ self.send_event = send_event
+ self.display = display
+ self.window = window
+
+ def get_specific_event(
+ self,
+ ) -> Optional[
+ Union[
+ XEvent,
+ XErrorEvent,
+ XButtonEvent,
+ XKeyEvent,
+ XFocusChangeEvent,
+ XCreateWindowEvent,
+ XDestroyWindowEvent,
+ XMapEvent,
+ XUnmapEvent,
+ XConfigureEvent,
+ XPropertyEvent,
+ XClientMessageEvent,
+ ]
+ ]:
+ """
+ Returns the specific class related to the event or the base class `XEvent` if no child class exists for the type of event.
+ """
+ try:
+ if self._cython_event is None:
+ return None
+ if self.type.value.python_class is None:
+ return self
+ cython_instance: TYPES.EVENTS.Cython_XEvent = self._cython_event._get_specific_event_()
+ if self.type == XEvent.EVENT_TYPES.ErrorEvent:
+ return XErrorEvent(error_event=cast(TYPES.EVENTS.Cython_XErrorEvent, cython_instance))
+ elif self.type == XEvent.EVENT_TYPES.ButtonPress or self.type == XEvent.EVENT_TYPES.ButtonRelease:
+ return XButtonEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ button_event=cast(TYPES.EVENTS.Cython_XButtonEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.KeyPress or self.type == XEvent.EVENT_TYPES.KeyRelease:
+ return XKeyEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ key_event=cast(TYPES.EVENTS.Cython_XKeyEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.FocusIn or self.type == XEvent.EVENT_TYPES.FocusOut:
+ return XFocusChangeEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ focus_change_event=cast(TYPES.EVENTS.Cython_XFocusChangeEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.CreateNotify:
+ return XCreateWindowEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ create_window_event=cast(TYPES.EVENTS.Cython_XCreateWindowEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.DestroyNotify:
+ return XDestroyWindowEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ destroy_window_event=cast(TYPES.EVENTS.Cython_XDestroyWindowEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.MapNotify:
+ return XMapEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ map_event=cast(TYPES.EVENTS.Cython_XMapEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.UnmapNotify:
+ return XUnmapEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ unmap_event=cast(TYPES.EVENTS.Cython_XUnmapEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.ConfigureNotify:
+ return XConfigureEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ configure_event=cast(TYPES.EVENTS.Cython_XConfigureEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.PropertyNotify:
+ return XPropertyEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ property_event=cast(TYPES.EVENTS.Cython_XPropertyEvent, cython_instance)
+ )
+ elif self.type == XEvent.EVENT_TYPES.ClientMessage:
+ return XClientMessageEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ client_message_event=cast(TYPES.EVENTS.Cython_XClientMessageEvent, cython_instance)
+ )
+ return self
+ except Exception as e:
+ _logger.error(msg=f"An exception occurred getting the specific event. {e}")
+ return None
+
+ @override
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({', '.join([f'{k}={v!r}' for k, v in self.__dict__.items()])})"
+
+
+class XErrorEvent(XEvent):
+ """X protocol error (`XErrorEvent`) plus a decoded `msg` string."""
+
+ def __init__(self, error_event: TYPES.EVENTS.Cython_XErrorEvent) -> None:
+ self._set_attributes_from_cython_event_(cython_event=error_event)
+ self.resourceid: int = int(error_event.resourceid) # resource id
+ self.error_code: int = int(error_event.error_code) # error code of failed request
+ self.request_code: int = int(
+ error_event.request_code # Major op-code of failed request
+ )
+ self.minor_code: int = int(error_event.minor_code) # Minor op-code of failed request
+ self.msg: str = str(error_event.msg) # Message of the error
+
+
+class XButtonEvent(XEvent):
+ """Pointer button press/release (`XButtonEvent`)."""
+
+ class TYPE(int, Enum):
+ ButtonPress = XEvent.EVENT_TYPES.ButtonPress.value.id
+ ButtonRelease = XEvent.EVENT_TYPES.ButtonRelease.value.id
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ type: XButtonEvent.TYPE,
+ window: TYPES.Cython_Window,
+ root: TYPES.Cython_Window,
+ subwindow: TYPES.Cython_Window,
+ button: BUTTONS,
+ serial: int = 0,
+ send_event: bool = False,
+ time: TYPES.Cython_Time = CONSTANTS.CURRENT_TIME,
+ x: int = 0,
+ y: int = 0,
+ x_root: int = 0,
+ y_root: int = 0,
+ state: Optional[Union[list[Union[BUTTON_MASKS, KEY_MASKS]], int]] = None,
+ same_screen: bool = True,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.from_id(id_=type.value)
+ state_: int = 0
+ if state is not None:
+ if isinstance(state, int):
+ state_ = state
+ else:
+ for state__ in state:
+ state_ |= state__.value
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XButtonEvent = orcsome3_backend.PyXButtonEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ root=root,
+ subwindow=subwindow,
+ time=time,
+ x=x,
+ y=y,
+ x_root=x_root,
+ y_root=y_root,
+ state=state_,
+ button=button.value,
+ same_screen=same_screen,
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+
+ self.root: TYPES.Cython_Window = root
+ self.subwindow: TYPES.Cython_Window = subwindow
+ self.time: TYPES.Cython_Time = time
+ self.x: int = x
+ self.y: int = y
+ self.x_root: int = x_root
+ self.y_root: int = y_root
+ self.state: int = state_
+ self.button: BUTTONS = button
+ self.same_screen: bool = same_screen
+
+ @classmethod
+ def _new_from_cython_event_(cls, button_event: TYPES.EVENTS.Cython_XButtonEvent) -> XButtonEvent:
+ x_button_event: XButtonEvent = cls(
+ display=button_event.display,
+ type=XButtonEvent.TYPE(button_event.type),
+ window=TYPES.Cython_Window(button_event.window),
+ root=TYPES.Cython_Window(button_event.root),
+ subwindow=TYPES.Cython_Window(button_event.subwindow),
+ button=BUTTONS(button_event.button),
+ serial=int(button_event.serial),
+ send_event=bool(button_event.send_event),
+ time=TYPES.Cython_Time(button_event.time),
+ x=int(button_event.x),
+ y=int(button_event.y),
+ x_root=int(button_event.x_root),
+ y_root=int(button_event.y_root),
+ state=int(button_event.state),
+ same_screen=bool(button_event.same_screen),
+ create_cython_event=False,
+ )
+ x_button_event._cython_event = button_event
+ return x_button_event
+
+
+class XKeyEvent(XEvent):
+ """Keyboard press/release (`XKeyEvent`)."""
+
+ class TYPE(int, Enum):
+ KeyPress = XEvent.EVENT_TYPES.KeyPress.value.id
+ KeyRelease = XEvent.EVENT_TYPES.KeyRelease.value.id
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ type: XKeyEvent.TYPE,
+ window: TYPES.Cython_Window,
+ root: TYPES.Cython_Window,
+ subwindow: TYPES.Cython_Window,
+ keycode: TYPES.Cython_KeyCode,
+ serial: int = 0,
+ send_event: bool = False,
+ time: TYPES.Cython_Time = CONSTANTS.CURRENT_TIME,
+ x: int = 0,
+ y: int = 0,
+ x_root: int = 0,
+ y_root: int = 0,
+ state: Optional[Union[list[Union[BUTTON_MASKS, KEY_MASKS]], int]] = None,
+ same_screen: bool = True,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.from_id(id_=type.value)
+ state_: int = 0
+ if state is not None:
+ if isinstance(state, int):
+ state_ = state
+ else:
+ for state__ in state:
+ state_ |= state__.value
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XKeyEvent = orcsome3_backend.PyXKeyEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ root=root,
+ subwindow=subwindow,
+ time=time,
+ x=x,
+ y=y,
+ x_root=x_root,
+ y_root=y_root,
+ state=state_,
+ keycode=keycode,
+ same_screen=same_screen,
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.root: TYPES.Cython_Window = root
+ self.subwindow: TYPES.Cython_Window = subwindow
+ self.time: TYPES.Cython_Time = time
+ self.x: int = x
+ self.y: int = y
+ self.x_root: int = x_root
+ self.y_root: int = y_root
+ self.state: int = state_
+ self.keycode: TYPES.Cython_KeyCode = keycode
+ self.same_screen: bool = same_screen
+
+ @classmethod
+ def _new_from_cython_event_(cls, key_event: TYPES.EVENTS.Cython_XKeyEvent) -> XKeyEvent:
+ x_key_event: XKeyEvent = cls(
+ display=key_event.display,
+ type=XKeyEvent.TYPE(key_event.type),
+ window=TYPES.Cython_Window(key_event.window),
+ root=TYPES.Cython_Window(key_event.root),
+ subwindow=TYPES.Cython_Window(key_event.subwindow),
+ keycode=TYPES.Cython_KeyCode(key_event.keycode),
+ serial=int(key_event.serial),
+ send_event=bool(key_event.send_event),
+ time=TYPES.Cython_Time(key_event.time),
+ x=int(key_event.x),
+ y=int(key_event.y),
+ x_root=int(key_event.x_root),
+ y_root=int(key_event.y_root),
+ state=int(key_event.state),
+ same_screen=bool(key_event.same_screen),
+ create_cython_event=False,
+ )
+ x_key_event._cython_event = key_event
+ return x_key_event
+
+
+class XFocusChangeEvent(XEvent):
+ """FocusIn / FocusOut (`XFocusChangeEvent`)."""
+
+ class TYPE(int, Enum):
+ FocusIn = XEvent.EVENT_TYPES.FocusIn.value.id
+ FocusOut = XEvent.EVENT_TYPES.FocusOut.value.id
+
+ class NOTIFY_MODE(int, Enum):
+ NotifyNormal = orcsome3_backend.NOTIFY_MODES.NotifyNormal
+ NotifyGrab = orcsome3_backend.NOTIFY_MODES.NotifyGrab
+ NotifyUngrab = orcsome3_backend.NOTIFY_MODES.NotifyUngrab
+ NotifyWhileGrabbed = orcsome3_backend.NOTIFY_MODES.NotifyWhileGrabbed
+
+ class NOTIFY_DETAIL(int, Enum):
+ NotifyAncestor = orcsome3_backend.NOTIFY_DETAILS.NotifyAncestor
+ NotifyVirtual = orcsome3_backend.NOTIFY_DETAILS.NotifyVirtual
+ NotifyInferior = orcsome3_backend.NOTIFY_DETAILS.NotifyInferior
+ NotifyNonlinear = orcsome3_backend.NOTIFY_DETAILS.NotifyNonlinear
+ NotifyNonlinearVirtual = orcsome3_backend.NOTIFY_DETAILS.NotifyNonlinearVirtual
+ NotifyPointer = orcsome3_backend.NOTIFY_DETAILS.NotifyPointer
+ NotifyPointerRoot = orcsome3_backend.NOTIFY_DETAILS.NotifyPointerRoot
+ NotifyDetailNone = orcsome3_backend.NOTIFY_DETAILS.NotifyDetailNone
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ type: XFocusChangeEvent.TYPE,
+ window: TYPES.Cython_Window,
+ detail: XFocusChangeEvent.NOTIFY_DETAIL,
+ mode: XFocusChangeEvent.NOTIFY_MODE = NOTIFY_MODE.NotifyNormal,
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.from_id(id_=type.value)
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XFocusChangeEvent = (
+ orcsome3_backend.PyXFocusChangeEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ mode=mode.value,
+ detail=detail.value,
+ )
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.mode: XFocusChangeEvent.NOTIFY_MODE = mode
+ self.detail: XFocusChangeEvent.NOTIFY_DETAIL = detail
+
+ @classmethod
+ def _new_from_cython_event_(cls, focus_change_event: TYPES.EVENTS.Cython_XFocusChangeEvent) -> XFocusChangeEvent:
+ x_focus_change_event: XFocusChangeEvent = cls(
+ display=focus_change_event.display,
+ type=XFocusChangeEvent.TYPE(focus_change_event.type),
+ window=TYPES.Cython_Window(focus_change_event.window),
+ detail=XFocusChangeEvent.NOTIFY_DETAIL(focus_change_event.detail),
+ mode=XFocusChangeEvent.NOTIFY_MODE(focus_change_event.mode),
+ serial=int(focus_change_event.serial),
+ send_event=bool(focus_change_event.send_event),
+ create_cython_event=False,
+ )
+ x_focus_change_event._cython_event = focus_change_event
+ return x_focus_change_event
+
+
+class XCreateWindowEvent(XEvent):
+ """CreateNotify (`XCreateWindowEvent`)."""
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ parent: TYPES.Cython_Window,
+ width: int,
+ height: int,
+ override_redirect: bool,
+ x: int = 0,
+ y: int = 0,
+ border_width: int = 0,
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.CreateNotify
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XCreateWindowEvent = (
+ orcsome3_backend.PyXCreateWindowEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ parent=parent,
+ window=window,
+ x=x,
+ y=y,
+ width=width,
+ height=height,
+ border_width=border_width,
+ override_redirect=override_redirect,
+ )
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.parent: TYPES.Cython_Window = parent
+ self.x: int = x
+ self.y: int = y
+ self.width: int = width
+ self.height: int = height
+ self.border_width: int = border_width
+ self.override_redirect: bool = override_redirect
+
+ @classmethod
+ def _new_from_cython_event_(cls, create_window_event: TYPES.EVENTS.Cython_XCreateWindowEvent) -> XCreateWindowEvent:
+ x_create_window_event: XCreateWindowEvent = cls(
+ display=create_window_event.display,
+ parent=TYPES.Cython_Window(create_window_event.parent),
+ window=TYPES.Cython_Window(create_window_event.window),
+ width=int(create_window_event.width),
+ height=int(create_window_event.height),
+ override_redirect=bool(create_window_event.override_redirect),
+ x=int(create_window_event.x),
+ y=int(create_window_event.y),
+ border_width=int(create_window_event.border_width),
+ serial=int(create_window_event.serial),
+ send_event=bool(create_window_event.send_event),
+ create_cython_event=False,
+ )
+ x_create_window_event._cython_event = create_window_event
+ return x_create_window_event
+
+
+class XDestroyWindowEvent(XEvent):
+ """DestroyNotify (`XDestroyWindowEvent`)."""
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ event: TYPES.Cython_Window,
+ window: TYPES.Cython_Window,
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.DestroyNotify
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XDestroyWindowEvent = (
+ orcsome3_backend.PyXDestroyWindowEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ event=event,
+ window=window,
+ )
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.event: TYPES.Cython_Window = event
+
+ @classmethod
+ def _new_from_cython_event_(
+ cls, destroy_window_event: TYPES.EVENTS.Cython_XDestroyWindowEvent
+ ) -> XDestroyWindowEvent:
+ x_destroy_window_event: XDestroyWindowEvent = cls(
+ display=destroy_window_event.display,
+ event=TYPES.Cython_Window(destroy_window_event.event),
+ window=TYPES.Cython_Window(destroy_window_event.window),
+ serial=int(destroy_window_event.serial),
+ send_event=bool(destroy_window_event.send_event),
+ create_cython_event=False,
+ )
+ x_destroy_window_event._cython_event = destroy_window_event
+ return x_destroy_window_event
+
+
+class XMapEvent(XEvent):
+ """MapNotify (`XMapEvent`). `window` is the mapped client; `event` is who selected (parent or self)."""
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ event: TYPES.Cython_Window,
+ window: TYPES.Cython_Window,
+ override_redirect: bool,
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.MapNotify
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XMapEvent = orcsome3_backend.PyXMapEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ event=event,
+ window=window,
+ override_redirect=override_redirect,
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.event: TYPES.Cython_Window = event
+ self.override_redirect: bool = override_redirect
+
+ @classmethod
+ def _new_from_cython_event_(cls, map_event: TYPES.EVENTS.Cython_XMapEvent) -> XMapEvent:
+ x_map_event: XMapEvent = cls(
+ display=map_event.display,
+ event=TYPES.Cython_Window(map_event.event),
+ window=TYPES.Cython_Window(map_event.window),
+ override_redirect=bool(map_event.override_redirect),
+ serial=int(map_event.serial),
+ send_event=bool(map_event.send_event),
+ create_cython_event=False,
+ )
+ x_map_event._cython_event = map_event
+ return x_map_event
+
+
+class XUnmapEvent(XEvent):
+ """UnmapNotify (`XUnmapEvent`). `from_configure` is True when the unmap is due to a parent configure."""
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ event: TYPES.Cython_Window,
+ window: TYPES.Cython_Window,
+ from_configure: bool = False,
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.UnmapNotify
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XUnmapEvent = orcsome3_backend.PyXUnmapEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ event=event,
+ window=window,
+ from_configure=from_configure,
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.event: TYPES.Cython_Window = event
+ self.from_configure: bool = from_configure
+
+ @classmethod
+ def _new_from_cython_event_(cls, unmap_event: TYPES.EVENTS.Cython_XUnmapEvent) -> XUnmapEvent:
+ x_unmap_event: XUnmapEvent = cls(
+ display=unmap_event.display,
+ event=TYPES.Cython_Window(unmap_event.event),
+ window=TYPES.Cython_Window(unmap_event.window),
+ from_configure=bool(unmap_event.from_configure),
+ serial=int(unmap_event.serial),
+ send_event=bool(unmap_event.send_event),
+ create_cython_event=False,
+ )
+ x_unmap_event._cython_event = unmap_event
+ return x_unmap_event
+
+
+class XConfigureEvent(XEvent):
+ """ConfigureNotify (`XConfigureEvent`). `x`/`y` are relative to the parent; `above` is the stacking sibling."""
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ event: TYPES.Cython_Window,
+ window: TYPES.Cython_Window,
+ x: int = 0,
+ y: int = 0,
+ width: int = 0,
+ height: int = 0,
+ border_width: int = 0,
+ above: TYPES.Cython_Window = 0,
+ override_redirect: bool = False,
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.ConfigureNotify
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XConfigureEvent = orcsome3_backend.PyXConfigureEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ event=event,
+ window=window,
+ x=x,
+ y=y,
+ width=width,
+ height=height,
+ border_width=border_width,
+ above=above,
+ override_redirect=override_redirect,
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.event: TYPES.Cython_Window = event
+ self.x: int = x
+ self.y: int = y
+ self.width: int = width
+ self.height: int = height
+ self.border_width: int = border_width
+ self.above: TYPES.Cython_Window = above
+ self.override_redirect: bool = override_redirect
+
+ @classmethod
+ def _new_from_cython_event_(cls, configure_event: TYPES.EVENTS.Cython_XConfigureEvent) -> XConfigureEvent:
+ x_configure_event: XConfigureEvent = cls(
+ display=configure_event.display,
+ event=TYPES.Cython_Window(configure_event.event),
+ window=TYPES.Cython_Window(configure_event.window),
+ x=int(configure_event.x),
+ y=int(configure_event.y),
+ width=int(configure_event.width),
+ height=int(configure_event.height),
+ border_width=int(configure_event.border_width),
+ above=TYPES.Cython_Window(configure_event.above),
+ override_redirect=bool(configure_event.override_redirect),
+ serial=int(configure_event.serial),
+ send_event=bool(configure_event.send_event),
+ create_cython_event=False,
+ )
+ x_configure_event._cython_event = configure_event
+ return x_configure_event
+
+
+class XPropertyEvent(XEvent):
+ """PropertyNotify (`XPropertyEvent`)."""
+
+ class STATE(int, Enum):
+ PropertyNewValue = orcsome3_backend.PROPERTY_NOTIFICATION.PropertyNewValue
+ PropertyDelete = orcsome3_backend.PROPERTY_NOTIFICATION.PropertyDelete
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ atom: TYPES.Cython_Atom,
+ state: STATE,
+ serial: int = 0,
+ send_event: bool = False,
+ time: TYPES.Cython_Time = CONSTANTS.CURRENT_TIME,
+ create_cython_event: bool = True,
+ ) -> None:
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.PropertyNotify
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XPropertyEvent = orcsome3_backend.PyXPropertyEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ atom=atom,
+ time=time,
+ state=state.value,
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.atom: TYPES.Cython_Atom = atom
+ self.time: TYPES.Cython_Time = time
+ self.state: XPropertyEvent.STATE = state
+
+ @classmethod
+ def _new_from_cython_event_(cls, property_event: TYPES.EVENTS.Cython_XPropertyEvent) -> XPropertyEvent:
+ x_property_event: XPropertyEvent = cls(
+ display=property_event.display,
+ window=TYPES.Cython_Window(property_event.window),
+ atom=TYPES.Cython_Atom(property_event.atom),
+ state=XPropertyEvent.STATE(property_event.state),
+ serial=int(property_event.serial),
+ send_event=bool(property_event.send_event),
+ time=TYPES.Cython_Time(property_event.time),
+ create_cython_event=False,
+ )
+ x_property_event._cython_event = property_event
+ return x_property_event
+
+
+class XClientMessageEvent(XEvent):
+ """ClientMessage (`XClientMessageEvent`), used for EWMH `_NET_*` requests."""
+
+ def __init__(
+ self,
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ message_type: TYPES.Cython_Atom,
+ format_: PROPERTY_FORMAT,
+ data: Union[str, list[int]],
+ serial: int = 0,
+ send_event: bool = False,
+ create_cython_event: bool = True,
+ ) -> None:
+ message_data: array[int] = _build_client_message_array(format_=format_, data=data)
+ type_: XEvent.EVENT_TYPES = XEvent.EVENT_TYPES.ClientMessage
+ if create_cython_event and type_.value.cython_class is not None:
+ cython_event: TYPES.EVENTS.Cython_XClientMessageEvent = (
+ orcsome3_backend.PyXClientMessageEvent._new_from_python_(
+ type=type_.value.id,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ message_type=message_type,
+ format=format_.value[0],
+ data=message_data,
+ )
+ )
+ setattr(self, "_cython_event", cython_event)
+ self._set_attributes_(
+ type=type_,
+ serial=serial,
+ send_event=send_event,
+ display=display,
+ window=window,
+ )
+ self.message_type: TYPES.Cython_Atom = message_type
+ self.format_: PROPERTY_FORMAT = format_
+ self.data: array[int] = message_data
+
+ @classmethod
+ def _new_from_cython_event_(
+ cls, client_message_event: TYPES.EVENTS.Cython_XClientMessageEvent
+ ) -> XClientMessageEvent:
+ x_client_message_event: XClientMessageEvent = cls(
+ display=client_message_event.display,
+ window=TYPES.Cython_Window(client_message_event.window),
+ message_type=TYPES.Cython_Atom(client_message_event.message_type),
+ format_=PROPERTY_FORMAT.new_from_value(value=client_message_event.format.value[0]),
+ data=client_message_event.data.tolist(),
+ serial=int(client_message_event.serial),
+ send_event=bool(client_message_event.send_event),
+ create_cython_event=False,
+ )
+ x_client_message_event._cython_event = client_message_event
+ return x_client_message_event
+
+
+class XScreenSaverInfo:
+ """Result of `XScreenSaverQueryInfo`."""
+
+ class State(int, Enum):
+ Off = orcsome3_backend.SCREENSAVER_STATE.ScreenSaverOff
+ On = orcsome3_backend.SCREENSAVER_STATE.ScreenSaverOn
+ Cycle = orcsome3_backend.SCREENSAVER_STATE.ScreenSaverCycle
+ Disabled = orcsome3_backend.SCREENSAVER_STATE.ScreenSaverDisabled
+
+ class Kind(int, Enum):
+ Blanked = orcsome3_backend.SCREENSAVER_KIND.ScreenSaverBlanked
+ Internal = orcsome3_backend.SCREENSAVER_KIND.ScreenSaverInternal
+ External = orcsome3_backend.SCREENSAVER_KIND.ScreenSaverExternal
+
+ def __init__(
+ self,
+ window: TYPES.Cython_Window,
+ state: int,
+ kind: int,
+ til_or_since: int,
+ idle: int,
+ event_mask: int,
+ ) -> None:
+ self.window: TYPES.Cython_Window = window # screen saver window
+ self.state: XScreenSaverInfo.State = XScreenSaverInfo.State(state)
+ self.kind: XScreenSaverInfo.Kind = XScreenSaverInfo.Kind(kind)
+ self.til_or_since: int = til_or_since # time til or since screen saver (milliseconds)
+ self.idle: int = idle # total time since last user input (milliseconds)
+ self.event_mask: int = event_mask # currently selected events for this client
+
+ @override
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({', '.join([f'{k}={v!r}' for k, v in self.__dict__.items() if not k.startswith('_')])})"
+
+
+class XkbStateRec(NamedTuple):
+ """Class representing Xkb keyboard state"""
+
+ group: int
+ base_group: int
+ latched_group: int
+ locked_group: int
+ mods: int
+ base_mods: int
+ latched_mods: int
+ locked_mods: int
+ compat_state: int
+ grab_mods: int
+ compat_grab_mods: int
+ lookup_mods: int
+ compat_lookup_mods: int
+ ptr_buttons: int
+
+ @classmethod
+ def new(cls, state: TYPES.Cython_XkbStateRec) -> XkbStateRec:
+ """Copy fields from the Cython Xkb state struct."""
+ return cls(
+ group=int(state.group),
+ base_group=int(state.base_group),
+ latched_group=int(state.latched_group),
+ locked_group=int(state.locked_group),
+ mods=int(state.mods),
+ base_mods=int(state.base_mods),
+ latched_mods=int(state.latched_mods),
+ locked_mods=int(state.locked_mods),
+ compat_state=int(state.compat_state),
+ grab_mods=int(state.grab_mods),
+ compat_grab_mods=int(state.compat_grab_mods),
+ lookup_mods=int(state.lookup_mods),
+ compat_lookup_mods=int(state.compat_lookup_mods),
+ ptr_buttons=int(state.ptr_buttons),
+ )
+
+
+class XWindowGeometry(NamedTuple):
+ """
+ Class representing the geometry of a window.
+
+ Attrs:
+ - `x`: Location of the drawable
+ - `y`: Location of the drawable
+ - `width`: Dimension of the drawable
+ - `height`: Dimension of the drawable
+
+
+ For a window, `x` and `y` coordinates specify the upper-left outer corner relative to its parent's origin.
+ For pixmaps, these coordinates are always zero.
+
+ For a window, `width` and `height` dimensions specify the inside size, not including the border.
+ """
+
+ window: TYPES.Cython_Window
+ root: TYPES.Cython_Window
+ x: int
+ y: int
+ width: int
+ height: int
+ border_width: int
+ depth: int
+
+
+class XWindowTree(NamedTuple):
+ """
+ Class representing the tree of a window.
+
+ Attrs:
+ - `window`: window
+ - `root`: Root window
+ - `parent`: Parent window
+ - `children`: Children windows
+ """
+
+ window: TYPES.Cython_Window
+ root: TYPES.Cython_Window
+ parent: TYPES.Cython_Window
+ children: list[TYPES.Cython_Window]
+
+
+class PROPERTY_FORMAT(tuple[int, str], Enum):
+ """
+ Enum representing the valid formats when getting/setting a property.
+
+ - CHAR: Format with value 8. The value of the property is a char array
+ - SHORT: Format with value 16. The value of the property is a short array
+ - LONG: Format with value 32. The value of the property is a long array
+ """
+
+ CHAR = (8, "b")
+ SHORT = (16, "h")
+ LONG = (32, "l")
+
+ @classmethod
+ def new_from_value(cls, value: Union[int, str]) -> PROPERTY_FORMAT:
+ """Look up by bit width (`8`/`16`/`32`) or array typecode (`b`/`h`/`l`)."""
+ for member in cls:
+ if value in member:
+ return member
+ raise ValueError(f"The value {value} is not in the enum members")
+
+
+def _build_client_message_array(format_: PROPERTY_FORMAT, data: Union[str, list[int]]) -> array[int]:
+ """Build a typed array payload for XClientMessageEvent matching the X11 format field."""
+ if format_ == PROPERTY_FORMAT.CHAR:
+ chars: list[int] = list(data.encode()) if isinstance(data, str) else list(data)
+ return array("b", (chars + [0] * 20)[:20])
+ if isinstance(data, str):
+ raise ValueError(f"Invalid data type for format {format_}, expected list[int]")
+ item_count: int = 10 if format_ == PROPERTY_FORMAT.SHORT else 5
+ return array(format_.value[1], (list(data) + [0] * item_count)[:item_count])
+
+
+class WindowProperty(NamedTuple):
+ """
+ Class used to represent the property of a window
+
+ Attributes:
+ - `window`: Related window
+ - `property_name`: Name of the property.
+ - `type_`: Name of the type of the property.
+ - `atom_type`: Atom of the type of the property.
+ - `format_`: Format of the property. See enum `orcsome3.libs.xlib.PROPERTY_FORMAT`.
+ - `property_data`: Actual data of the property, it must be an array of char ('b'), short ('h') or long ('l').
+
+ """
+
+ window: TYPES.Cython_Window
+ property_name: str
+ type_: str
+ atom_type: TYPES.Cython_Atom
+ format_: PROPERTY_FORMAT
+ property_data: array[int]
+
+ def get_int_list(self) -> list[int]:
+ """Get list of int from the array when the format is not `CHAR`, else returns an empty list."""
+ if self.format_ == PROPERTY_FORMAT.CHAR:
+ return []
+ return self.property_data.tolist()
+
+ def get_string_list(self) -> list[str]:
+ """Get list of string from the array when the format is `CHAR`, else returns an empty list."""
+ if self.format_ != PROPERTY_FORMAT.CHAR:
+ return []
+ null_byte: bytes = b"\x00"
+ return [part.decode() for part in self.property_data.tobytes().split(sep=null_byte) if part]
+
+
+class DPMSInfo:
+ """DPMS enabled flag and current power level."""
+
+ class PowerLEvel(int, Enum):
+ DPMSModeOn = orcsome3_backend.DPMS_POWER_LEVEL.DPMSModeOn
+ DPMSModeStandby = orcsome3_backend.DPMS_POWER_LEVEL.DPMSModeStandby
+ DPMSModeSuspend = orcsome3_backend.DPMS_POWER_LEVEL.DPMSModeSuspend
+ DPMSModeOff = orcsome3_backend.DPMS_POWER_LEVEL.DPMSModeOff
+
+ def __init__(self, dpms_info: TYPES.Cython_DPMSInfo) -> None:
+ self.state: bool = bool(dpms_info.state)
+ self.power_level: DPMSInfo.PowerLEvel = DPMSInfo.PowerLEvel(dpms_info.power_level)
+
+ @override
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({', '.join([f'{k}={v!r}' for k, v in self.__dict__.items() if not k.startswith('_')])})"
+
+
+def x_open_display(display_name: Optional[str] = None) -> TYPES.Cython_Display:
+ """
+ Connect to the X server. Wrapper for `XOpenDisplay`. Returns a `orcsome3.libs.xlib.TYPES.Cython_Display` object.
+
+ Params:
+ - `display_name`: Specifies the hardware display name, which determines the display and communications
+ domain to be used. On a POSIX-conformant system, if the display_name is `None`,
+ it defaults to the value of the `DISPLAY` environment variable.
+ """
+ display: Optional[TYPES.Cython_Display] = orcsome3_backend.PyXOpenDisplay(display_name=display_name)
+ if display is None:
+ raise Exception("Can't open display")
+ return display
+
+
+def x_close_display(display: TYPES.Cython_Display) -> None:
+ """Disconnect from X server. Wrapper for `XCloseDisplay`."""
+ orcsome3_backend.PyXCloseDisplay(display=display)
+
+
+def get_default_root_window(display: TYPES.Cython_Display) -> TYPES.Cython_Window:
+ """Returns the root window for `display`"""
+ return TYPES.Cython_Window(orcsome3_backend.PyXDefaultRootWindow(display=display))
+
+
+def get_connection_number(display: TYPES.Cython_Display) -> int:
+ """
+ Return a connection number for `display`.
+ On a POSIX-conformant system, this is the file descriptor of the connection.
+ """
+ return int(orcsome3_backend.PyXConnectionNumber(display=display))
+
+
+def x_create_window(
+ display: TYPES.Cython_Display,
+ parent: TYPES.Cython_Window,
+ width: int = 32,
+ height: int = 32,
+) -> TYPES.Cython_Window:
+ """InputOnly override-redirect window at (0, 0). Wrapper for `XCreateWindow`."""
+ return TYPES.Cython_Window(
+ orcsome3_backend.PyXCreateOverrideRedirectWindow(display=display, parent=parent, width=width, height=height)
+ )
+
+
+def x_map_window(display: TYPES.Cython_Display, window: TYPES.Cython_Window) -> None:
+ """Wrapper for `XMapWindow`"""
+ _ = orcsome3_backend.PyXMapWindow(display=display, window=window)
+
+
+def x_destroy_window(display: TYPES.Cython_Display, window: TYPES.Cython_Window) -> None:
+ """Wrapper for `XDestroyWindow`"""
+ _ = orcsome3_backend.PyXDestroyWindow(display=display, window=window)
+
+
+def x_set_input_focus(
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ revert_to: int = CONSTANTS.REVERT_TO_POINTER_ROOT,
+) -> None:
+ """Wrapper for `XSetInputFocus`"""
+ _ = orcsome3_backend.PyXSetInputFocus(display=display, window=window, revert_to=revert_to)
+
+
+def x_get_input_focus(display: TYPES.Cython_Display) -> tuple[TYPES.Cython_Window, int]:
+ """Wrapper for `XGetInputFocus`. Returns `(focus_window, revert_to)`."""
+ window: int
+ revert_to: int
+ window, revert_to = orcsome3_backend.PyXGetInputFocus(display=display)
+ return TYPES.Cython_Window(window), revert_to
+
+
+def x_grab_key(
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ owner_events: bool,
+ pointer_mode: GRAB_MODE,
+ keyboard_mode: GRAB_MODE,
+ keycode: TYPES.Cython_KeyCode = CONSTANTS.KB.ANY_KEY,
+ modifiers: int = KEY_MASKS.AnyModifier,
+) -> None:
+ """Wrapper for `XGrabKey`"""
+ _ = orcsome3_backend.PyXGrabKey(
+ display=display,
+ keycode=keycode,
+ modifiers=modifiers,
+ window=window,
+ owner_events=owner_events,
+ pointer_mode=pointer_mode.value,
+ keyboard_mode=keyboard_mode.value,
+ )
+
+
+def x_ungrab_key(
+ display: TYPES.Cython_Display,
+ keycode: int,
+ modifiers: int,
+ window: TYPES.Cython_Window,
+) -> None:
+ """Wrapper for `XUngrabKey`"""
+ _ = orcsome3_backend.PyXUngrabKey(display=display, keycode=keycode, modifiers=modifiers, window=window)
+
+
+def x_grab_button(
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ button: int,
+ modifiers: int,
+ owner_events: bool,
+ event_mask: int,
+ pointer_mode: GRAB_MODE,
+ keyboard_mode: GRAB_MODE,
+ confine_to: TYPES.Cython_Window = 0,
+ cursor: int = 0,
+) -> None:
+ """Wrapper for `XGrabButton`. `confine_to`/`cursor` of 0 is X `None`."""
+ _ = orcsome3_backend.PyXGrabButton(
+ display=display,
+ button=button,
+ modifiers=modifiers,
+ window=window,
+ owner_events=owner_events,
+ event_mask=event_mask,
+ pointer_mode=pointer_mode.value,
+ keyboard_mode=keyboard_mode.value,
+ confine_to=confine_to,
+ cursor=cursor,
+ )
+
+
+def x_ungrab_button(
+ display: TYPES.Cython_Display,
+ button: int,
+ modifiers: int,
+ window: TYPES.Cython_Window,
+) -> None:
+ """Wrapper for `XUngrabButton`"""
+ _ = orcsome3_backend.PyXUngrabButton(display=display, button=button, modifiers=modifiers, window=window)
+
+
+def x_select_input(
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ event_mask: Union[list[INPUT_EVENT_MASKS], INPUT_EVENT_MASKS] = INPUT_EVENT_MASKS.NoEventMask,
+) -> None:
+ """Wrapper for `XSelectInput`"""
+ mask: int = 0
+ if isinstance(event_mask, list):
+ mask = INPUT_EVENT_MASKS.NoEventMask.value
+ for em in event_mask:
+ mask |= em.value
+ else:
+ mask = event_mask.value
+ _ = orcsome3_backend.PyXSelectInput(display=display, window=window, event_mask=mask)
+
+
+def x_configure_window(
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ value_mask: Union[list[WINDOW_VALUE_MASK], WINDOW_VALUE_MASK],
+ window_changes: XWindowChanges,
+) -> None:
+ """Wrapper for `XConfigureWindow`"""
+ value_mask_: int = 0
+ if isinstance(value_mask, list):
+ for mask in value_mask:
+ value_mask_ |= mask.value
+ else:
+ value_mask_ = value_mask.value
+ _ = orcsome3_backend.PyXConfigureWindow(
+ display=display,
+ window=window,
+ value_mask=value_mask_,
+ window_changes=window_changes._cython_xwindowchanges, # pyright: ignore[reportPrivateUsage]
+ )
+
+
+def x_sync(display: TYPES.Cython_Display, discard: bool) -> None:
+ """Wrapper for `XSync`"""
+ _ = orcsome3_backend.PyXSync(display=display, discard=discard)
+
+
+def x_set_error_handler(
+ handler: Callable[[TYPES.Cython_Display, TYPES.EVENTS.Cython_XErrorEvent], None],
+) -> None:
+ """Wrapper for `XSetErrorHandler`"""
+ _ = orcsome3_backend.PyXSetErrorHandler(handler=handler)
+
+
+def x_get_window_property(
+ display: TYPES.Cython_Display, window: TYPES.Cython_Window, property_: str
+) -> Optional[WindowProperty]:
+ """Wrapper for `XGetWindowProperty`"""
+ result: Optional[tuple[array[int], TYPES.Cython_Atom, str]] = orcsome3_backend.PyXGetWindowProperty(
+ display=display, window=window, property_=property_
+ )
+ if result is None:
+ return None
+ property_data, atom_type, type_name = result
+ resolved_type: str = type_name if type_name else (x_get_atom_name(display=display, atom=atom_type) or "")
+ try:
+ prop_format: PROPERTY_FORMAT = PROPERTY_FORMAT.new_from_value(value=property_data.typecode)
+ except ValueError:
+ _logger.warning("Unsupported property format %r for %s", property_data.typecode, property_)
+ return None
+ return WindowProperty(
+ window=window,
+ property_name=property_,
+ type_=resolved_type,
+ atom_type=atom_type,
+ format_=prop_format,
+ property_data=property_data,
+ )
+
+
+def x_change_window_property(
+ display: TYPES.Cython_Display,
+ window_property: WindowProperty,
+ mode: SET_PROPERTY_MODE = SET_PROPERTY_MODE.PropModeReplace,
+) -> bool:
+ """Wrapper for `XChangeProperty`.
+
+ `window_property.property_data` must use the array typecode that matches
+ `window_property.format_` (b/h/l); the Cython layer sends it as-is to X11.
+ """
+ return bool(
+ orcsome3_backend.PyXChangeProperty(
+ display=display,
+ window=window_property.window,
+ property_name=window_property.property_name,
+ atom_type=window_property.atom_type,
+ format_=window_property.format_.value[0],
+ mode=mode.value,
+ property_data=window_property.property_data,
+ )
+ )
+
+
+def x_get_window_attributes(display: TYPES.Cython_Display, window: TYPES.Cython_Window) -> Optional[XWindowAttributes]:
+ """Wrapper for `XGetWindowAttributes`"""
+ attrs: Optional[TYPES.Cython_XWindowAttributes] = orcsome3_backend.PyXGetWindowAttributes(
+ display=display, window=window
+ )
+ if attrs is None:
+ return None
+
+ return XWindowAttributes._new_from_cython_xwindowattributes_( # pyright:ignore[reportPrivateUsage]
+ window=window, cython_xwindowattributes=attrs
+ )
+
+
+def x_get_window_tree(display: TYPES.Cython_Display, window: TYPES.Cython_Window) -> Optional[XWindowTree]:
+ """Wrapper for `XQueryTree`"""
+ result: Optional[TYPES.Cython_XWindowTree] = orcsome3_backend.PyXQueryTree(display=display, window=window)
+ if result is None:
+ return None
+
+ return XWindowTree(
+ window=result.window,
+ root=result.root,
+ parent=result.parent,
+ children=result.children,
+ )
+
+
+def x_get_window_geometry(display: TYPES.Cython_Display, window: TYPES.Cython_Window) -> Optional[XWindowGeometry]:
+ """Wrapper for `XGetGeometry`"""
+ window_geometry: Optional[TYPES.Cython_XWindowGeometry] = orcsome3_backend.PyXGetGeometry(
+ display=display, window=window
+ )
+ if window_geometry is None:
+ return None
+
+ return XWindowGeometry(
+ window=window,
+ root=window_geometry.root,
+ x=window_geometry.x,
+ y=window_geometry.y,
+ width=window_geometry.width,
+ height=window_geometry.height,
+ border_width=window_geometry.border_width,
+ depth=window_geometry.depth,
+ )
+
+
+def x_get_screen_saver_info(display: TYPES.Cython_Display, drawable: TYPES.Cython_Window) -> Optional[XScreenSaverInfo]:
+ """Wrapper for `XScreenSaverQueryInfo`"""
+ result: Optional[TYPES.Cython_XScreenSaverInfo] = orcsome3_backend.PyXScreenSaverQueryInfo(
+ display=display, window=drawable
+ )
+ if result is None:
+ return None
+
+ return XScreenSaverInfo(
+ window=result.window,
+ state=result.state,
+ kind=result.kind,
+ til_or_since=result.til_or_since,
+ idle=result.idle,
+ event_mask=result.event_mask,
+ )
+
+
+def x_kb_get_state(display: TYPES.Cython_Display, device_spec: int = CONSTANTS.KB.X_KB_USE_CORE_KBD) -> XkbStateRec:
+ """Wrapper for `XkbGetState`"""
+ result: TYPES.Cython_XkbStateRec = orcsome3_backend.PyXkbGetState(display=display, device_spec=device_spec)
+ return XkbStateRec.new(state=result)
+
+
+def x_kb_lock_group(
+ display: TYPES.Cython_Display,
+ group: KEYSYM_GROUPS,
+ device_spec: int = CONSTANTS.KB.X_KB_USE_CORE_KBD,
+) -> bool:
+ """Wrapper for `XkbLockGroup`"""
+ result: bool = bool(orcsome3_backend.PyXkbLockGroup(display=display, device_spec=device_spec, group=group.value))
+ x_flush(display=display)
+ return result
+
+
+def x_flush(display: TYPES.Cython_Display) -> None:
+ """Wrapper for `XFlush`"""
+ _ = orcsome3_backend.PyXFlush(display=display)
+
+
+def xtest_query_extension(display: TYPES.Cython_Display) -> bool:
+ """True if the XTEST extension is present. Wrapper for `XTestQueryExtension`."""
+ return bool(orcsome3_backend.PyXTestQueryExtension(display=display))
+
+
+def xtest_fake_key_event(display: TYPES.Cython_Display, keycode: int, press: bool, delay: int = 0) -> None:
+ """Wrapper for `XTestFakeKeyEvent`"""
+ _ = orcsome3_backend.PyXTestFakeKeyEvent(display=display, keycode=keycode, press=press, delay=delay)
+ x_flush(display=display)
+
+
+def xtest_fake_button_event(display: TYPES.Cython_Display, button: int, press: bool, delay: int = 0) -> None:
+ """Wrapper for `XTestFakeButtonEvent`"""
+ _ = orcsome3_backend.PyXTestFakeButtonEvent(display=display, button=button, press=press, delay=delay)
+ x_flush(display=display)
+
+
+def xtest_fake_motion_event(display: TYPES.Cython_Display, x: int, y: int, screen: int = -1, delay: int = 0) -> None:
+ """Wrapper for `XTestFakeMotionEvent`. `screen=-1` is the screen the pointer is on."""
+ _ = orcsome3_backend.PyXTestFakeMotionEvent(display=display, screen=screen, x=x, y=y, delay=delay)
+ x_flush(display=display)
+
+
+def x_get_atom_name(display: TYPES.Cython_Display, atom: TYPES.Cython_Atom) -> Optional[str]:
+ """
+ Returns the name associated with an Atom if the Atom exists.
+
+ Wrapper for `XGetAtomName`
+ """
+ result: str = str(orcsome3_backend.PyXGetAtomName(display=display, atom=atom))
+ return result if len(result.strip()) else None
+
+
+def x_get_atom_from_name(
+ display: TYPES.Cython_Display, atom_name: str, create_if_not_exists: bool
+) -> TYPES.Cython_Atom:
+ """Wrapper for XInternAtom"""
+ atom: TYPES.Cython_Atom = orcsome3_backend.PyXInternAtom(
+ display=display, atom_name=atom_name, only_if_exists=not create_if_not_exists
+ )
+ return atom
+
+
+def x_send_event(
+ display: TYPES.Cython_Display,
+ window: TYPES.Cython_Window,
+ propagate: bool,
+ xevent: XEvent,
+ event_masks: Union[list[INPUT_EVENT_MASKS], INPUT_EVENT_MASKS] = INPUT_EVENT_MASKS.NoEventMask,
+) -> None:
+ """Wrapper for `XSendEvent`"""
+
+ if xevent._cython_event is None: # pyright: ignore[reportPrivateUsage]
+ return
+
+ event_mask: int = 0
+ if isinstance(event_masks, list):
+ for event_mask_ in event_masks:
+ event_mask |= event_mask_.value
+ else:
+ event_mask = event_masks.value
+
+ _ = orcsome3_backend.PyXSendEvent(
+ display=display,
+ window=window,
+ propagate=propagate,
+ event_mask=event_mask,
+ xevent=xevent._cython_event, # pyright: ignore[reportPrivateUsage]
+ )
+
+
+def set_window_icon(display: TYPES.Cython_Display, window: TYPES.Cython_Window, icon_path: Path) -> bool:
+ """Encode `icon_path` and set `_NET_WM_ICON`. True on success."""
+ return bool(orcsome3_backend.PySetWindowIcon(display=display, window=window, filepath=icon_path))
+
+
+def render_svg_to_argb(svg_path: Path, size: int) -> Optional[bytes]:
+ """Rasterize `svg_path` to `size`x`size` raw ARGB32 pixels (network byte order: A,R,G,B per
+ pixel) via resvg. `None` if the file can't be parsed. No X display or window involved.
+ """
+ result: Optional[bytes] = orcsome3_backend.PyRenderSvgToArgb(filepath=str(svg_path), size=size)
+ return result
+
+
+def x_string_to_keysym(string: str) -> TYPES.Cython_KeySym:
+ """Wrapper for `XStringToKeysym`"""
+ return TYPES.Cython_KeySym(orcsome3_backend.PyXStringToKeysym(string=string))
+
+
+def x_keysym_to_keycode(display: TYPES.Cython_Display, keysym: TYPES.Cython_KeySym) -> TYPES.Cython_KeyCode:
+ """Wrapper for `XKeysymToKeycode`"""
+ return TYPES.Cython_KeyCode(orcsome3_backend.PyXKeysymToKeycode(display=display, keysym=keysym))
+
+
+def x_pending(display: TYPES.Cython_Display) -> int:
+ """Wrapper for `XPending`"""
+ return int(orcsome3_backend.PyXPending(display=display))
+
+
+def x_next_event(
+ display: TYPES.Cython_Display,
+) -> Union[
+ XEvent,
+ XErrorEvent,
+ XButtonEvent,
+ XKeyEvent,
+ XFocusChangeEvent,
+ XCreateWindowEvent,
+ XDestroyWindowEvent,
+ XMapEvent,
+ XUnmapEvent,
+ XConfigureEvent,
+ XPropertyEvent,
+ XClientMessageEvent,
+]:
+ """Wrapper for `XNextEvent`"""
+ cython_xevent: TYPES.EVENTS.Cython_XEvent = orcsome3_backend.PyXNextEvent(display=display)
+ if isinstance(cython_xevent, orcsome3_backend.PyXKeyEvent):
+ return XKeyEvent._new_from_cython_event_(key_event=cython_xevent) # pyright: ignore[reportPrivateUsage]
+ if isinstance(cython_xevent, orcsome3_backend.PyXButtonEvent):
+ return XButtonEvent._new_from_cython_event_(button_event=cython_xevent) # pyright: ignore[reportPrivateUsage]
+ if isinstance(cython_xevent, orcsome3_backend.PyXFocusChangeEvent):
+ return XFocusChangeEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ focus_change_event=cython_xevent
+ )
+ if isinstance(cython_xevent, orcsome3_backend.PyXCreateWindowEvent):
+ return XCreateWindowEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ create_window_event=cython_xevent
+ )
+ if isinstance(cython_xevent, orcsome3_backend.PyXDestroyWindowEvent):
+ return XDestroyWindowEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ destroy_window_event=cython_xevent
+ )
+ if isinstance(cython_xevent, orcsome3_backend.PyXMapEvent):
+ return XMapEvent._new_from_cython_event_(map_event=cython_xevent) # pyright: ignore[reportPrivateUsage]
+ if isinstance(cython_xevent, orcsome3_backend.PyXUnmapEvent):
+ return XUnmapEvent._new_from_cython_event_(unmap_event=cython_xevent) # pyright: ignore[reportPrivateUsage]
+ if isinstance(cython_xevent, orcsome3_backend.PyXConfigureEvent):
+ return XConfigureEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ configure_event=cython_xevent
+ )
+ if isinstance(cython_xevent, orcsome3_backend.PyXPropertyEvent):
+ return XPropertyEvent._new_from_cython_event_(property_event=cython_xevent) # pyright: ignore[reportPrivateUsage]
+ if isinstance(cython_xevent, orcsome3_backend.PyXClientMessageEvent):
+ return XClientMessageEvent._new_from_cython_event_( # pyright: ignore[reportPrivateUsage]
+ client_message_event=cython_xevent
+ )
+ if isinstance(cython_xevent, orcsome3_backend.PyXErrorEvent):
+ return XErrorEvent(error_event=cython_xevent)
+ xevent: XEvent = XEvent()
+ xevent._set_attributes_from_cython_event_(cython_event=cython_xevent) # pyright: ignore[reportPrivateUsage]
+ return xevent
+
+
+def dpms_info(display: TYPES.Cython_Display) -> DPMSInfo:
+ """Wrapper for `DPMSInfo`"""
+ return DPMSInfo(dpms_info=orcsome3_backend.PyGetDPMSInfo(display=display))
+
+
+def dpms_enable(display: TYPES.Cython_Display) -> bool:
+ """Wrapper for `DPMSEnable`"""
+ return bool(orcsome3_backend.PyDPMSEnable(display=display))
+
+
+def dpms_disable(display: TYPES.Cython_Display) -> bool:
+ """Wrapper for `DPMSDisable`"""
+ return bool(orcsome3_backend.PyDPMSDisable(display=display))
+
+
+def reset_dpms(display: TYPES.Cython_Display) -> None:
+ """If DPMS is enabled, disable then re-enable it so the display wakes / timer resets."""
+ dpms_info_: DPMSInfo = dpms_info(display=display)
+ if dpms_info_.state:
+ _ = dpms_disable(display=display)
+ _ = dpms_enable(display=display)
+
+
+def default_error_handler(__display__: TYPES.Cython_Display, error: TYPES.EVENTS.Cython_XErrorEvent) -> None:
+ """Default callback for errors"""
+ err: XErrorEvent = XErrorEvent(error_event=error)
+ msg_resource: str = f"{'0x%0.2X' % int(err.resourceid)}:{int(err.resourceid)}"
+ _logger.error(msg=f"{err.msg} ({msg_resource})")
diff --git a/orcsome3/notify.py b/orcsome3/notify.py
new file mode 100644
index 0000000..30e4de4
--- /dev/null
+++ b/orcsome3/notify.py
@@ -0,0 +1,593 @@
+"""Desktop notifications via the Freedesktop `org.freedesktop.Notifications` D-Bus interface."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+from concurrent.futures import Future
+from enum import Enum
+from pathlib import Path
+from typing import Any, Callable, Optional, Union, cast
+
+from dbus_next.aio.message_bus import MessageBus
+from dbus_next.signature import Variant
+from typing_extensions import override
+
+from orcsome3.common import APPNAME
+from orcsome3.utils import SingletonMixin
+
+# Globals
+_logger: logging.Logger = logging.getLogger(name=__name__)
+_visible_notifications: dict[int, Notification] = {}
+
+
+class CONSTANTS(str, Enum):
+ """D-Bus well-known name, object path, and interface for desktop notifications."""
+
+ NOTIFICATIONS_BUS_NAME = "org.freedesktop.Notifications"
+ NOTIFICATIONS_OBJECT_PATH = "/org/freedesktop/Notifications"
+ NOTIFICATIONS_INTERFACE = "org.freedesktop.Notifications"
+
+
+class ServerCapabilities(str, Enum):
+ """
+ Enum representation of server capabilities.
+
+ - "action-icons": Supports using icons instead of text for displaying actions. Using icons for actions
+ must be enabled on a per-notification basis using the "action-icons" hint.
+ - "actions": The server will provide the specified actions to the user. Even if this cap is missing,
+ actions may still be specified by the client, however the server is free to ignore them.
+ - "body": Supports body text.
+ Some implementations may only show the summary (for instance, onscreen displays, marquee/scrollers)
+ - "body-hyperlinks": The server supports hyperlinks in the notifications.
+ - "body-images": The server supports images in the notifications.
+ - "body-markup": Supports markup in the body text. If marked up text is sent to a server that does not give this cap,
+ the markup will show through as regular text so must be stripped clientside.
+ - "icon-multi": The server will render an animation of all the frames in a given image array.
+ The client may still specify multiple frames even if this cap and/or "icon-static" is missing,
+ however the server is free to ignore them and use only the primary frame.
+ - "icon-static": Supports display of exactly 1 frame of any given image array. This value is mutually exclusive
+ with "icon-multi", it is a protocol error for the server to specify both.
+ - "persistence": The server supports persistence of notifications. Notifications will be retained until they are
+ acknowledged or removed by the user or recalled by the sender. The presence of this
+ capability allows clients to depend on the server to ensure a notification is seen and eliminate
+ the need for the client to display a reminding function (such as a status icon) of its own.
+ - "sound": The server supports sounds on notifications. If returned, the server must support the
+ "sound-file" and "suppress-sound" hints.
+ """
+
+ ACTION_ICONS = "action-icons"
+ ACTIONS = "actions"
+ BODY = "body"
+ BODY_HYPERLINKS = "body-hyperlinks"
+ BODY_IMAGES = "body-images"
+ BODY_MARKUP = "body-markup"
+ ICON_MULTI = "icon-multi"
+ ICON_STATIC = "icon-static"
+ PERSISTENCE = "persistence"
+ SOUND = "sound"
+
+
+class NotificationBus(threading.Thread, SingletonMixin):
+ """Class wrapper mainly to call methods from Desktop Notifications Specification D-Bus Protocol (`org.freedesktop.Notifications`)."""
+
+ session_bus: Optional[Any] = None
+ notification_proxy: Optional[Any] = None
+ notification_interface: Optional[Any] = None
+ can_show_notifications: bool = False
+ _dbus_methods: dict[str, Callable[..., Any]]
+
+ class ReasonNotificationClosed(int, Enum):
+ """
+ Enum representing the reason why a notification was closed.
+
+ - 1 - The notification expired.
+ - 2 - The notification was dismissed by the user.
+ - 3 - The notification was closed by a call to CloseNotification.
+ - 4 - Undefined/reserved reasons.
+ """
+
+ NOTIFICATION_EXPIRED = 1
+ NOTIFICATION_DISMISSED = 2
+ NOTIFICACION_CLOSED = 3
+ UNDEFINED = 4
+
+ def __init__(self) -> None:
+ """Connect the session bus, introspect Notifications, then start the asyncio thread."""
+ if type(self)._singleton_init_done():
+ return
+ try:
+ super().__init__(daemon=True)
+ self._dbus_methods = {}
+ self._event_loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop=self._event_loop)
+ self._event_loop.run_until_complete(future=NotificationBus.initialize_notification_bus())
+ Notification.notification_bus = self # Share state for all Notification objects
+ self.start()
+ except Exception as e:
+ _logger.error(msg="An error occurred starting dbus. Notifications can't be shown.")
+ _logger.error(msg=e)
+
+ @override
+ def run(self) -> None:
+ """Start thread's activity"""
+ if self.session_bus is None:
+ return
+
+ if self.notification_interface is not None:
+ self.connect_to_signal(signal_name="NotificationClosed", callback=self._on_notification_closed)
+ self.connect_to_signal(signal_name="ActionInvoked", callback=self._on_action_invoked)
+ self._event_loop.run_forever()
+
+ # Loop forever until disconnection from the bus
+ self._event_loop.run_until_complete(future=self.session_bus.wait_for_disconnect())
+
+ @classmethod
+ async def initialize_notification_bus(cls) -> None:
+ """Connect to the session bus, bind Notifications if present, then export the SNI tray."""
+ cls.session_bus = await MessageBus().connect()
+ try:
+ cls.notification_proxy = cls.session_bus.get_proxy_object(
+ bus_name=CONSTANTS.NOTIFICATIONS_BUS_NAME.value,
+ path=CONSTANTS.NOTIFICATIONS_OBJECT_PATH,
+ introspection=await cls.session_bus.introspect(
+ bus_name=CONSTANTS.NOTIFICATIONS_BUS_NAME, path=CONSTANTS.NOTIFICATIONS_OBJECT_PATH
+ ),
+ )
+ cls.notification_interface = cls.notification_proxy.get_interface(name=CONSTANTS.NOTIFICATIONS_INTERFACE)
+ cls.can_show_notifications = True
+ except Exception as e:
+ _logger.warning(msg=f"Desktop notifications unavailable: {e}")
+ cls.can_show_notifications = False
+ try:
+ from orcsome3.tray import start_status_notifier
+
+ await start_status_notifier(bus=cls.session_bus)
+ except Exception as e:
+ _logger.warning(msg=f"Status notifier tray unavailable: {e}")
+
+ @classmethod
+ def stop(cls) -> None:
+ """Disconnect the session bus, stop its asyncio loop, and join the thread.
+
+ Must finish before the interpreter starts finalizing: a daemon thread still writing to
+ stdout (dbus_next logging, tray `_dbg`) during shutdown crashes CPython with
+ `Fatal Python error: _enter_buffered_busy` / SIGABRT.
+ """
+ instance: Optional[NotificationBus] = cast(Optional["NotificationBus"], cls._singletons.get(cls))
+ if instance is None or cls.session_bus is None:
+ return
+ loop: asyncio.AbstractEventLoop = instance._event_loop
+ _ = loop.call_soon_threadsafe(callback=cls.session_bus.disconnect)
+ _ = loop.call_soon_threadsafe(callback=loop.stop)
+ instance.join(timeout=2.0)
+
+ @classmethod
+ def restart(cls) -> None:
+ """Drop the singleton and construct a new bus thread (used after a missing Notifications service)."""
+ cls.delete_singleton()
+ _ = cls()
+
+ def run_method(self, method_name: str, params: Optional[list[Any]] = None, wait_for_result: bool = True) -> Any:
+ """
+ Run method from dbus interface.
+
+ Params:
+ - `method_name`: Method name
+ - `params`: Params to pass to method. Defaults to `None`
+ - `wait_for_result`: Whether to return the result of `method_name` or an instance of `concurrent.futures.Future`.
+ Defaults to `True`
+ """
+
+ if self.notification_interface is None:
+ return
+
+ cached_method: Optional[Callable[..., Any]] = self._dbus_methods.get(method_name)
+ if cached_method is None:
+ snake_case_method_name: str = getattr(self.notification_interface, "_to_snake_case")(member=method_name)
+ method: Callable[..., Any] = getattr(self.notification_interface, f"call_{snake_case_method_name}")
+ self._dbus_methods[method_name] = method
+ else:
+ method = cached_method
+
+ async def run_method_() -> Any:
+ """Run method from dbus interface and wait for result"""
+ try:
+ result: Any = None
+ if params is None:
+ result = await method()
+ else:
+ result = await method(*params)
+ return result
+ except Exception as e:
+ _logger.error(msg=f"An exception occured running method '{method_name}' from dbus interface: {e}")
+ if f"The name {CONSTANTS.NOTIFICATIONS_BUS_NAME} was not provided by any .service files" in str(e):
+ if len(_visible_notifications):
+ _visible_notifications.clear()
+
+ future: Future[Any] = asyncio.run_coroutine_threadsafe(coro=run_method_(), loop=self._event_loop)
+ return future.result() if wait_for_result else future
+
+ def connect_to_signal(self, signal_name: str, callback: Callable[..., Any]) -> None:
+ """
+ Connect to signal.
+
+ Params:
+ - `signal_name`: Name of signal to connect
+ - `callback`: Callback to run
+ """
+ getattr(
+ self.notification_interface, f"on_{getattr(self.notification_interface, '_to_snake_case')(signal_name)}"
+ )(callback)
+
+ def get_server_capabilities(self) -> list[ServerCapabilities]:
+ """Wrapper for ` org.freedesktop.Notifications.GetCapabilities`"""
+ capabilities: Optional[list[str]] = self.run_method(method_name="GetCapabilities")
+ if capabilities is None or not len(capabilities):
+ return []
+ return [ServerCapabilities(cap) for cap in capabilities]
+
+ def notify(
+ self,
+ app_name: str,
+ replaces_id: int,
+ app_icon: str,
+ summary: str,
+ body: str,
+ actions: list[str],
+ hints: dict[str, Any],
+ expire_timeout: int,
+ ) -> int:
+ """Wrapper for `org.freedesktop.Notifications.Notify`"""
+ try:
+ return int(
+ self.run_method(
+ method_name="Notify",
+ params=[app_name, replaces_id, app_icon, summary, body, actions, hints, expire_timeout],
+ )
+ )
+ except:
+ return -1
+
+ def close(self, notification_id: int, wait: bool = False) -> None:
+ """Wrapper for `org.freedesktop.Notifications.CloseNotification`"""
+ self.run_method(method_name="CloseNotification", params=[notification_id], wait_for_result=wait)
+
+ def _on_notification_closed(self, notification_id: int, __reason__: int) -> None:
+ """Callback of signal `org.freedesktop.Notifications.NotificationClosed`"""
+ try:
+ notification: Notification = _visible_notifications[notification_id]
+ if notification.on_close is not None:
+ notification.on_close()
+ del _visible_notifications[notification_id]
+ except:
+ pass
+
+ def _on_action_invoked(self, notification_id: int, action_key: str) -> None:
+ """Callback of signal `org.freedesktop.Notifications.ActionInvoked`"""
+ try:
+ notification: Notification = _visible_notifications[notification_id]
+ for action in notification.actions:
+ if action.id == action_key:
+ if action.callback is not None:
+ action.callback()
+ break
+ except:
+ pass
+
+
+class Notification:
+ """Freedesktop desktop notification.
+
+ Call `show()` to send it. Duplicate summary/body/app_name replaces the visible one.
+ """
+
+ notification_bus: Optional[NotificationBus] = None
+
+ class Hints:
+ """Class representing hints for a notification."""
+
+ class Categories(str, Enum):
+ """
+ Categories
+
+ Notifications can optionally have a type indicator.
+ Although neither client or nor server must support this, some may choose to. Those servers implementing
+ categories may use them to intelligently display the notification in a certain way,
+ or group notifications of similar types.
+
+ - "device": A generic device-related notification that doesn't fit into any other category.
+ - "device.added": A device, such as a USB device, was added to the system.
+ - "device.error": A device had some kind of error.
+ - "device.removed": A device, such as a USB device, was removed from the system.
+ - "email": A generic e-mail-related notification that doesn't fit into any other category.
+ - "email.arrived": A new e-mail notification.
+ - "email.bounced": A notification stating that an e-mail has bounced.
+ - "im": A generic instant message-related notification that doesn't fit into any other category.
+ - "im.error": An instant message error notification.
+ - "im.received": A received instant message notification.
+ - "network": A generic network notification that doesn't fit into any other category.
+ - "network.connected": A network connection notification, such as successful sign-on to a network service.
+ This should not be confused with device.added for new network devices.
+ - "network.disconnected": A network disconnected notification.
+ This should not be confused with device.removed for disconnected network devices.
+ - "network.error": A network-related or connection-related error.
+ - "presence": A generic presence change notification that doesn't fit into any other category,
+ such as going away or idle.
+ - "presence.offline": An offline presence change notification.
+ - "presence.online": An online presence change notification.
+ - "transfer": A generic file transfer or download notification that doesn't fit into any other category.
+ - "transfer.complete": A file transfer or download complete notification.
+ - "transfer.error": A file transfer or download error.
+ """
+
+ # device
+ DEVICE = "device"
+ DEVICE_ADDED = "device.added"
+ DEVICE_ERROR = "device.error"
+ DEVICE_REMOVED = "device.removed"
+
+ # email
+ EMAIL = "email"
+ EMAIL_ARRIVED = "email.arrived"
+ EMAIL_BOUNCED = "email.bounced"
+
+ # im
+ IM = "im"
+ IM_ERROR = "im.error"
+ IM_RECEIVED = "im.received"
+
+ # network
+ NETWORK = "network"
+ NETWORK_CONNECTED = "network.connected"
+ NETWORK_DISCONNECTED = "network.disconnected"
+ NETWORK_ERROR = "network.error"
+
+ # presence
+ PRESENCE = "presence"
+ PRESENCE_OFFLINE = "presence.offline"
+ PRESENCE_ONLINE = "presence.online"
+
+ # transfer
+ TRANSFER = "transfer"
+ TRANSFER_COMPLETE = "transfer.complete"
+ TRANSFER_ERROR = "transfer.error"
+
+ class Urgency(int, Enum):
+ """
+ Urgency levels.
+
+ - 0: Low
+ - 1: Normal
+ - 2: Critical
+ """
+
+ LOW = 0
+ NORMAL = 1
+ CRITICAL = 2
+
+ def __init__(
+ self,
+ action_icons: Optional[bool] = None,
+ category: Optional[Categories] = None,
+ desktop_entry: Optional[str] = None,
+ resident: Optional[bool] = None,
+ sound_file: Optional[Union[str, Path]] = None,
+ sound_name: Optional[str] = None,
+ suppress_sound: Optional[bool] = None,
+ transient: Optional[bool] = None,
+ x: Optional[int] = None,
+ y: Optional[int] = None,
+ urgency: Optional[Urgency] = None,
+ ) -> None:
+ """
+ Params:
+ - `action-icons`: When set, a server that has the "action-icons" capability will attempt to interpret any
+ action identifier as a named icon. The icon name should be compliant with the
+ Freedesktop.org Icon Naming Specification.
+ - `category`: The type of notification this is. See enum `orcsome3.notify.Notification.Hints.Categories`.
+ - `desktop-entry`: This specifies the name of the desktop filename representing the calling program.
+ This should be the same as the prefix used for the application's .desktop file.
+ - `image-path`: Alternative way to define the notification image.
+ - `resident`: When set the server will not automatically remove the notification when an
+ action has been invoked. The notification will remain resident in the server until it
+ is explicitly removed by the user or by the sender.
+ This hint is likely only useful when the server has the "persistence" capability.
+ - `sound-file`: The path to a sound file to play when the notification pops up.
+ - `sound-name`: A themeable named sound from the freedesktop.org sound naming specification to play
+ when the notification pops up. Similar to icon-name, only for sounds.
+ An example would be "message-new-instant".
+ - `suppress-sound`: Causes the server to suppress playing any sounds, if it has that ability.
+ This is usually set when the client itself is going to play its own sound.
+ - `transient`: When set the server will treat the notification as transient and by-pass the server's
+ persistence capability, if it should exist.
+ - `x`: Specifies the X location on the screen that the notification should point to.
+ The `y` hint must also be specified.
+ - `y`: Specifies the Y location on the screen that the notification should point to.
+ The `x` hint must also be specified.
+ - `urgency`: Urgency Level. See enum `orcsome3.notify.Notification.Hints.Urgency`
+ """
+ self.action_icons: Optional[bool] = action_icons
+ self.category: Optional[Notification.Hints.Categories] = category
+ self.desktop_entry: Optional[str] = desktop_entry
+ self.resident: Optional[bool] = resident
+ self.sound_file: Optional[Union[str, Path]] = sound_file
+ self.sound_name: Optional[str] = sound_name
+ self.suppress_sound: Optional[bool] = suppress_sound
+ self.transient: Optional[bool] = transient
+ self.x: Optional[int] = x
+ self.y: Optional[int] = y
+ self.urgency: Optional[Notification.Hints.Urgency] = urgency
+
+ def get_dict(self) -> dict[str, Variant]:
+ """Hints as D-Bus variants (`action_icons` → `action-icons`, enums/paths unwrapped)."""
+ final_dict: dict[str, Variant] = {}
+ attrs: list[str] = [
+ "action_icons",
+ "category",
+ "desktop_entry",
+ "resident",
+ "sound_file",
+ "sound_name",
+ "suppress_sound",
+ "transient",
+ "x",
+ "y",
+ "urgency",
+ ]
+ for attr in attrs:
+ value: Any = getattr(self, attr)
+ if value is not None:
+ if isinstance(value, Enum):
+ value = value.value
+ elif isinstance(value, Path):
+ value = str(value)
+ final_dict[attr.replace("_", "-")] = Variant(signature=type(value).__name__[0], value=value)
+ return final_dict
+
+ class Action:
+ """Class representing a notification action"""
+
+ def __init__(self, visible_name: str, callback: Optional[Callable[[], None]]) -> None:
+ """
+ Create Action for a Notification
+
+ Params:
+ - `visible_name`: Localized string displayed to the user
+ - `callback`: Optional callback to run when action activated. Defaults to `None`
+ """
+ self.id: str = str(id(self))
+ self.visible_name: str = visible_name
+ self.callback: Optional[Callable[[], None]] = callback
+
+ def __init__(
+ self,
+ summary: str,
+ body: str,
+ app_name: Optional[str] = None,
+ replaces_id: int = 0,
+ app_icon: Optional[Union[str, Path]] = None,
+ actions: Optional[list[Action]] = None,
+ hints: Optional[Hints] = None,
+ expire_timeout: int = -1,
+ on_close: Optional[Callable[[], None]] = None,
+ ) -> None:
+ """
+ Create a Notification.
+
+ Params:
+ - `summary`: The summary text briefly describing the notification.
+ - `body`: The optional detailed body text. Can be empty.
+ The body markup is XML-based, and consists of a small subset of HTML along with a few additional tags.
+ Supported tags:
+ - ... Bold
+ - ... Italic
+ - ... Underline
+ - ... Hyperlink
+ -
Image\n
+ If the server doesn't have the "body-markup" capability then `body` should only contain text.
+ - `app_name`: The optional name of the application sending the notification.
+ Defaults to `orcsome3` if blank, empty or `None`.
+ - `replaces_id`: The optional notification ID that this notification replaces. The server must atomically replace
+ the given notification with this one. This allows clients to effectively modify the notification
+ while it's active. A value of value of `0` means that this notification won't replace any
+ existing notifications.
+ - `app_icon`: The optional program icon of the calling application.
+ - `actions`: List of actions. See class `orcsome3.notify.Notification.Action`.
+ - `hints`: Hints for the notification. See class `orcsome3.notify.Notification.Hints`.
+ - `expire_timeout`: The timeout time in milliseconds since the display of the notification at which the
+ notification should automatically close.
+ If `-1`, the notification's expiration time is dependent on the notification server's settings,
+ and may vary for the type of notification. If `0`, never expire.
+ - `on_close`: Optional function to run when closing the notification. Defaults to `None`.
+ """
+ self.id: int = -1
+ self.summary: str = summary.lstrip("-")
+ self.body: str = body
+ self.app_name: str = app_name if app_name is not None and len(app_name.strip()) else APPNAME
+ self.replaces_id: int = replaces_id
+
+ self._app_icon: Optional[Union[str, Path]] = app_icon
+ self._actions: list[Notification.Action] = actions if actions is not None else []
+ self._hints: Optional[Notification.Hints] = hints
+ self._expire_timeout: int = expire_timeout
+ self.on_close: Optional[Callable[[], None]] = on_close
+
+ @property
+ def actions(self) -> list[Notification.Action]:
+ """Buttons shown on the notification (id + visible name + optional callback)."""
+ return self._actions
+
+ @property
+ def app_icon(self) -> Union[str, Path]:
+ """Icon path or name passed to Notify; empty string if unset when showing."""
+ return cast(Union[str, Path], self._app_icon)
+
+ @property
+ def hints(self) -> Notification.Hints:
+ """Optional hints object; `show()` sends `{}` when this was not set."""
+ return cast(Notification.Hints, self._hints)
+
+ def __getattr__(self, __name: str) -> Any:
+ if __name == "notification_bus":
+ try:
+ notification_bus: Optional[NotificationBus] = Notification.__dict__[__name]
+ if notification_bus is None:
+ NotificationBus.restart()
+ self.notification_bus = NotificationBus()
+ except KeyError:
+ pass
+ return super().__getattribute__(__name)
+
+ def show(self) -> None:
+ """
+ Show notification.
+
+ If a visible notification already have the same `summary`, `body`, and `app_name` of `self` (the notification
+ that is requesting to show), then the visible notification gets closed and replaced with `self` (the new notification).
+ """
+ if not (self.notification_bus is not None and self.notification_bus.can_show_notifications):
+ return
+
+ props: list[str] = ["summary", "body", "app_name"]
+ for visible_notification in _visible_notifications.values():
+ if all(getattr(visible_notification, prop) == getattr(self, prop) for prop in props):
+ self.replaces_id = visible_notification.id
+ cb_close_visible_notification: Optional[Callable[[], None]] = visible_notification.on_close
+ visible_notification.on_close = None
+ self.notification_bus.close(notification_id=visible_notification.id, wait=True)
+ visible_notification.on_close = cb_close_visible_notification
+ break
+
+ param_app_icon: str = ""
+ if self._app_icon is not None:
+ if isinstance(self._app_icon, str):
+ self._app_icon = Path(self._app_icon)
+ if self._app_icon.is_file():
+ param_app_icon = str(self._app_icon)
+
+ param_actions: list[str] = []
+ for action in self._actions:
+ param_actions.append(action.id)
+ param_actions.append(action.visible_name)
+
+ self.id = self.notification_bus.notify(
+ app_name=self.app_name,
+ replaces_id=self.replaces_id if not (self.replaces_id == 0 and self.id != -1) else self.id,
+ app_icon=param_app_icon,
+ summary=self.summary,
+ body=self.body,
+ actions=param_actions,
+ hints=self.hints.get_dict() if self._hints is not None else {},
+ expire_timeout=self._expire_timeout,
+ )
+ if self.id != -1:
+ _visible_notifications[self.id] = self
+
+ def close(self) -> None:
+ """Ask the server to close this notification (`CloseNotification`)."""
+ if not (self.notification_bus is not None and self.notification_bus.can_show_notifications):
+ return
+ if self.id != -1:
+ self.notification_bus.close(notification_id=self.id)
diff --git a/orcsome3/orcsome/__init__.py b/orcsome3/orcsome/__init__.py
deleted file mode 100644
index 64acd69..0000000
--- a/orcsome3/orcsome/__init__.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from typing import Optional, cast
-
-from .wm import WM, ImmediateWM
-
-_wm: Optional[WM] = None
-
-
-def get_wm() -> WM:
- return cast(WM, _wm)
-
-
-def get_wm_immediate() -> ImmediateWM:
- return ImmediateWM()
-
-
-def update_wm(new_wm: WM) -> None:
- global _wm
- _wm = new_wm
diff --git a/orcsome3/orcsome/__main__.py b/orcsome3/orcsome/__main__.py
deleted file mode 100644
index 126e99c..0000000
--- a/orcsome3/orcsome/__main__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from orcsome3.orcsome.run import run
-
-run()
diff --git a/orcsome3/orcsome/actions.py b/orcsome3/orcsome/actions.py
deleted file mode 100644
index faeb656..0000000
--- a/orcsome3/orcsome/actions.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from typing import Optional
-
-from . import wm, wrappers
-
-
-class Actions(wm.Actions):
- def __init__(self, window_manager: wm.WM) -> None:
- self._wm: wm.WM = window_manager
-
- def _focus(self, window: wrappers.Window, direction: int) -> None:
- clients = self._wm.find_clients(clients=self._wm.get_clients(), desktop=window.desktop)
- idx = clients.index(window)
- newc = clients[(idx + direction) % len(clients)]
- self._wm.focus_and_raise(window=newc)
-
- def focus_next(self, window: Optional[wrappers.Window] = None) -> None:
- window_: Optional[wrappers.Window] = window
- if window is None:
- window_ = self._wm.current_window
- if window_ is not None:
- self._focus(window=window_, direction=1)
-
- def focus_prev(self, window: Optional[wrappers.Window] = None) -> None:
- window_: Optional[wrappers.Window] = window
- if window is None:
- window_ = self._wm.current_window
- if window_ is not None:
- self._focus(window=window_, direction=-1)
-
- def restart(self) -> None:
- raise wm.RestartException()
-
- def activate_window_desktop(self, window: wrappers.Window) -> Optional[bool]:
- wd = window.desktop
- if wd is not None:
- if self._wm.current_desktop != wd:
- self._wm.activate_desktop(num=wd)
- return True
- else:
- return False
- else:
- return None
diff --git a/orcsome3/orcsome/ev.py b/orcsome3/orcsome/ev.py
deleted file mode 100644
index 1faccb0..0000000
--- a/orcsome3/orcsome/ev.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import time
-from typing import Any, Callable, Optional
-
-try:
- from ._ev import ffi, lib # type: ignore
-except ModuleNotFoundError:
- from . import ev_build
-
- ev_build.main()
-finally:
- from ._ev import ffi, lib # type: ignore
-
-
-class Loop(object):
- def __init__(self) -> None:
- self._loop = lib.ev_loop_new(lib.EVBACKEND_SELECT)
-
- def destroy(self) -> None:
- lib.ev_loop_destroy(self._loop)
-
- def run(self, flags: int = 0) -> None:
- lib.ev_run(self._loop, flags)
-
- def break_(self, flags: int = lib.EVBREAK_ALL) -> None:
- lib.ev_break(self._loop, flags)
-
-
-class IOWatcher(object):
- def __init__(self, callback: Callable[..., Any], file_descriptor: int, flags: int) -> None:
- self._watcher = ffi.new("ev_io*")
- self._callback = ffi.callback("io_cb", callback)
- lib.ev_io_init(self._watcher, self._callback, file_descriptor, flags)
-
- def start(self, loop: Loop) -> None:
- lib.ev_io_start(loop._loop, self._watcher)
-
- def stop(self, loop: Loop) -> None:
- lib.ev_io_stop(loop._loop, self._watcher)
-
-
-class SignalWatcher(object):
- def __init__(self, callback: Callable[..., Any], signum: int) -> None:
- self._watcher = ffi.new("ev_signal*")
- self._callback = ffi.callback("signal_cb", callback)
- lib.ev_signal_init(self._watcher, self._callback, signum)
-
- def start(self, loop: Loop) -> None:
- lib.ev_signal_start(loop._loop, self._watcher)
-
- def stop(self, loop: Loop) -> None:
- lib.ev_signal_stop(loop._loop, self._watcher)
-
-
-class TimerWatcher(object):
- def __init__(self, callback: Callable[..., Any], after: float, repeat: float = 0.0) -> None:
- self._after: float = after
- self._repeat: float = repeat
- self._watcher = ffi.new("ev_timer*")
- self._cb = ffi.callback("timer_cb", callback)
- self.next_stop: float = 0
- lib.ev_timer_init(self._watcher, self._cb, after, repeat)
-
- def start(self, loop: Loop, after: Optional[float] = None, repeat: Optional[float] = None) -> None:
- if after or repeat:
- self._after = after or self._after
- self._repeat = repeat or self._repeat
- lib.ev_timer_set(self._watcher, self._after, self._repeat)
-
- self.next_stop = time.time() + self._after
- lib.ev_timer_start(loop._loop, self._watcher)
-
- def stop(self, loop: Loop) -> None:
- lib.ev_timer_stop(loop._loop, self._watcher)
-
- def again(self, loop: Loop) -> None:
- self.next_stop = time.time() + self._repeat
- lib.ev_timer_again(loop._loop, self._watcher)
-
- def remaining(self, loop: Loop) -> float:
- return float(lib.ev_timer_remaining(loop._loop, self._watcher))
-
- def update_next_stop(self) -> None:
- self.next_stop = time.time() + self._repeat
-
- def overdue(self, timeout: float) -> bool:
- return time.time() > self.next_stop + timeout
diff --git a/orcsome3/orcsome/ev_build.py b/orcsome3/orcsome/ev_build.py
deleted file mode 100644
index dec1632..0000000
--- a/orcsome3/orcsome/ev_build.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import cffi
-from cffi.api import FFI
-
-source: str = """
-#include
-"""
-
-export_source: str = """
-#define EVBACKEND_SELECT ...
-#define EV_READ ...
-#define EV_WRITE ...
-#define EVBREAK_ALL ...
-
-typedef ... ev_loop;
-
-struct ev_loop *ev_loop_new (unsigned int flags);
-void ev_loop_destroy (struct ev_loop*);
-void ev_break (struct ev_loop*, int);
-int ev_run (struct ev_loop*, int);
-
-typedef struct { ...; } ev_io;
-typedef void (*io_cb) (struct ev_loop*, ev_io*, int);
-void ev_io_init(ev_io*, io_cb, int, int);
-void ev_io_start(struct ev_loop*, ev_io*);
-void ev_io_stop(struct ev_loop*, ev_io*);
-
-typedef struct { ...; } ev_signal;
-typedef void (*signal_cb) (struct ev_loop*, ev_signal*, int);
-void ev_signal_init(ev_signal*, signal_cb, int);
-void ev_signal_start(struct ev_loop*, ev_signal*);
-void ev_signal_stop(struct ev_loop*, ev_signal*);
-
-typedef double ev_tstamp;
-typedef struct { ...; } ev_timer;
-typedef void (*timer_cb) (struct ev_loop*, ev_timer*, int);
-void ev_timer_init(ev_timer*, timer_cb, ev_tstamp, ev_tstamp);
-void ev_timer_set(ev_timer*, ev_tstamp, ev_tstamp);
-void ev_timer_start(struct ev_loop*, ev_timer*);
-void ev_timer_again(struct ev_loop*, ev_timer*);
-void ev_timer_stop(struct ev_loop*, ev_timer*);
-ev_tstamp ev_timer_remaining(struct ev_loop*, ev_timer*);
-"""
-
-ffibuilder: FFI = cffi.FFI()
-
-ffibuilder.set_source(module_name="orcsome3.orcsome._ev", source=source, libraries=["ev"])
-
-ffibuilder.cdef(csource=export_source, override=True)
-
-
-def main(verbose: bool = False) -> None:
- ffibuilder.compile(verbose=verbose)
-
-
-if __name__ == "__main__":
- main(verbose=True)
diff --git a/orcsome3/orcsome/notify.py b/orcsome3/orcsome/notify.py
deleted file mode 100644
index dfbe9c9..0000000
--- a/orcsome3/orcsome/notify.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from __future__ import annotations
-
-import subprocess
-from typing import List, Optional
-
-_default_appname = "orcsome3"
-
-
-def notify(summary: str, body: str, timeout: int = -1, urgency: int = 1, appname: Optional[str] = None) -> Notification:
- n: Notification = Notification(
- summary=summary, body=body, timeout=timeout, urgency=urgency, appname=appname or _default_appname
- )
- n.show()
- return n
-
-
-class Notification(object):
- def __init__(self, summary: str, body: str, timeout: int, urgency: int, appname: str) -> None:
- self.summary: str = summary.lstrip("-")
- self.body: str = body
- self.timeout: int = timeout
- self.urgency: int = urgency
- self.appname: str = appname
- self.replace_id: int = 0
- self.lastcmd: List[str] = []
-
- def show(self) -> None:
- timeout: int = self.timeout * 1000
- if timeout < 0:
- timeout = -1
-
- urgency: str = "{}"
- if self.urgency != 1:
- urgency = f"{'urgency': }"
-
- cmd: List[str] = [
- "gdbus",
- "call",
- "--session",
- "--dest=org.freedesktop.Notifications",
- "--object-path=/org/freedesktop/Notifications",
- "--method=org.freedesktop.Notifications.Notify",
- self.appname,
- str(self.replace_id),
- "",
- self.summary,
- self.body,
- "[]",
- urgency,
- str(timeout),
- ]
- self.lastcmd = cmd
-
- out, error = subprocess.Popen(args=cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()
-
- if error:
- raise Exception(error)
-
- self.replace_id = int(out.strip().split()[1].rstrip(b",)"))
-
- def update(
- self,
- summary: Optional[str] = None,
- body: Optional[str] = None,
- timeout: Optional[int] = None,
- urgency: Optional[int] = None,
- ) -> None:
- if summary is not None:
- self.summary = summary
-
- if body is not None:
- self.body = body
-
- if timeout is not None:
- self.timeout = timeout
-
- if urgency is not None:
- self.urgency = urgency
-
- self.show()
-
- def close(self) -> None:
- cmd: List[str] = [
- "gdbus",
- "call",
- "--session",
- "--dest=org.freedesktop.Notifications",
- "--object-path=/org/freedesktop/Notifications",
- "--method=org.freedesktop.Notifications.CloseNotification",
- str(self.replace_id),
- ]
-
- _, error = subprocess.Popen(args=cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()
- if error:
- raise Exception(error)
diff --git a/orcsome3/orcsome/run.py b/orcsome3/orcsome/run.py
deleted file mode 100644
index 3605542..0000000
--- a/orcsome3/orcsome/run.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import logging
-import os
-import signal
-import sys
-from argparse import ArgumentParser, Namespace
-from pathlib import Path
-from typing import Any, Dict, Optional, Union
-
-from ..version import VERSION
-from . import ev, update_wm
-from .wm import WM
-
-logger: logging.Logger = logging.getLogger(name=__name__)
-
-
-def execfile(filepath: Path, globales: Optional[Dict[str, Any]] = None) -> None:
- if globales is None:
- globales = globals()
- with filepath.open(mode="r") as fh:
- exec(fh.read() + "\n", globales)
-
-
-def load_config(wm: WM, config: Path) -> None:
- update_wm(new_wm=wm)
- sys.path.insert(0, str(config.parent))
- try:
- execfile(filepath=config)
- except:
- logger.exception(msg=f"Error on loading {config}")
- sys.exit(1)
- finally:
- sys.path.pop(0)
-
-
-"""def check_config(config: Path) -> bool:
- wm: TestWM = TestWM() # Mock class
- _update_wm(new_wm=wm)
- sys.path.insert(0, str(config.parent))
- try:
- execfile(filepath=config)
- except:
- logger.exception(msg=f"Config file check failed {config}")
- return False
- finally:
- sys.path.pop(0)
-
- return True"""
-
-
-def run() -> None:
- parser: ArgumentParser = ArgumentParser()
- parser.add_argument("--version", action="version", version="%(prog)s " + VERSION)
- parser.add_argument("-l", "--log", dest="log", metavar="FILE", help="Path to log file (log to stdout by default)")
- parser.add_argument("--log-level", metavar="LOGLEVEL", default="INFO", help="log level, default is INFO")
-
- config_dir: str = os.getenv(key="XDG_CONFIG_HOME", default=str(Path("~/.config").expanduser()))
- default_rcfile: str = str(Path(config_dir).joinpath("orcsome3", "rc.py"))
- parser.add_argument(
- "-c",
- "--config",
- dest="config",
- metavar="FILE",
- default=default_rcfile,
- help="Path to config file (%(default)s)",
- )
-
- args: Namespace = parser.parse_args()
- handler: Optional[Union[logging.FileHandler, logging.StreamHandler]] = None
- if args.log:
- handler = logging.FileHandler(filename=args.log)
- else:
- handler = logging.StreamHandler()
-
- root_logger: logging.Logger = logging.getLogger()
- root_logger.setLevel(level=args.log_level)
- handler.setFormatter(fmt=logging.Formatter(fmt="%(asctime)s %(name)s %(levelname)s: %(message)s"))
- root_logger.addHandler(hdlr=handler)
-
- if not Path(args.config).is_file():
- logger.info(msg="There is no config file available, exiting...")
- return
-
- loop: ev.Loop = ev.Loop()
- wm: WM = WM(loop=loop)
-
- def stop(loop_: Any, watcher: Any, events: int) -> None:
- wm.stop(is_exit=True)
- loop.break_()
-
- signal_watcher = ev.SignalWatcher(callback=stop, signum=signal.SIGINT)
- signal_watcher.start(loop=loop)
-
- def on_restart() -> None:
- wm.stop()
- logger.info(msg="Restarting...")
- load_config(wm=wm, config=Path(args.config))
- wm.init()
- logger.info(msg="Started successfully")
-
- wm._restart_handler = on_restart
-
- load_config(wm=wm, config=Path(args.config))
- wm.init()
- loop.run()
diff --git a/orcsome3/orcsome/utils.py b/orcsome3/orcsome/utils.py
deleted file mode 100644
index 9ad138f..0000000
--- a/orcsome3/orcsome/utils.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import re
-import subprocess
-from typing import Dict, List, Pattern, Tuple
-
-_re_cache: Dict[str, Pattern[str]] = {}
-
-
-def match_string(pattern: str, data: str) -> bool:
- if not pattern in _re_cache.keys():
- _re_cache[pattern] = re.compile(pattern=pattern)
- pattern_ = _re_cache[pattern]
-
- return bool(re.search(pattern=pattern_, string=data))
-
-
-def get_compiler_args(*libraries: str) -> Tuple[List[str], List[str]]:
- cmd_cflags: List[str] = ["pkg-config", "--cflags"]
- cmd_cflags.extend(libraries)
- process_cflags: subprocess.CompletedProcess = subprocess.run(
- args=cmd_cflags, capture_output=True, text=True, encoding="utf-8"
- )
- result_cflags: List[str] = [x for x in process_cflags.stdout.replace("\n", "").split(sep=" ") if len(x.strip())]
-
- cmd_libs: List[str] = ["pkg-config", "--libs"]
- cmd_libs.extend(libraries)
- process_libs: subprocess.CompletedProcess = subprocess.run(
- args=cmd_libs, capture_output=True, text=True, encoding="utf-8"
- )
- result_libs: List[str] = [
- x.replace("-l", "") for x in process_libs.stdout.replace("\n", "").split(sep=" ") if len(x.strip())
- ]
-
- return result_cflags, result_libs
diff --git a/orcsome3/orcsome/wm.py b/orcsome3/orcsome/wm.py
deleted file mode 100644
index c0475f0..0000000
--- a/orcsome3/orcsome/wm.py
+++ /dev/null
@@ -1,1054 +0,0 @@
-import logging
-from abc import ABC, abstractmethod
-from functools import wraps
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
-
-from . import ev, wrappers, xlib
-from .aliases import KEYS as KEY_ALIASES
-
-logger: logging.Logger = logging.getLogger(name=__name__)
-ignore_logger: bool = False
-
-MODIFICATORS: Dict[str, int] = {
- "Alt": xlib.MASKS.Mod1Mask.value,
- "Control": xlib.MASKS.ControlMask.value,
- "Ctrl": xlib.MASKS.ControlMask.value,
- "Shift": xlib.MASKS.ShiftMask.value,
- "Win": xlib.MASKS.Mod4Mask.value,
- "Mod": xlib.MASKS.Mod4Mask.value,
- "Hyper": xlib.MASKS.Mod4Mask.value,
- "Super": xlib.MASKS.Mod4Mask.value,
-}
-
-IGNORED_MOD_MASKS: Tuple[int, int, int, int] = (
- 0,
- int(xlib.MASKS.LockMask.value),
- int(xlib.MASKS.Mod2Mask.value),
- int(xlib.MASKS.LockMask.value | xlib.MASKS.Mod2Mask.value),
-)
-
-
-class RestartException(Exception):
- # Exception raised by method `wm.actions.restart()`
- pass
-
-
-class Actions(ABC):
- @abstractmethod
- def focus_next(self, window: Optional[wrappers.Window] = None) -> None:
- """Focus next client on current desktop.
-
- next/prev are defined by client creation time
- """
- pass
-
- @abstractmethod
- def focus_prev(self, window: Optional[wrappers.Window] = None) -> None:
- """Focus previous client on current desktop.
-
- next/prev are defined by client creation time
- """
- pass
-
- @abstractmethod
- def restart(self) -> None:
- """Restarts orcsome"""
- pass
-
- @abstractmethod
- def activate_window_desktop(self, window: wrappers.Window) -> Optional[bool]:
- """Activate a window's desktop
-
- Returns:
-
- * True if window is placed on different from current desktop (activates window's desktop)
- * False if window's desktop and current desktop are the same
- * None if window does not have a desktop property
- """
- pass
-
-
-class WM:
- """Core orcsome instance
-
- Can be get in any time as::
-
- from orcsome3.orcsome import get_wm
- from orcsome3.orcsome.wm import WM
-
- wm: WM = get_wm()
- """
-
- def __init__(self, loop: ev.Loop) -> None:
- self._handlers: Dict[int, Callable[[xlib.XEvent], None]] = {
- xlib.EVENTS.KeyPress.value: self._handle_keypress,
- xlib.EVENTS.KeyRelease.value: self._handle_keyrelease,
- xlib.EVENTS.CreateNotify.value: self._handle_create,
- xlib.EVENTS.DestroyNotify.value: self._handle_destroy,
- xlib.EVENTS.FocusIn.value: self._handle_focus,
- xlib.EVENTS.FocusOut.value: self._handle_focus,
- xlib.EVENTS.PropertyNotify.value: self._handle_property,
- }
- # This is filled every time a new event comes by the function `_xevent_cb`
- self._native_event: Any = xlib.ffi.new("XEvent *")
-
- # Event associated with the callback
- self._event: Optional[
- Union[xlib.XKeyEvent, xlib.XCreateWindowEvent, xlib.XDestroyWindowEvent, xlib.XPropertyEvent]
- ] = None
- self._event_window: Optional[wrappers.Window] = None
-
- # Handlers
- self._key_handlers: Dict[xlib.Window, Dict[Tuple[int, int], Callable[[], None]]] = {}
- self._property_handlers: Dict[xlib.Atom, Dict[Optional[xlib.Window], List[Callable[[], None]]]] = {}
- self._create_handlers: List[Callable[[], None]] = []
- self._destroy_handlers: Dict[Optional[xlib.Window], List[Callable[[], None]]] = {}
- self._init_handlers: List[Callable[[], None]] = []
- self._deinit_handlers: List[Callable[[], None]] = []
- self._timer_handlers: List[Callable[[], None]] = []
- self._restart_handler: Optional[Callable[[], None]] = None
-
- # History
- self.focus_history: List[xlib.Window] = []
-
- # Auxiliar vars to avoid callbacks being called twice
- self._recently_destroyed_window: Optional[xlib.Window] = None
- self._recently_mapped_window: Optional[xlib.Window] = None
-
- self.dpy: xlib.Display = xlib.lib.XOpenDisplay(xlib.ffi.NULL) # X11's Display
- if self.dpy == xlib.ffi.NULL:
- raise Exception("Can't open display")
-
- self.root: xlib.Window = xlib.lib.DefaultRootWindow(self.dpy) # Root window
- self.atom: xlib.AtomCache = xlib.AtomCache(dpy=self.dpy)
-
- self._loop: ev.Loop = loop
- self._xevent_watcher: ev.IOWatcher = ev.IOWatcher(
- callback=self._xevent_cb, file_descriptor=xlib.lib.ConnectionNumber(self.dpy), flags=ev.lib.EV_READ
- )
- self._xevent_watcher.start(loop=self._loop)
-
- self.track_kbd_layout: bool = False
- self._startup: bool = False
-
- from . import actions
-
- self.actions: Actions = actions.Actions(window_manager=self)
-
- def init(self) -> None:
- # Report all events within the root window
- xlib.lib.XSelectInput(self.dpy, self.root, xlib.lib.SubstructureNotifyMask)
-
- for handler in self._init_handlers:
- handler()
-
- self._startup = True
- for window in self.get_clients():
- self._process_create_window(window=window)
-
- xlib.lib.XSync(self.dpy, False)
- xlib.lib.XSetErrorHandler(xlib.lib.error_handler)
-
- # Initializes MagickWand
- xlib.lib.MagickWandGenesis()
-
- def stop(self, is_exit: bool = False) -> None:
- self._key_handlers.clear()
- self._property_handlers.clear()
- self._create_handlers[:] = []
- self._destroy_handlers.clear()
- self.focus_history[:] = []
-
- if not is_exit:
- xlib.lib.XUngrabKey(self.dpy, xlib.lib.AnyKey, xlib.lib.AnyModifier, self.root)
- for window in self.get_clients():
- xlib.lib.XUngrabKey(self.dpy, xlib.lib.AnyKey, xlib.lib.AnyModifier, window)
-
- for handler in self._timer_handlers:
- getattr(handler, "stop")
- self._timer_handlers[:] = []
-
- for handler in self._deinit_handlers:
- try:
- handler()
- except:
- logger.exception(msg="Shutdown error")
-
- self._init_handlers[:] = []
- self._deinit_handlers[:] = []
-
- # Ends MagickWand
- xlib.lib.MagickWandTerminus()
-
- def create_window(self, window_id: int) -> wrappers.Window:
- window = wrappers.Window(window_id)
- window.wm = self
- return window
-
- def get_keycode_from_string(self, key: str) -> Optional[int]:
- keysym: int = xlib.lib.XStringToKeysym(str.encode(KEY_ALIASES.get(key, key)))
- if keysym is xlib.lib.NoSymbol:
- return None
- return int(xlib.lib.XKeysymToKeycode(self.dpy, keysym))
-
- def parse_keydef(self, keydef: str) -> Optional[List[Tuple[int, int]]]:
- keys: List[str] = [r.strip() for r in "".join(keydef.split()).split()]
- result: List[Tuple[int, int]] = []
- for k in keys:
- parts = k.split(sep="+")
- mod, key = parts[:-1], parts[-1]
- modmask = 0
- for m in mod:
- try:
- modmask |= MODIFICATORS[m]
- except KeyError:
- return None
-
- code = self.get_keycode_from_string(key=key)
- if not code:
- return None
- result.append((code, modmask))
-
- return result
-
- def on_key(
- self, keydef: str, window: Optional[wrappers.Window] = None
- ) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Signal decorator to define hotkey
-
- Signature of decorated function should be::
-
- function_cb() -> None:
- # ... function's body
- pass
-
- Key defenition is a string in format ``[mod + ... +]keysym`` where ``mod`` is
- one of modificators [Alt, Shift, Control(Ctrl), Mod(Win)] and
- ``keysym`` is a key name.
-
- You can define global hotkeys as follows::
-
- @wm.on_key(keydef='Control + a')
- def test() -> None:
- print('hotkey Control + a pressed')
-
- wm.on_key(keydef='Control+b')(lambda: print('hotkey Control + b pressed'))
-
- Or keybinded to a specific window::
-
- @wm.on_create(cls='URxvt')
- def bind_urxvt_keys() -> None:
- # Custom key to close only urxvt windows
- wm.on_key(window=wm.event_window, keydef='Ctrl+d')(lambda: wm.close_window(window=wm.event_window))
- """
-
- def decorator(function: Callable[[], None]) -> Callable[[], None]:
- @wraps(function)
- def inner() -> Callable[[], None]:
- window_ = window or self.root
- code_mmask_list = self.parse_keydef(keydef=keydef)
- if not code_mmask_list or not len(code_mmask_list) == 1:
- logger.error(msg=f"Invalid key definition [{keydef}]")
- else:
- code, modmask = code_mmask_list[0]
- keys: List[Tuple[int, int]] = []
- for imask in IGNORED_MOD_MASKS:
- mask = modmask | imask
- xlib.lib.XGrabKey(
- self.dpy, code, mask, window_, False, xlib.lib.GrabModeAsync, xlib.lib.GrabModeAsync
- )
- self._key_handlers.setdefault(window_, {})[(mask, code)] = function
- keys.append((mask, code))
-
- def remove() -> None:
- for key in keys:
- del self._key_handlers[window_][key]
-
- setattr(function, "remove", remove)
- return function
-
- return inner()
-
- return decorator
-
- def on_create(self, **matchers: Any) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Signal decorator to handle window creation
-
- Signature of decorated function should be::
-
- function_cb() -> None:
- # ... function's body
- pass
-
- Can be used in two forms. Listen to any window creation::
-
- @wm.on_create()
- def debug() -> None:
- print(wm.event_window.get_wm_class())
-
- Or specific window::
-
- @wm.on_create(cls='Opera')
- def use_firefox_luke() -> None:
- wm.close_window(window=wm.event_window)
- subprocess.Popen(cmd=['firefox'])
-
- Also, orcsome calls on_create handlers on its startup.
- You can check ``wm.startup`` attribute to denote such event.
-
- See :meth:`orcsome3.orcsome.wrappers.Window.matches` for ``**matchers`` argument description.
- """
-
- def decorator(function: Callable[[], None]) -> Callable[[], None]:
- @wraps(function)
- def inner() -> Callable[[], None]:
- return self._on_create_manage(callback=function, ignore_startup=False, **matchers)
-
- return inner()
-
- return decorator
-
- def on_manage(self, **matchers: Any) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Signal decorator to handle window creation (ignoring orcsome's startup)
-
- Signature of decorated function should be::
-
- function_cb() -> None:
- # ... function's body
- pass
-
- Can be used in two forms. Listen to any window creation::
-
- @wm.on_manage()
- def debug() -> None:
- print(wm.event_window.get_wm_class())
-
- or can be written::
-
- wm.on_manage()(lambda: print(wm.event_window.get_wm_class()))
-
- Or specific window::
-
- @wm.on_manage(cls='Opera')
- def use_firefox_luke():
- wm.close_window(window=wm.event_window)
- subprocess.Popen(cmd=['firefox'])
-
- See :meth:`orcsome3.orcsome.wrappers.Window.matches` for ``**matchers`` argument description.
- """
-
- def decorator(function: Callable[[], None]) -> Callable[[], None]:
- @wraps(function)
- def inner() -> Callable[[], None]:
- return self._on_create_manage(callback=function, ignore_startup=True, **matchers)
-
- return inner()
-
- return decorator
-
- def _on_create_manage(
- self, callback: Callable[[], None], ignore_startup: bool, **matchers: Any
- ) -> Callable[[], None]:
- if matchers:
- old_function = callback
-
- def new_callback() -> None:
- if self.event_window.matches(**matchers):
- old_function()
-
- callback = new_callback
-
- if ignore_startup:
- old_old_function = callback
-
- def new_callback() -> None:
- if not self._startup:
- old_old_function()
-
- callback = new_callback
-
- self._create_handlers.append(callback)
- setattr(callback, "remove", lambda: self._create_handlers.remove(callback))
- return callback
-
- def on_destroy(
- self, window: Optional[wrappers.Window] = None
- ) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Signal decorator to handle window destroy
-
- Signature of decorated function should be::
-
- function_cb() -> None:
- # ... function's body
- pass
-
- It can be used to all windows::
-
- @wm.on_destroy()
- def cb_destroy_window() -> None:
- print(f'The window {wm.event_window} was destroyed')
-
- Or to a specific window::
-
- @wm.on_manage(name='easyeffects', cls='easyeffects')
- def on_create_easyeffects() -> None:
- @wm.on_destroy(window=wm.event_window)
- def on_destroy_easyeffects() -> None:
- print("easyeffect's window destroyed")
-
- `wm.event_window` only contains the id of the recently closed window,
- trying to access any attribute of the window will result in an error
- cause the function is executed when the window is clonsing/has closed.
- """
-
- def decorator(function: Callable[[], None]) -> Callable[[], None]:
- @wraps(function)
- def inner() -> Callable[[], None]:
- self._destroy_handlers.setdefault(window, []).append(function)
-
- def remove() -> None:
- self._destroy_handlers[window].remove(function)
-
- setattr(function, "remove", remove)
- return function
-
- return inner()
-
- return decorator
-
- def on_property_change(
- self, properties: List[str], window: Optional[wrappers.Window] = None
- ) -> Callable[[Callable[[], None]], Callable[[], None]]:
- """Signal decorator to handle window property change
-
- Signature of decorated function should be::
-
- function_cb() -> None:
- # ... function's body
- pass
-
- One can handle any window property change::
-
- @wm.on_property_change(properties=['_NET_WM_STATE']) # one or multiple properties can be handled
- def window_maximized_state_change() -> None:
- window: Window = wm.event_window
- if window.maximized_vert and window.maximized_horz:
- print('Look, ma! Window is maximized now!')
-
- And specific window::
-
- @wm.on_create()
- def switch_to_desktop() -> None:
- if not wm.startup:
- if wm.actions.activate_window_desktop(window=wm.event_window) is None:
- # Created window has no any attached desktop so wait for it
- @wm.on_property_change(window=wm.event_window, properties=['_NET_WM_DESKTOP'])
- def property_was_set() -> None:
- wm.actions.activate_window_desktop(window=wm.event_window)
- getattr('property_was_set', 'remove') # removes the callback
-
- """
-
- def decorator(function: Callable[[], None]) -> Callable[[], None]:
- @wraps(function)
- def inner() -> Callable[[], None]:
- for prop in properties:
- self._property_handlers.setdefault(self.atom[prop], {}).setdefault(window, []).append(function)
-
- def remove() -> None:
- for prop in properties:
- self._property_handlers[self.atom[prop]][window].remove(function)
-
- setattr(function, "remove", remove)
- return function
-
- return inner()
-
- return decorator
-
- def on_timer(
- self, timeout: float, start: bool = True, first_timeout: Optional[float] = None
- ) -> Callable[[Callable[[], None]], Callable[[], None]]:
- def decorator(function: Callable[[], None]) -> Callable[[], None]:
- @wraps(function)
- def inner() -> Callable[[], None]:
- def callback_of_timer(loop: Any, watcher: Any, events: int) -> None:
- timer.stop(loop=self._loop) if function() else timer.update_next_stop()
-
- self._timer_handlers.append(function)
- timer = ev.TimerWatcher(callback=callback_of_timer, after=first_timeout or timeout, repeat=timeout)
- setattr(function, "start", lambda: timer.start(loop=self._loop))
- setattr(function, "stop", lambda: timer.stop(loop=self._loop))
- setattr(function, "again", lambda: timer.again(loop=self._loop))
- setattr(function, "remaining", lambda: timer.remaining(loop=self._loop))
- setattr(function, "overdue", lambda timeout: timer.overdue(timeout=timeout))
-
- if start:
- getattr(function, "start")
-
- def remove() -> None:
- try:
- getattr(function, "stop")
- self._timer_handlers.remove(function)
- except:
- pass
-
- setattr(function, "remove", remove)
- return function
-
- return inner()
-
- return decorator
-
- def get_clients(self) -> List[wrappers.Window]:
- """Return wm client list"""
- result = xlib.get_window_property(
- display=self.dpy, window=self.root, property=self.atom["_NET_CLIENT_LIST"], type=self.atom["WINDOW"] or 0
- )
- return [] if not result else [self.create_window(window_id=x) for x in cast(List[int], result)]
-
- def get_stacked_clients(self) -> List[wrappers.Window]:
- """Return client list in stacked order.
-
- Most top window will be last in list. Can be useful to determine window visibility.
- """
- result = xlib.get_window_property(
- display=self.dpy,
- window=self.root,
- property=self.atom["_NET_CLIENT_LIST_STACKING"],
- type=self.atom["WINDOW"] or 0,
- )
- return [] if not result else [self.create_window(window_id=r) for r in cast(List[int], result)]
-
- @property
- def current_window(self) -> Optional[wrappers.Window]:
- """Returns currently active (with input focus) window"""
- result = xlib.get_window_property(
- display=self.dpy, window=self.root, property=self.atom["_NET_ACTIVE_WINDOW"], type=self.atom["WINDOW"]
- )
- return None if not result else self.create_window(window_id=cast(List[xlib.Window], result)[0])
-
- @property
- def current_desktop(self) -> int:
- """Return current desktop number
-
- The index of the current desktop. This is always an integer between 0 and _NET_NUMBER_OF_DESKTOPS - 1
- """
- result = xlib.get_window_property(
- display=self.dpy, window=self.root, property=self.atom["_NET_CURRENT_DESKTOP"]
- )
- return cast(List[int], result)[0]
-
- @property
- def wm_name(self) -> Optional[str]:
- """
- Returns the actual Window Manager name or None if there's no ICCCM2.0-compliant window manager running.\n
- By the EWMH spec, a compliant window manager will set the _NET_SUPPORTING_WM_CHECK property on the root window to a window ID.\n
- If the `_NET_SUPPORTING_WM_CHECK` property exists and contains the ID of an existing window, then a ICCCM2.0-compliant window manager is running.\n
- If the property exists but does not contain the ID of an existing window, then a ICCCM2.0-compliant window manager exited without proper cleanup.\n
- If the property does not exist, then no ICCCM2.0-compliant window manager is running.
- """
- result = xlib.get_window_property(
- display=self.dpy, window=self.root, property=self.atom["_NET_SUPPORTING_WM_CHECK"], type=self.atom["WINDOW"]
- )
- if not result:
- return None
- result = xlib.get_window_property(
- display=self.dpy,
- window=cast(List[int], result)[0],
- property=self.atom["_NET_WM_NAME"],
- type=self.atom["UTF8_STRING"],
- )
- return None if not result else cast(List[str], result)[0]
-
- @property
- def event_window(self) -> wrappers.Window:
- """Returns the window associated with the event"""
- return cast(wrappers.Window, self._event_window)
-
- @property
- def event(self) -> Union[xlib.XKeyEvent, xlib.XCreateWindowEvent, xlib.XDestroyWindowEvent, xlib.XPropertyEvent]:
- """Returns the event associated"""
- return cast(
- Union[xlib.XKeyEvent, xlib.XCreateWindowEvent, xlib.XDestroyWindowEvent, xlib.XPropertyEvent], self._event
- )
-
- def activate_desktop(self, num: int) -> None:
- """Activate desktop ``num``"""
- if num < 0:
- return
-
- self._send_event(window=self.root, mtype=self.atom["_NET_CURRENT_DESKTOP"], data=[num])
- self._flush()
-
- def _send_event(self, window: xlib.Window, mtype: xlib.Atom, data: List[int]) -> None:
- data = (data + ([0] * (5 - len(data))))[:5]
- ev = xlib.ffi.new(
- "XClientMessageEvent *",
- {
- "type": xlib.lib.ClientMessage,
- "window": window,
- "message_type": mtype,
- "format": 32,
- "data": {"l": data},
- },
- )
- xlib.lib.XSendEvent(
- self.dpy, self.root, False, xlib.lib.SubstructureRedirectMask, xlib.ffi.cast("XEvent *", ev)
- )
-
- def _flush(self) -> None:
- xlib.lib.XFlush(self.dpy)
-
- def find_clients(self, clients: List[wrappers.Window], **matchers: Any) -> List[wrappers.Window]:
- """Return matching clients list
-
- :param clients: window list returned by :meth:`get_clients` or :meth:`get_stacked_clients`.
- :param **matchers: keyword arguments defined in :meth:`orcsome3.orcsome.wrappers.Window.matches`
- """
- return [r for r in clients if r.matches(**matchers)]
-
- def find_client(self, clients: List[wrappers.Window], **matchers: Any) -> Optional[wrappers.Window]:
- """Return first matching client
-
- :param clients: window list returned by :meth:`get_clients` or :meth:`get_stacked_clients`.
- :param **matchers: keyword arguments defined in :meth:`orcsome3.orcsome.wrappers.Window.matches`
- """
- result = self.find_clients(clients, **matchers)
- try:
- return result[0]
- except IndexError:
- return None
-
- def _process_create_window(self, window: wrappers.Window) -> None:
- xlib.lib.XSelectInput(
- self.dpy, window, xlib.lib.StructureNotifyMask | xlib.lib.PropertyChangeMask | xlib.lib.FocusChangeMask
- )
- self._event_window = window
- for handler in self._create_handlers:
- handler()
-
- def _handle_keypress(self, event: xlib.XEvent) -> None:
- xkeyevent: xlib.XKeyEvent = xlib.XKeyEvent(event=event)
- logger.info(msg=f"Keypress {xkeyevent.state} {xkeyevent.keycode}")
- try:
- handler = self._key_handlers[xkeyevent.window][(xkeyevent.state, xkeyevent.keycode)]
- except KeyError:
- pass
- else:
- self._event = xkeyevent
- self._event_window = self.create_window(window_id=xkeyevent.window)
- handler()
-
- def _handle_keyrelease(self, event: xlib.XEvent) -> None:
- keyevent: xlib.XKeyEvent = xlib.XKeyEvent(event=event)
- logger.info(msg=f"KeyRelease {keyevent.state} {keyevent.keycode}")
-
- def _handle_create(self, event: xlib.XEvent) -> None:
- xcreatewindowevent: xlib.XCreateWindowEvent = xlib.XCreateWindowEvent(event=event)
- self._startup = False
- window: wrappers.Window = self.create_window(window_id=xcreatewindowevent.window)
- global ignore_logger
- ignore_logger = True
- if not xlib.get_window_attributes(display=self.dpy, window=window):
- ignore_logger = False
- return
- ignore_logger = False
- self._event = xcreatewindowevent
- self._process_create_window(window=window)
-
- def _handle_destroy(self, event: xlib.XEvent) -> None:
- xdestroywindowevent: xlib.XDestroyWindowEvent = xlib.XDestroyWindowEvent(event=event)
- if xdestroywindowevent.window == self._recently_destroyed_window:
- return
- self._recently_destroyed_window = xdestroywindowevent.window
-
- handlers: List[Callable[[], None]] = []
- if None in self._destroy_handlers.keys():
- handlers.extend(self._destroy_handlers[None])
- if xdestroywindowevent.window in self._destroy_handlers.keys():
- handlers.extend(self._destroy_handlers[xdestroywindowevent.window])
-
- self._event = xdestroywindowevent
- self._event_window = self.create_window(window_id=xdestroywindowevent.window)
- for handler in handlers:
- handler()
- self._clean_window_data(window=xdestroywindowevent.window)
-
- def _handle_property(self, event: xlib.XEvent) -> None:
- xpropertyevent: xlib.XPropertyEvent = xlib.XPropertyEvent(event=event)
- atom: xlib.Atom = xpropertyevent.atom
- if xpropertyevent.state.value == xlib.lib.PropertyNewValue and atom in self._property_handlers:
- wphandlers = self._property_handlers[atom]
- self._event = xpropertyevent
- self._event_window = self.create_window(window_id=xpropertyevent.window)
- if xpropertyevent.window in wphandlers:
- for handler in wphandlers[xpropertyevent.window]:
- handler()
-
- if None in wphandlers:
- for handler in wphandlers[None]:
- handler()
-
- def _handle_focus(self, event: xlib.XEvent) -> None:
- xfocuschangeevent: xlib.XFocusChangeEvent = xlib.XFocusChangeEvent(event=event)
- if xfocuschangeevent.type == xlib.lib.FocusIn:
- try:
- self.focus_history.remove(xfocuschangeevent.window)
- except ValueError:
- pass
-
- self.focus_history.append(xfocuschangeevent.window)
- if (
- xfocuschangeevent.mode.value in (xlib.lib.NotifyNormal, xlib.lib.NotifyWhileGrabbed)
- and self.track_kbd_layout
- ):
- prop = xlib.get_window_property(
- display=self.dpy, window=xfocuschangeevent.window, property=self.atom["_ORCSOME_KBD_GROUP"]
- )
- if prop:
- xlib.set_kbd_group(display=self.dpy, group=int(prop[0]))
- else:
- xlib.set_kbd_group(display=self.dpy, group=0)
- else:
- if (
- xfocuschangeevent.mode.value in (xlib.lib.NotifyNormal, xlib.lib.NotifyWhileGrabbed)
- and self.track_kbd_layout
- ):
- xlib.set_window_property(
- display=self.dpy,
- window=xfocuschangeevent.window,
- property=self.atom["_ORCSOME_KBD_GROUP"],
- type=self.atom["CARDINAL"],
- format=32,
- values=[xlib.get_kbd_group(display=self.dpy)],
- )
-
- def _xevent_cb(self, loop: Any, watcher: Any, events: int) -> None:
- event = self._native_event
- while True:
- pending_events: int = xlib.lib.XPending(self.dpy)
- if not pending_events:
- break
-
- while pending_events > 0:
- xlib.lib.XNextEvent(self.dpy, event)
- pending_events -= 1
-
- try:
- handler = self._handlers[event.type]
- except KeyError:
- continue
-
- try:
- handler(event)
- except RestartException:
- if self._restart_handler:
- self._restart_handler()
- return
- except Exception as e:
- logger.exception(msg=e)
-
- def _clean_window_data(self, window: xlib.Window) -> None:
- if window in self._key_handlers:
- del self._key_handlers[window]
-
- if window in self._destroy_handlers:
- del self._destroy_handlers[window]
-
- try:
- self.focus_history.remove(window)
- except ValueError:
- pass
-
- for atom, whandlers in list(self._property_handlers.items()):
- if window in whandlers:
- del whandlers[window]
-
- if not len(self._property_handlers[atom]):
- del self._property_handlers[atom]
-
- def focus_window(self, window: xlib.Window) -> None:
- """Activate window"""
- self._send_event(window=window, mtype=self.atom["_NET_ACTIVE_WINDOW"], data=[2, xlib.lib.CurrentTime])
- self._flush()
-
- def focus_and_raise(self, window: wrappers.Window) -> None:
- """Activate window's desktop, set input focus and raise it"""
- self.actions.activate_window_desktop(window=window)
- xlib.lib.XConfigureWindow(
- self.dpy, window, xlib.lib.CWStackMode, xlib.ffi.new("XWindowChanges *", {"stack_mode": xlib.lib.Above})
- )
- self.focus_window(window=window)
-
- def place_window_above(self, window: xlib.Window) -> None:
- """Float up window in wm stack"""
- xlib.lib.XConfigureWindow(
- self.dpy, window, xlib.lib.CWStackMode, xlib.ffi.new("XWindowChanges *", {"stack_mode": xlib.lib.Above})
- )
- self._flush()
-
- def place_window_below(self, window: xlib.Window) -> None:
- """Float down window in wm stack"""
- xlib.lib.XConfigureWindow(
- self.dpy, window, xlib.lib.CWStackMode, xlib.ffi.new("XWindowChanges *", {"stack_mode": xlib.lib.Below})
- )
- self._flush()
-
- def _change_window_hidden_state(self, window: xlib.Window, minimize: bool) -> None:
- params = int(minimize), self.atom["_NET_WM_STATE_HIDDEN"]
- self._send_event(window=window, mtype=self.atom["_NET_WM_STATE"], data=list(params))
- self._flush()
-
- def minimize_window(self, window: xlib.Window) -> None:
- """Minimize window"""
- self._change_window_hidden_state(window=window, minimize=True)
-
- def restore_window(self, window: xlib.Window) -> None:
- """Restore window"""
- self._change_window_hidden_state(window=window, minimize=False)
-
- def set_window_state(
- self,
- window: xlib.Window,
- taskbar: Optional[bool] = None,
- pager: Optional[bool] = None,
- decorate: Optional[bool] = None,
- otaskbar: Optional[bool] = None,
- vmax: Optional[bool] = None,
- hmax: Optional[bool] = None,
- ) -> None:
- """
- Set window state
-
- `taskbar`: Indicates if the window is going to be included on the taskbar (_NET_WM_STATE_SKIP_TASKBAR)
- `pager`: Indicates if the window is going to be included on the Pager (_NET_WM_STATE_SKIP_PAGER)
- `decorate`: Indicates if the window is going to have decorations
- `otaskbar`: Indicates if the window is going to be ignored by orcsome3
- `vmax`: Indicates if the window is going to be maximized vertically (_NET_WM_STATE_MAXIMIZED_VERT)
- `hmax`: Indicates if the window is going to be maximized horizontally (_NET_WM_STATE_MAXIMIZED_HORZ)
- """
- state_atom: xlib.Atom = self.atom["_NET_WM_STATE"]
- params: Optional[List[int]] = None
-
- if decorate is not None:
- if self.wm_name == "Openbox":
- params = [int(not decorate), self.atom["_OB_WM_STATE_UNDECORATED"]]
- self._send_event(window=window, mtype=state_atom, data=params)
- else:
- motif_hints = xlib.get_window_property(
- display=self.dpy, window=window, property=self.atom["_MOTIF_WM_HINTS"]
- )
- if motif_hints is not None and len(motif_hints) == 5:
- params = cast(List[int], motif_hints)
- params[2] = int(decorate)
- else:
- # "0x2, 0x0, 0x0, 0x0, 0x0" to undecorate and "0x2, 0x0, 0x1, 0x0, 0x0" to redecorate
- params = [2, 0, int(decorate), 0, 0]
- xlib.set_window_property(
- display=self.dpy,
- window=window,
- property=self.atom["_MOTIF_WM_HINTS"],
- type=self.atom["_MOTIF_WM_HINTS"],
- format=32,
- values=params,
- )
-
- if taskbar is not None:
- params = [int(not taskbar), self.atom["_NET_WM_STATE_SKIP_TASKBAR"]]
- self._send_event(window=window, mtype=state_atom, data=params)
-
- if vmax is not None and vmax == hmax:
- params = [int(vmax), self.atom["_NET_WM_STATE_MAXIMIZED_VERT"], self.atom["_NET_WM_STATE_MAXIMIZED_HORZ"]]
- self._send_event(window=window, mtype=state_atom, data=params)
-
- if otaskbar is not None:
- params = [] if otaskbar else [self.atom["_ORCSOME_SKIP_TASKBAR"]]
- xlib.set_window_property(
- display=self.dpy,
- window=window,
- property=self.atom["_ORCSOME_STATE"],
- type=self.atom["ATOM"],
- format=32,
- values=params,
- )
-
- if pager is not None:
- params = [int(not pager), self.atom["_NET_WM_STATE_SKIP_PAGER"]]
- self._send_event(window=window, mtype=state_atom, data=params)
-
- self._flush()
-
- def get_window_geometry(self, window: xlib.Window) -> Tuple[int, int, int, int]:
- """Get window geometry
-
- Returns window geometry without decorations"""
- root_ret = xlib.ffi.new("Window *")
- x = xlib.ffi.new("int *")
- y = xlib.ffi.new("int *")
- w = xlib.ffi.new("unsigned int *")
- h = xlib.ffi.new("unsigned int *")
- border_width = xlib.ffi.new("unsigned int *")
- depth = xlib.ffi.new("unsigned int *")
- xlib.lib.XGetGeometry(self.dpy, window, root_ret, x, y, w, h, border_width, depth)
- return x[0], y[0], w[0], h[0]
-
- def get_screen_size(self) -> Tuple[int, int]:
- """Get size of screen (root window)"""
- return self.get_window_geometry(window=self.root)[2:]
-
- def get_workarea(self, desktop: Optional[int] = None) -> List[int]:
- """Get workarea geometery
-
- :param desktop: Desktop for working area receiving. If None then current_desktop is used"""
- result = xlib.get_window_property(
- display=self.dpy, window=self.root, property=self.atom["_NET_WORKAREA"], type=self.atom["CARDINAL"]
- )
- if desktop is None:
- desktop = self.current_desktop
- if not desktop:
- return []
- return cast(List[int], result)[4 * desktop : 4 * desktop + 4]
-
- def moveresize_window(
- self,
- window: xlib.Window,
- x: Optional[int] = None,
- y: Optional[int] = None,
- w: Optional[int] = None,
- h: Optional[int] = None,
- ) -> None:
- """Change window geometry"""
- flags = 0
- flags |= 2 << 12
- if x is not None:
- flags |= 1 << 8
- if y is not None:
- flags |= 1 << 9
- if w is not None:
- flags |= 1 << 10
- if h is not None:
- flags |= 1 << 11
- # Workarea offsets
- o_x, o_y, _, _ = tuple(self.get_workarea())
- params = (flags, cast(int, x) + o_x, cast(int, y) + o_y, max(1, cast(int, w)), max(1, cast(int, h)))
- self._send_event(window=window, mtype=self.atom["_NET_MOVERESIZE_WINDOW"], data=list(params))
- self._flush()
-
- def moveresize_window2(self, window: wrappers.Window, left: int, top: int, right: int, bottom: int) -> None:
- """Change window geometry"""
- flags = 0x2F00
- # Workarea offsets
- dl, dt, dw, dh = tuple(self.get_workarea(desktop=window.desktop))
- params = (flags, left + dl, top + dt, max(1, dw - right - left), max(1, dh - bottom - top))
- self._send_event(window=window, mtype=self.atom["_NET_MOVERESIZE_WINDOW"], data=list(params))
- self._flush()
-
- def close_window(self, window: Optional[wrappers.Window] = None) -> None:
- """Send request to wm to close window"""
- window = window or self.current_window
- if not window:
- return
- self._send_event(window=window, mtype=self.atom["_NET_CLOSE_WINDOW"], data=[xlib.lib.CurrentTime])
- self._flush()
-
- def change_window_desktop(self, window: xlib.Window, desktop: int) -> None:
- """Move window to ``desktop``"""
- if desktop < 0:
- return
-
- self._send_event(window=window, mtype=self.atom["_NET_WM_DESKTOP"], data=[desktop])
- self._flush()
-
- def on_init(self, func: Callable[[], None]) -> Callable[[], None]:
- """
- Adds a function to the list of init handlers, every function on the list gets
- executed whenever orcsome3 is starting
- """
- self._init_handlers.append(func)
- return func
-
- def on_deinit(self, func: Callable[[], None]) -> Callable[[], None]:
- """
- Adds a function to the list of de-init handlers, every function on the list gets
- executed whenever orcsome3 is stopping
- """
- self._deinit_handlers.append(func)
- return func
-
- def get_screen_saver_info(self) -> Optional[wrappers.XScreenSaverInfo]:
- """
- This is a wrapper around `XScreenSaverQueryInfo`, returns information about the current state of the screen saver or `None` if there's no screensaver
- active.
- """
- xscreensaverinfo = xlib.get_screen_saver_info(display=self.dpy, drawable=self.root)
- if not xscreensaverinfo:
- return None
- return wrappers.XScreenSaverInfo(screensaverinfo=xscreensaverinfo)
-
- def reset_dpms(self) -> None:
- """
- Resets Display Power Management Signaling (DPMS)
- """
- power = xlib.ffi.new("unsigned short *")
- state = xlib.ffi.new("unsigned char *")
- if xlib.lib.DPMSInfo(self.dpy, power, state):
- if state[0]:
- xlib.lib.DPMSDisable(self.dpy)
- xlib.lib.DPMSEnable(self.dpy)
-
- def get_atom_name(self, atom: xlib.Atom) -> str:
- """
- Return the name associated with an atom
- """
- return xlib.get_atom_name(display=self.dpy, atom=atom)
-
- def _set_window_icon(self, window: xlib.Window, icon: str) -> None:
- xlib.lib.set_window_icon(self.dpy, window, xlib.ffi.new("char[]", icon.encode())) # char[] is char*
-
- def _get_window_tree(
- self, window: wrappers.Window
- ) -> Optional[Tuple[wrappers.Window, wrappers.Window, List[wrappers.Window], int]]:
- root_window_ = xlib.ffi.new("Window *")
- parent_window_ = xlib.ffi.new("Window *")
- children_windows_ = xlib.ffi.new("Window **")
- number_of_children_ = xlib.ffi.new("unsigned int *")
-
- result: int = xlib.lib.XQueryTree(
- self.dpy, window, root_window_, parent_window_, children_windows_, number_of_children_
- )
- if not result:
- return None
- children_windows: List[wrappers.Window] = []
- for i in range(0, number_of_children_[0], 1):
- window_: wrappers.Window = self.create_window(window_id=children_windows_[0][i])
- children_windows.append(window_)
- return (
- self.create_window(window_id=root_window_[0]),
- self.create_window(window_id=parent_window_[0]),
- children_windows,
- number_of_children_[0],
- )
-
-
-class ImmediateWM(WM):
- """
- ImmediateWM which allows to play with wm from repl.
- """
-
- def __init__(self) -> None:
- self.dpy: xlib.Display = xlib.lib.XOpenDisplay(xlib.ffi.NULL)
- if self.dpy == xlib.ffi.NULL:
- raise Exception("Can't open display")
-
- self.root: xlib.Window = xlib.lib.DefaultRootWindow(self.dpy)
- self.atom: xlib.AtomCache = xlib.AtomCache(dpy=self.dpy)
-
-
-@xlib.ffi.def_extern() # type: ignore
-def error_handler(_display: xlib.Display, error: xlib.XErrorEvent) -> int:
- err: xlib.XErrorEvent_ = xlib.XErrorEvent_(error=error)
- logger.error(
- msg=f"{err.get_message()} ({err.request_code}:{err.minor_code}) ({'0x%0.2X' % int(err.resourceid)}:{int(err.resourceid)})"
- ) if not ignore_logger else None
- return 0
diff --git a/orcsome3/orcsome/wrappers.py b/orcsome3/orcsome/wrappers.py
deleted file mode 100644
index 811a8a6..0000000
--- a/orcsome3/orcsome/wrappers.py
+++ /dev/null
@@ -1,320 +0,0 @@
-from __future__ import annotations
-
-from enum import Enum
-from functools import cached_property
-from pathlib import Path
-from typing import List, Optional, Tuple, Union, cast
-
-from . import utils, wm, xlib
-
-
-class WindowTree:
- def __init__(self, window: Window, root: Window, parent: Window, children: List[Window]) -> None:
- self.window: Window = window
- self.root: Window = root
- self.parent: Window = parent
- self.children: List[Window] = children
-
-
-class XWindowAttributes:
- class MapState(Enum):
- IsUnmapped = int(xlib.lib.IsUnmapped)
- IsUnviewable = int(xlib.lib.IsUnviewable)
- IsViewable = int(xlib.lib.IsViewable)
-
- def __init__(self, attributes: xlib.XWindowAttributes) -> None:
- self.x: int = attributes.x # location of window
- self.y: int = attributes.y # location of window
- self.width: int = attributes.width # width of window
- self.height: int = attributes.width # height of window
- self.border_width: int = attributes.border_width # border width of window
- self.depth: int = attributes.depth # depth of window
- self.root: xlib.Window = attributes.root # root of screen containing window
- self.override_redirect: bool = bool(attributes.override_redirect) # boolean value for override-redirect
- self.map_state: XWindowAttributes.MapState = self.MapState(attributes.map_state)
-
-
-class XScreenSaverInfo:
- class State(Enum):
- Off = int(xlib.lib.ScreenSaverOff)
- On = int(xlib.lib.ScreenSaverOn)
- Disabled = int(xlib.lib.ScreenSaverDisabled)
-
- class Kind(Enum):
- Blanked = int(xlib.lib.ScreenSaverBlanked)
- Internal = int(xlib.lib.ScreenSaverInternal)
- External = int(xlib.lib.ScreenSaverExternal)
-
- def __init__(self, screensaverinfo: xlib.ScreenSaverInfo) -> None:
- self.window: xlib.Window = screensaverinfo.window # screen saver window
- self.state: XScreenSaverInfo.State = self.State(screensaverinfo.state) # ScreenSaver{Off,On,Disabled}
- self.kind: XScreenSaverInfo.Kind = self.Kind(screensaverinfo.kind) # ScreenSaver{Blanked,Internal,External}
- self.til_or_since: int = screensaverinfo.til_or_since # time til or since screen saver (milliseconds)
- self.idle: int = screensaverinfo.idle # total time since last user input (milliseconds)
- self.event_mask: int = screensaverinfo.eventMask # currently selected events for this client
-
-
-class Window(int):
- wm: wm.WM
-
- @cached_property
- def desktop(self) -> Optional[int]:
- """Return window desktop.
-
- Result is:
-
- * number from 0 to desktop_count - 1
- * -1 if window placed on all desktops
- * None if window does not have desktop property
-
- """
- result = self.get_property(property="_NET_WM_DESKTOP", type="CARDINAL")
- if not result:
- return None
- desktop = cast(List[int], result)[0]
- if desktop == 0xFFFFFFF or desktop == 0xFFFFFFFFFFFFFFFF:
- desktop = -1
- return desktop
-
- @cached_property
- def role(self) -> Optional[str]:
- """Return WM_WINDOW_ROLE property"""
- result = self.get_property(property="WM_WINDOW_ROLE", type="STRING")
- if not result or not len(result):
- return None
- return str(result[0])
-
- @cached_property
- def cls(self) -> Optional[str]:
- """Return second part from WM_CLASS property"""
- name, _class = self.get_name_and_class()
- if name:
- setattr(self, "name", name)
- return _class if _class else None
-
- @cached_property
- def name(self) -> Optional[str]:
- """Return first part from WM_CLASS property"""
- name, _class = self.get_name_and_class()
- if _class:
- setattr(self, "cls", _class)
- return name if name else None
-
- @cached_property
- def title(self) -> Optional[str]:
- """Return _NET_WM_NAME property"""
- result = self.get_property(property="_NET_WM_NAME", type="UTF8_STRING")
- if not result:
- return None
- return cast(List[str], result)[0]
-
- def get_name_and_class(self) -> Tuple[Optional[str], Optional[str]]:
- """Return WM_CLASS property"""
- result = self.get_property(property="WM_CLASS", type="STRING", split=True)
- if not result or len(result) != 2:
- return None, None
- result = cast(List[str], result)
- return result[0], result[1]
-
- def matches(
- self,
- name: Optional[str] = None,
- cls: Optional[str] = None,
- role: Optional[str] = None,
- desktop: Optional[int] = None,
- title: Optional[str] = None,
- ) -> bool:
- """Check if window suits given matchers.
-
- Matchers keyword arguments are used in :meth:`on_create`,
- :func:`actions.spawn_or_raise`. :meth:`find_clients` and
- :meth:`find_client`.
-
- name
- window name (also referenced as `instance`).
- The first part of ``WM_CLASS`` property.
-
- cls
- window class. The second part of ``WM_CLASS`` property.
-
- role
- window role. Value of ``WM_WINDOW_ROLE`` property.
-
- desktop
- matches windows placed on specific desktop. Must be int.
-
- title
- window title.
-
- `name`, `cls`, `title` and `role` can be regular expressions.
-
- """
- if name and not utils.match_string(pattern=name, data=self.name or ""):
- return False
- if cls and not utils.match_string(pattern=cls, data=self.cls or ""):
- return False
- if role and not utils.match_string(pattern=role, data=self.role or ""):
- return False
- if title and not utils.match_string(pattern=title, data=self.title or ""):
- return False
- if desktop is not None and desktop != self.desktop:
- return False
-
- return True
-
- def get_property(
- self, property: str, type: Optional[str] = None, split: bool = False
- ) -> Optional[Union[List[int], List[str]]]:
- """
- This function is a wrapper for `XGetWindowProperty`. Returns a property for the window, the result can be `None` if no property was found, a `List[int]`or a `List[str]`.
- """
- return xlib.get_window_property(
- display=self.wm.dpy,
- window=self,
- property=self.wm.atom[property],
- type=self.wm.atom[type] if type else xlib.lib.AnyPropertyType,
- split=split,
- )
-
- def set_property(
- self, property: str, format: int, data: Union[List[int], List[str]], type: Optional[str] = None
- ) -> None:
- """
- This functions is a wrapper for `XChangeProperty`, it alters the property for the specified window.
-
- If the specified format is 8, the property data must be a char array. (List[str] on python)
-
- If the specified format is 16, the property data must be a short array. (List[int] on python)
-
- If the specified format is 32, the property data must be a long array. (List[int] on python)
- """
- xlib.set_window_property(
- display=self.wm.dpy,
- window=self,
- property=self.wm.atom[property],
- type=self.wm.atom[type] if type else xlib.lib.AnyPropertyType,
- format=format,
- values=data,
- )
-
- def get_windows_same_pid(self) -> List[Window]:
- """
- This function returns a List[Window] that has the same pid of `self`
- """
- windows_associated: List[Window] = []
- window_tree: Optional[Tuple[Window, Window, List[Window], int]] = self.wm._get_window_tree(
- window=self.wm.create_window(window_id=self.wm.root)
- )
- if not window_tree or not len(window_tree[2]) or not self.pid:
- return windows_associated
- for window in window_tree[2]:
- if window.pid == self.pid:
- windows_associated.append(window)
- return windows_associated
-
- def get_window_tree(self) -> Optional[WindowTree]:
- """
- This function is a wrapper for `XQueryTree`. Returns a WindowTree instance containing
- the window, the root window, the parent window and a list of children windows (empty if there's none)
- """
- window_tree: Optional[Tuple[Window, Window, List[Window], int]] = self.wm._get_window_tree(window=self)
- if not window_tree:
- return None
- return WindowTree(window=self, root=window_tree[0], parent=window_tree[1], children=window_tree[2])
-
- def set_window_icon(self, icon: Union[Path, str]) -> None:
- """
- This function sets the window's icon, the maximum icon size is 10Mb
- """
- if isinstance(icon, str):
- icon = Path(icon)
- if not icon.is_file():
- print("The path is not a valid file")
- return
- if icon.stat().st_size > 10000000:
- print("The maximum icon size is 10Mb")
- return
- self.wm._set_window_icon(window=self, icon=str(icon))
-
- @cached_property
- def attributes(self) -> XWindowAttributes:
- """
- This function is a wrapper for `XGetWindowAttributes`. Returns the current attributes for the window
- """
- attrs = xlib.get_window_attributes(display=self.wm.dpy, window=self)
- return XWindowAttributes(attributes=attrs)
-
- @cached_property
- def state(self) -> List[str]:
- """Return _NET_WM_STATE"""
- states = self.get_property(property="_NET_WM_STATE", type="ATOM")
- if not states:
- return []
- states = cast(List[xlib.Atom], states)
- return [self.wm.get_atom_name(atom=state) for state in states if self.wm.get_atom_name(atom=state)]
-
- @cached_property
- def maximized_vert(self) -> bool:
- return "_NET_WM_STATE_MAXIMIZED_VERT" in self.state
-
- @cached_property
- def maximized_horz(self) -> bool:
- return "_NET_WM_STATE_MAXIMIZED_HORZ" in self.state
-
- @cached_property
- def decorated(self) -> bool:
- """
- Returns False if the window is not decorated otherwise True
-
- A non-decorated window is considered when:
- - There's no window manager running
- - It has the attribute override-redirect
- - It has a 0 in the third bit on the property `_MOTIF_WM_HINTS`
- """
- decorated: bool = True
- try:
- if not self.wm.wm_name: # If the window manager is not running window can't be decorated
- decorated = False
- else:
- # If the window has the attribute override-redirect it can't be decorated
- if self.attributes.override_redirect:
- decorated = False
- # If the window manager running is Openbox then it checks if window has the state '_OB_WM_STATE_UNDECORATED'
- elif self.wm.wm_name == "Openbox":
- if "_OB_WM_STATE_UNDECORATED" in self.state:
- decorated = False
- # If the window manager is not Openbox then it looks for the third bit on the property '_MOTIF_WM_HINTS'
- # to determine the decorations
- else:
- """
- {
- int flags;
- int functions;
- int decorations;
- int input_mode;
- int status;
- } MotifWmHints;
- """
- motif_hints = self.get_property(property="_MOTIF_WM_HINTS", type="_MOTIF_WM_HINTS")
- # If the decorations bit in the _MOTIF_WM_HINTS (position 2) is 0 then we can assume
- # the window is not decorated
- if motif_hints is not None and cast(List[int], motif_hints)[2] == 0:
- decorated = False
- except:
- decorated = False
- return decorated
-
- @cached_property
- def urgent(self) -> bool:
- return "_NET_WM_STATE_DEMANDS_ATTENTION" in self.state
-
- @cached_property
- def fullscreen(self) -> bool:
- return "_NET_WM_STATE_FULLSCREEN" in self.state
-
- @cached_property
- def pid(self) -> Optional[int]:
- result = self.get_property(property="_NET_WM_PID", type="CARDINAL")
- if not result:
- return None
- return cast(List[int], result)[0]
diff --git a/orcsome3/orcsome/xlib.py b/orcsome3/orcsome/xlib.py
deleted file mode 100644
index cc23eda..0000000
--- a/orcsome3/orcsome/xlib.py
+++ /dev/null
@@ -1,311 +0,0 @@
-import math
-from array import array
-from enum import Enum
-from typing import Any, Dict, List, Optional, Union, cast
-
-try:
- from ._xlib import ffi, lib # type: ignore
-except ModuleNotFoundError:
- from . import xlib_build
-
- xlib_build.main()
-finally:
- from ._xlib import ffi, lib # type: ignore
-
-# Type Aliases
-Atom = int
-Window = int
-Display = Any
-XEvent = Any
-XErrorEvent = Any
-XWindowAttributes = Any
-ScreenSaverInfo = Any
-
-
-class MASKS(Enum):
- Mod1Mask = int(lib.Mod1Mask)
- ControlMask = int(lib.ControlMask)
- ShiftMask = int(lib.ShiftMask)
- Mod2Mask = int(lib.Mod2Mask)
- Mod4Mask = int(lib.Mod4Mask)
- LockMask = int(lib.LockMask)
-
-
-class EVENTS(Enum):
- KeyPress = int(lib.KeyPress)
- KeyRelease = int(lib.KeyRelease)
- CreateNotify = int(lib.CreateNotify)
- DestroyNotify = int(lib.DestroyNotify)
- FocusIn = int(lib.FocusIn)
- FocusOut = int(lib.FocusOut)
- PropertyNotify = int(lib.PropertyNotify)
- # Unimplemented
- MapNotify = int(lib.MapNotify)
- UnmapNotify = int(lib.UnmapNotify)
-
-
-class AtomCache(object):
- def __init__(self, dpy: Display):
- self.dpy: Display = dpy
- self._cache: Dict[str, Atom] = {}
-
- def __getitem__(self, name: str) -> Atom:
- try:
- return self._cache[name]
- except KeyError:
- pass
- finally:
- atom: Atom = lib.XInternAtom(self.dpy, str.encode(name), False)
- self._cache[name] = atom
- return atom
-
-
-class XEvent_:
- class Type(Enum):
- # Types for XKeyEvent
- KeyPress = int(lib.KeyPress)
- KeyRelease = int(lib.KeyRelease)
- # Types for XCreateWindowEvent
- CreateNotify = int(lib.CreateNotify)
- # Types for XDestroyWindowEvent
- DestroyNotify = int(lib.DestroyNotify)
- # Types for XPropertyEvent
- PropertyNotify = int(lib.PropertyNotify)
- # Types for XFocusChangeEvent
- FocusIn = int(lib.FocusIn)
- FocusOut = int(lib.FocusOut)
- # Types for XCirculateEvent
- CirculateNotify = int(lib.CirculateNotify)
- # Types for XConfigureEvent
- ConfigureNotify = int(lib.ConfigureNotify)
- # Types for XGravityEvent
- GravityNotify = int(lib.GravityNotify)
- # Types for XReparentEvent
- ReparentNotify = int(lib.ReparentNotify)
- # Types for XMapEvent
- MapNotify = int(lib.MapNotify)
- # Types for XUnmapEvent
- UnmapNotify = int(lib.UnmapNotify)
-
- def __init__(self, xevent: XEvent, specific_event: Any) -> None:
- self._xevent: XEvent = xevent
- self.type: XEvent_.Type = self.Type(specific_event.type)
-
-
-class XKeyEvent(XEvent_):
- def __init__(self, event: XEvent) -> None:
- self._xkeyevent: lib.XKeyEvent = event.xkey
- super().__init__(xevent=event, specific_event=self._xkeyevent)
- self.window: Window = self._xkeyevent.window # "event" window it is reported relative to
- self.root: Window = self._xkeyevent.root # root window that the event occurred on
- self.subwindow: Window = self._xkeyevent.subwindow # child window
- self.time: float = float(self._xkeyevent.time) # milliseconds
- self.x: int = self._xkeyevent.x # coordinates in event window
- self.y: int = self._xkeyevent.y # coordinates in event window
- self.x_root: int = self._xkeyevent.x_root # coordinates relative to root
- self.y_root: int = self._xkeyevent.y_root # coordinates relative to root
- self.state: int = self._xkeyevent.state # key or button mask
- self.keycode: int = self._xkeyevent.keycode # detail
- self.same_screen: bool = bool(self._xkeyevent.same_screen) # same screen flag
-
-
-class XCreateWindowEvent(XEvent_):
- def __init__(self, event: XEvent) -> None:
- self._xcreatewindowevent: lib.XCreateWindowEvent = event.xcreatewindow
- super().__init__(xevent=event, specific_event=self._xcreatewindowevent)
- self.parent: Window = self._xcreatewindowevent.parent # parent of the window
- self.window: Window = self._xcreatewindowevent.window # window id of window created
- self.x: int = self._xcreatewindowevent.x # window location
- self.y: int = self._xcreatewindowevent.y # window location
- self.width: int = self._xcreatewindowevent.width # size of window
- self.height: int = self._xcreatewindowevent.height # size of window
- self.border_width: int = self._xcreatewindowevent.border_width # border width
- self.override_redirect: bool = bool(self._xcreatewindowevent.override_redirect) # creation should be overriden
-
-
-class XDestroyWindowEvent(XEvent_):
- def __init__(self, event: XEvent) -> None:
- self._xdestroywindowevent: lib.XDestroyWindowEvent = event.xdestroywindow
- super().__init__(xevent=event, specific_event=self._xdestroywindowevent)
- self.parent: Window = self._xdestroywindowevent.event
- self.window: Window = self._xdestroywindowevent.window
-
-
-class XPropertyEvent(XEvent_):
- class State(Enum):
- PropertyNewValue = int(lib.PropertyNewValue)
- PropertyDelete = int(lib.PropertyDelete)
-
- def __init__(self, event: XEvent) -> None:
- self._xpropertyevent: lib.XPropertyEvent = event.xproperty
- super().__init__(xevent=event, specific_event=self._xpropertyevent)
- self.window: Window = self._xpropertyevent.window
- self.atom: Atom = self._xpropertyevent.atom
- self.time: float = float(self._xpropertyevent.time)
- self.state: XPropertyEvent.State = self.State(self._xpropertyevent.state)
-
-
-class XFocusChangeEvent(XEvent_):
- class Mode(Enum):
- NotifyNormal = int(lib.NotifyNormal)
- NotifyWhileGrabbed = int(lib.NotifyWhileGrabbed)
- NotifyGrab = int(lib.NotifyGrab)
- NotifyUngrab = int(lib.NotifyUngrab)
-
- class Detail(Enum):
- NotifyAncestor = int(lib.NotifyAncestor)
- NotifyVirtual = int(lib.NotifyVirtual)
- NotifyInferior = int(lib.NotifyInferior)
- NotifyNonlinear = int(lib.NotifyNonlinear)
- NotifyNonlinearVirtual = int(lib.NotifyNonlinearVirtual)
- NotifyPointer = int(lib.NotifyPointer)
- NotifyPointerRoot = int(lib.NotifyPointerRoot)
- NotifyDetailNone = int(lib.NotifyDetailNone)
-
- def __init__(self, event: XEvent) -> None:
- self._xfocuschangeevent: lib.XFocusChangeEvent = event.xfocus
- super().__init__(xevent=event, specific_event=self._xfocuschangeevent)
- self.window: Window = self._xfocuschangeevent.window
- self.mode: XFocusChangeEvent.Mode = self.Mode(self._xfocuschangeevent.mode)
- self.detail: XFocusChangeEvent.Detail = self.Detail(self._xfocuschangeevent.detail)
-
-
-class XErrorEvent_:
- def __init__(self, error: XErrorEvent) -> None:
- self.type: int = error.type
- self.display: Display = error.display # Display the event was read from
- self.resourceid: float = float(error.resourceid) # resource id
- self.serial: float = float(error.serial) # serial number of failed request
- self.error_code: str = str(error.error_code) # error code of failed request
- self.request_code: str = str(error.request_code) # Major op-code of failed request
- self.minor_code: str = str(error.minor_code) # Minor op-code of failed request
-
- def get_message(self, size: int = 100) -> str:
- msg = ffi.new(f"char[{size}]")
- lib.XGetErrorText(self.display, int(self.error_code), msg, size)
- pymsg: bytes = ffi.string(msg)
- return pymsg.decode()
-
-
-def get_window_property(
- display: Display, window: Window, property: Atom, type: Atom = 0, split: bool = False
-) -> Optional[Union[List[int], List[str]]]:
- # Default values
- # Specifies the offset in the specified property (in 32-bit quantities) where the data is to be retrieved.
- long_offset: int = 0
- long_length: int = 50 # Specifies the length in 32-bit multiples of the data to be retrieved.
- delete: bool = False # Specifies a Boolean value that determines whether the property is deleted.
-
- # Params to C function XGetWindowProperty
- type_return_ = ffi.new("Atom *")
- format_return_ = ffi.new("int *")
- nitems_return_ = ffi.new("unsigned long *")
- bytes_after_ = ffi.new("unsigned long *")
- data_ = ffi.new("unsigned char **")
-
- result: bytes = b""
-
- offset: int = long_offset
- length: int = long_length
- while True:
- lib.XGetWindowProperty(
- display,
- window,
- property,
- offset,
- length,
- delete,
- type,
- type_return_,
- format_return_,
- nitems_return_,
- bytes_after_,
- data_,
- )
- format: int = format_return_[0]
- bytes_after_return: int = bytes_after_[0]
- if format == 8: # array of usigned char
- result += ffi.buffer(data_[0], nitems_return_[0] * array("B").itemsize)
- elif format == 16: # array of unsigned short
- result += ffi.buffer(data_[0], nitems_return_[0] * array("H").itemsize)
- elif format == 32: # array of unsigned long
- result += ffi.buffer(data_[0], nitems_return_[0] * array("L").itemsize)
- elif not format:
- return None
- else:
- raise Exception(f"Unknown format: {format}")
- if bytes_after_return:
- lib.XFree(data_[0])
- offset = long_length
- length = math.ceil(bytes_after_return / 4 + 1)
- else:
- break
-
- format = format_return_[0]
- final_result: Optional[Union[List[int], List[str]]] = None
- if format == 8:
- final_result = [result.rstrip(b"\x00").decode()]
- if split:
- final_result = [x.decode() for x in result.split(b"\x00") if len(x.decode())]
- elif format == 16:
- final_result = array("H", result).tolist()
- elif format == 32:
- final_result = array("L", result).tolist()
- else:
- raise Exception(f"Unknown format: {format}")
-
- lib.XFree(data_[0])
- return final_result
-
-
-def get_window_attributes(display: Display, window: Window) -> Optional[XWindowAttributes]:
- data_ = ffi.new("XWindowAttributes *")
- status: int = lib.XGetWindowAttributes(display, window, data_)
- if not status:
- return None
- return data_[0]
-
-
-def get_screen_saver_info(display: Display, drawable: Window) -> Optional[ScreenSaverInfo]:
- data_ = ffi.new("XScreenSaverInfo *")
- status: int = lib.XScreenSaverQueryInfo(display, drawable, data_)
- if not status:
- return None
- return data_[0]
-
-
-def set_window_property(
- display: Display, window: Window, property: Atom, type: Atom, format: int, values: Union[List[int], List[str]]
-) -> None:
- data: Any = None
- if format == 8: # array of usigned char
- data = cast(List[str], values)
- elif format == 16: # array of unsigned short
- data = ffi.cast("unsigned char *", ffi.new("unsigned short[]", cast(List[int], values)))
- elif format == 32: # array of unsigned long
- data = ffi.cast("unsigned char *", ffi.new("unsigned long[]", cast(List[int], values)))
- else:
- raise Exception(f"Unknown format {format}")
-
- lib.XChangeProperty(display, window, property, type, format, lib.PropModeReplace, data, len(values))
-
-
-def get_kbd_group(display: Display) -> str:
- state = ffi.new("XkbStateRec *")
- lib.XkbGetState(display, lib.XkbUseCoreKbd, state)
- return str(state[0].group)
-
-
-def set_kbd_group(display: Display, group: int) -> None:
- lib.XkbLockGroup(display, lib.XkbUseCoreKbd, group)
- lib.XFlush(display)
-
-
-def get_atom_name(display: Display, atom: Atom) -> str:
- """
- Returns the name associated with an Atom if the Atom exists
- """
- atom_name = lib.XGetAtomName(display, atom)
- if not atom_name:
- return ""
- return bytes(ffi.string(atom_name)).decode()
diff --git a/orcsome3/orcsome/xlib_build.py b/orcsome3/orcsome/xlib_build.py
deleted file mode 100644
index d7cd232..0000000
--- a/orcsome3/orcsome/xlib_build.py
+++ /dev/null
@@ -1,603 +0,0 @@
-from typing import List, Tuple
-
-import cffi
-from cffi.api import FFI
-
-from orcsome3.orcsome import utils
-
-source: str = """
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#define gdMaxColors 256
-#define gdImageSX(im) ((im)->sx)
-#define gdImageSY(im) ((im)->sy)
-#define gdTrueColorGetBlue(c) ((c) & 0x0000FF)
-#define gdTrueColorGetGreen(c) (((c) & 0x00FF00) >> 8)
-#define gdTrueColorGetRed(c) (((c) & 0xFF0000) >> 16)
-#define gdTrueColorGetAlpha(c) (((c) & 0x7F000000) >> 24)
-#define gdImageBlue(im, c) ((im)->trueColor ? gdTrueColorGetBlue(c) : (im)->blue[(c)])
-#define gdImageGreen(im, c) ((im)->trueColor ? gdTrueColorGetGreen(c) : (im)->green[(c)])
-#define gdImageRed(im, c) ((im)->trueColor ? gdTrueColorGetRed(c) : (im)->red[(c)])
-#define gdImageAlpha(im, c) ((im)->trueColor ? gdTrueColorGetAlpha(c) : (im)->alpha[(c)])
-
-typedef enum {
- GD_DEFAULT = 0,
- GD_BELL,
- GD_BESSEL,
- GD_BILINEAR_FIXED,
- GD_BICUBIC,
- GD_BICUBIC_FIXED,
- GD_BLACKMAN,
- GD_BOX,
- GD_BSPLINE,
- GD_CATMULLROM,
- GD_GAUSSIAN,
- GD_GENERALIZED_CUBIC,
- GD_HERMITE,
- GD_HAMMING,
- GD_HANNING,
- GD_MITCHELL,
- GD_NEAREST_NEIGHBOUR,
- GD_POWER,
- GD_QUADRATIC,
- GD_SINC,
- GD_TRIANGLE,
- GD_WEIGHTED4,
- GD_LINEAR,
- GD_LANCZOS3,
- GD_LANCZOS8,
- GD_BLACKMAN_BESSEL,
- GD_BLACKMAN_SINC,
- GD_QUADRATIC_BSPLINE,
- GD_CUBIC_SPLINE,
- GD_COSINE,
- GD_WELSH,
- GD_METHOD_COUNT = 30
- } gdInterpolationMethod;
-
-typedef double (* interpolation_method )(double, double);
-
-typedef struct gdImageStruct {
- /* Palette-based image pixels */
- unsigned char **pixels;
- int sx;
- int sy;
- /* These are valid in palette images only. See also
- 'alpha', which appears later in the structure to
- preserve binary backwards compatibility */
- int colorsTotal;
- int red[gdMaxColors];
- int green[gdMaxColors];
- int blue[gdMaxColors];
- int open[gdMaxColors];
- /* For backwards compatibility, this is set to the
- first palette entry with 100% transparency,
- and is also set and reset by the
- gdImageColorTransparent function. Newer
- applications can allocate palette entries
- with any desired level of transparency; however,
- bear in mind that many viewers, notably
- many web browsers, fail to implement
- full alpha channel for PNG and provide
- support for full opacity or transparency only. */
- int transparent;
- int *polyInts;
- int polyAllocated;
- struct gdImageStruct *brush;
- struct gdImageStruct *tile;
- int brushColorMap[gdMaxColors];
- int tileColorMap[gdMaxColors];
- int styleLength;
- int stylePos;
- int *style;
- int interlace;
- /* New in 2.0: thickness of line. Initialized to 1. */
- int thick;
- /* New in 2.0: alpha channel for palettes. Note that only
- Macintosh Internet Explorer and (possibly) Netscape 6
- really support multiple levels of transparency in
- palettes, to my knowledge, as of 2/15/01. Most
- common browsers will display 100% opaque and
- 100% transparent correctly, and do something
- unpredictable and/or undesirable for levels
- in between. TBB */
- int alpha[gdMaxColors];
- /* Truecolor flag and pixels. New 2.0 fields appear here at the
- end to minimize breakage of existing object code. */
- int trueColor;
- int **tpixels;
- /* Should alpha channel be copied, or applied, each time a
- pixel is drawn? This applies to truecolor images only.
- No attempt is made to alpha-blend in palette images,
- even if semitransparent palette entries exist.
- To do that, build your image as a truecolor image,
- then quantize down to 8 bits. */
- int alphaBlendingFlag;
- /* Should the alpha channel of the image be saved? This affects
- PNG at the moment; other future formats may also
- have that capability. JPEG doesn't. */
- int saveAlphaFlag;
-
- /* There should NEVER BE ACCESSOR MACROS FOR ITEMS BELOW HERE, so this
- part of the structure can be safely changed in new releases. */
-
- /* 2.0.12: anti-aliased globals. 2.0.26: just a few vestiges after
- switching to the fast, memory-cheap implementation from PHP-gd. */
- int AA;
- int AA_color;
- int AA_dont_blend;
-
- /* 2.0.12: simple clipping rectangle. These values
- must be checked for safety when set; please use
- gdImageSetClip */
- int cx1;
- int cy1;
- int cx2;
- int cy2;
-
- /* 2.1.0: allows to specify resolution in dpi */
- unsigned int res_x;
- unsigned int res_y;
-
- /* Selects quantization method, see gdImageTrueColorToPaletteSetMethod() and gdPaletteQuantizationMethod enum. */
- int paletteQuantizationMethod;
- /* speed/quality trade-off. 1 = best quality, 10 = best speed. 0 = method-specific default.
- Applicable to GD_QUANT_LIQ and GD_QUANT_NEUQUANT. */
- int paletteQuantizationSpeed;
- /* Image will remain true-color if conversion to palette cannot achieve given quality.
- Value from 1 to 100, 1 = ugly, 100 = perfect. Applicable to GD_QUANT_LIQ.*/
- int paletteQuantizationMinQuality;
- /* Image will use minimum number of palette colors needed to achieve given quality. Must be higher than paletteQuantizationMinQuality
- Value from 1 to 100, 1 = ugly, 100 = perfect. Applicable to GD_QUANT_LIQ.*/
- int paletteQuantizationMaxQuality;
- gdInterpolationMethod interpolation_id;
- interpolation_method interpolation;
-} gdImage;
-
-typedef gdImage *gdImagePtr;
-
-/* We can't use the one defined in Xmd.h because that's an "unsigned int",
- * which comes out as a 32bit type always. We need this to be 64bit on 64bit
- * machines.
- */
-typedef unsigned long int CARD32_;
-
-gdImagePtr gdImageCreateFromPngPtr (int size, void* data);
-void gdImageDestroy(gdImagePtr im);
-int gdImageGetPixel(gdImagePtr im, int x, int y);
-
-void set_window_icon(Display *display, Window window, char *filepath) {
- gdImagePtr imagePtr = NULL; // Default value, if the image can't be loaded then the result is going to be NULL
- MagickWand *wand = NewMagickWand();
- PixelWand *pixelWand = NewPixelWand();
-
- PixelSetColor(pixelWand, "transparent"); // Color for the background
- // The background has to be assigned before loading the file image
- // to avoid white backgrounds (happens with svg files)
- MagickSetBackgroundColor(wand, pixelWand);
-
- int result = 0;
- result = MagickReadImage(wand, filepath); // Reads the file
- if (!result) {
- return;
- }
- result = MagickSetImageFormat(wand, "png"); // Converts the wand (image) to PNG format
- if (!result) {
- return;
- }
- // If the conversion is successful then it creates an imagePtr from the content of the wand
- size_t length;
- unsigned char *info = MagickGetImageBlob(wand, &length); // Gets image data and size from the wand
- imagePtr = gdImageCreateFromPngPtr((int) length, info); // Creates imagePtr from png data
- if (imagePtr == NULL) {
- return;
- }
-
- DestroyMagickWand(wand);
- DestroyPixelWand(pixelWand);
-
- int width, height;
- width = gdImageSX(imagePtr);
- height = gdImageSY(imagePtr);
-
- unsigned int ndata = (width * height) + 2;
- CARD32_* data = (CARD32_*) calloc(ndata, sizeof(CARD32_));
-
- int i = 0;
- data[i++] = width;
- data[i++] = height;
-
- int x, y;
- for(y = 0; y < height; y++) {
- for(x = 0; x < width; x++) {
- // data is RGBA
- // We'll do some horrible data-munging here
- unsigned char* cols = (unsigned char*) &(data[i++]);
- int pixcolour = gdImageGetPixel(imagePtr, x, y);
-
- cols[0] = gdImageBlue(imagePtr, pixcolour);
- cols[1] = gdImageGreen(imagePtr, pixcolour);
- cols[2] = gdImageRed(imagePtr, pixcolour);
-
- /* Alpha is more difficult */
- int alpha = 127 - gdImageAlpha(imagePtr, pixcolour); // 0 to 127
- // Scale it up to 0 to 255; remembering that 2*127 should be max
- cols[3] = alpha == 127 ? 255 : (alpha *= 2);
- }
- }
- gdImageDestroy(imagePtr);
-
- Atom property = XInternAtom(display, "_NET_WM_ICON", 0);
- Atom type = XInternAtom(display, "CARDINAL", 0);
- result = XChangeProperty(display, window, property, type, 32, PropModeReplace, (unsigned char*) data, (int) ndata);
- if (result) {
- XFlush(display);
- }
- XFree(data);
-}
-"""
-
-export_source: str = """
-static const long StructureNotifyMask;
-static const long SubstructureNotifyMask;
-static const long SubstructureRedirectMask;
-static const long PropertyChangeMask;
-static const long FocusChangeMask;
-static const long KeyPressMask;
-static const long KeyReleaseMask;
-
-static const long CurrentTime;
-
-static const int KeyPress;
-static const int KeyRelease;
-static const int CreateNotify;
-static const int DestroyNotify;
-static const int FocusIn;
-static const int FocusOut;
-static const int PropertyNotify;
-static const int ClientMessage;
-
-static const int CWX;
-static const int CWY;
-static const int CWWidth;
-static const int CWHeight;
-static const int CWBorderWidth;
-static const int CWSibling;
-static const int CWStackMode;
-
-static const int Above;
-static const int Below;
-
-static const int ShiftMask;
-static const int LockMask;
-static const int ControlMask;
-static const int Mod1Mask;
-static const int Mod2Mask;
-static const int Mod3Mask;
-static const int Mod4Mask;
-static const int Mod5Mask;
-static const int AnyKey;
-static const int AnyModifier;
-
-static const long NoSymbol;
-
-static const int GrabModeSync;
-static const int GrabModeAsync;
-
-static const int PropModeReplace;
-static const int PropModePrepend;
-static const int PropModeAppend;
-
-static const int XkbUseCoreKbd;
-
-static const int ScreenSaverOff;
-static const int ScreenSaverOn;
-static const int ScreenSaverDisabled;
-static const int ScreenSaverBlanked;
-static const int ScreenSaverInternal;
-static const int ScreenSaverExternal;
-
-static const int IsUnmapped;
-static const int IsUnviewable;
-static const int IsViewable;
-
-static const int PropertyNewValue;
-static const int PropertyDelete;
-
-static const int NotifyNormal;
-static const int NotifyWhileGrabbed;
-static const int NotifyGrab;
-static const int NotifyUngrab;
-
-static const int NotifyAncestor;
-static const int NotifyVirtual;
-static const int NotifyInferior;
-static const int NotifyNonlinear;
-static const int NotifyNonlinearVirtual;
-static const int NotifyPointer;
-static const int NotifyPointerRoot;
-static const int NotifyDetailNone;
-
-static const int AnyPropertyType;
-
-// Events related with SubstructureRedirectMask
-static const int CirculateNotify;
-static const int ConfigureNotify;
-static const int GravityNotify;
-static const int MapNotify;
-static const int ReparentNotify;
-static const int UnmapNotify;
-
-typedef int Bool;
-typedef int Status;
-typedef unsigned long XID;
-typedef unsigned long Time;
-typedef unsigned long Atom;
-typedef XID Window;
-typedef XID Drawable;
-typedef XID KeySym;
-typedef XID Cursor;
-typedef unsigned char KeyCode;
-typedef ... Display;
-typedef ... Visual;
-typedef XID Colormap;
-typedef ... Screen;
-
-typedef struct {
- int type;
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window window; /* window on which event was requested in event mask */
-} XAnyEvent;
-
-typedef struct {
- int type;
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window window;
- Atom message_type;
- int format;
- union {
- char b[20];
- short s[10];
- long l[5];
- } data;
-} XClientMessageEvent;
-
-typedef struct {
- int type; /* of event */
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window window; /* "event" window it is reported relative to */
- Window root; /* root window that the event occurred on */
- Window subwindow; /* child window */
- Time time; /* milliseconds */
- int x, y; /* pointer x, y coordinates in event window */
- int x_root, y_root; /* coordinates relative to root */
- unsigned int state; /* key or button mask */
- unsigned int keycode; /* detail */
- Bool same_screen; /* same screen flag */
-} XKeyEvent;
-
-typedef struct {
- int type;
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window parent; /* parent of the window */
- Window window; /* window id of window created */
- int x, y; /* window location */
- int width, height; /* size of window */
- int border_width; /* border width */
- Bool override_redirect; /* creation should be overridden */
-} XCreateWindowEvent;
-
-typedef struct {
- int type;
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window event;
- Window window;
-} XDestroyWindowEvent;
-
-typedef struct {
- int type; /* FocusIn or FocusOut */
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window window; /* window of event */
- int mode; /* NotifyNormal, NotifyWhileGrabbed, NotifyGrab, NotifyUngrab */
- int detail; /* NotifyAncestor, NotifyVirtual, NotifyInferior,
- * NotifyNonlinear,NotifyNonlinearVirtual, NotifyPointer,
- * NotifyPointerRoot, NotifyDetailNone
- */
-} XFocusChangeEvent;
-
-typedef struct {
- int type;
- unsigned long serial; /* # of last request processed by server */
- Bool send_event; /* true if this came from a SendEvent request */
- Display *display; /* Display the event was read from */
- Window window;
- Atom atom;
- Time time;
- int state; /* NewValue, Deleted */
-} XPropertyEvent;
-
-typedef struct {
- int type;
- Display *display; /* Display the event was read from */
- XID resourceid; /* resource id */
- unsigned long serial; /* serial number of failed request */
- unsigned char error_code; /* error code of failed request */
- unsigned char request_code; /* Major op-code of failed request */
- unsigned char minor_code; /* Minor op-code of failed request */
-} XErrorEvent;
-
-typedef int (*XErrorHandler) (Display* display, XErrorEvent* event);
-int XGetErrorText(Display *display, int code, char *buffer_return, int length);
-
-typedef union {
- int type;
- XAnyEvent xany;
- XErrorEvent xerror;
- XKeyEvent xkey;
- XCreateWindowEvent xcreatewindow;
- XDestroyWindowEvent xdestroywindow;
- XFocusChangeEvent xfocus;
- XPropertyEvent xproperty;
- ...;
-} XEvent;
-
-typedef struct {
- int x, y;
- int width, height;
- int border_width;
- Window sibling;
- int stack_mode;
-} XWindowChanges;
-
-typedef struct {
- Window window; /* screen saver window */
- int state; /* ScreenSaver{Off,On,Disabled} */
- int kind; /* ScreenSaver{Blanked,Internal,External} */
- unsigned long til_or_since; /* milliseconds */
- unsigned long idle; /* milliseconds */
- unsigned long eventMask; /* events */
-} XScreenSaverInfo;
-
-
-XErrorHandler XSetErrorHandler (XErrorHandler handler);
-
-Display* XOpenDisplay(char *display_name);
-int XCloseDisplay(Display *display);
-int XFree(void *data);
-Atom XInternAtom(Display *display, char *atom_name, Bool only_if_exists);
-char* XGetAtomName(Display *display, Atom atom);
-
-int XPending(Display *display);
-int XNextEvent(Display *display, XEvent *event_return);
-int XSelectInput(Display *display, Window w, long event_mask);
-int XFlush(Display *display);
-int XSync(Display *display, Bool discard);
-Status XSendEvent(Display *display, Window w, Bool propagate, long event_mask, XEvent *event_send);
-
-KeySym XStringToKeysym(char *string);
-KeyCode XKeysymToKeycode(Display *display, KeySym keysym);
-
-int XGrabKey(Display *display, int keycode, unsigned int modifiers,
- Window grab_window, Bool owner_events, int pointer_mode, int keyboard_mode);
-int XUngrabKey(Display *display, int keycode, unsigned int modifiers, Window grab_window);
-
-int XGetWindowProperty(Display *display, Window w, Atom property,
- long long_offset, long long_length, Bool delete, Atom req_type,
- Atom *actual_type_return, int *actual_format_return,
- unsigned long *nitems_return, unsigned long *bytes_after_return,
- unsigned char **prop_return);
-int XChangeProperty(Display *display, Window w, Atom property, Atom type,
- int format, int mode, unsigned char *data, int nelements);
-int XDeleteProperty(Display *display, Window w, Atom property);
-int XConfigureWindow(Display *display, Window w, unsigned int value_mask,
- XWindowChanges *changes);
-
-Status XGetGeometry(Display *display, Drawable d, Window *root_return,
- int *x_return, int *y_return, unsigned int *width_return,
- unsigned int *height_return, unsigned int *border_width_return, unsigned int *depth_return);
-
-Status XScreenSaverQueryInfo(Display *dpy, Drawable drawable, XScreenSaverInfo *saver_info);
-
-Status DPMSInfo (Display *display, unsigned short *power_level, unsigned char *state);
-Status DPMSEnable (Display *display);
-Status DPMSDisable (Display *display);
-
-Window DefaultRootWindow(Display *display);
-int ConnectionNumber(Display *display);
-
-typedef struct {
- unsigned char group;
- unsigned char locked_group;
- unsigned short base_group;
- unsigned short latched_group;
- unsigned char mods;
- unsigned char base_mods;
- unsigned char latched_mods;
- unsigned char locked_mods;
- unsigned char compat_state;
- unsigned char grab_mods;
- unsigned char compat_grab_mods;
- unsigned char lookup_mods;
- unsigned char compat_lookup_mods;
- unsigned short ptr_buttons;
-} XkbStateRec;
-
-Status XkbGetState (Display *display, unsigned int device_spec, XkbStateRec *state_return);
-Bool XkbLockGroup (Display *display, unsigned int device_spec, unsigned int group);
-
-typedef struct {
- int x, y; /* location of window */
- int width, height; /* width and height of window */
- int border_width; /* border width of window */
- int depth; /* depth of window */
- Visual *visual; /* the associated visual structure */
- Window root; /* root of screen containing window */
- int class; /* InputOutput, InputOnly*/
- int bit_gravity; /* one of the bit gravity values */
- int win_gravity; /* one of the window gravity values */
- int backing_store; /* NotUseful, WhenMapped, Always */
- unsigned long backing_planes; /* planes to be preserved if possible */
- unsigned long backing_pixel; /* value to be used when restoring planes */
- Bool save_under; /* boolean, should bits under be saved? */
- Colormap colormap; /* color map to be associated with window */
- Bool map_installed; /* boolean, is color map currently installed*/
- int map_state; /* IsUnmapped, IsUnviewable, IsViewable */
- long all_event_masks; /* set of events all people have interest in*/
- long your_event_mask; /* my event mask */
- long do_not_propagate_mask; /* set of events that should not propagate */
- Bool override_redirect; /* boolean value for override-redirect */
- Screen *screen; /* back pointer to correct screen */
-} XWindowAttributes;
-
-Status XGetWindowAttributes(Display *display, Window w, XWindowAttributes *window_attributes_return);
-
-void set_window_icon(Display *display, Window window, char *filepath);
-void MagickWandGenesis(void);
-void MagickWandTerminus(void);
-extern "Python" int error_handler(Display* display, XErrorEvent* event);
-Status XQueryTree(
- Display *display,
- Window window,
- Window *root_return,
- Window *parent_return,
- Window **children_return,
- unsigned int *nchildren_return
-);
-"""
-
-LIBRARIES: List[str] = ["x11", "xscrnsaver", "xext", "MagickWand", "gdlib"]
-compiler_args: Tuple[List[str], List[str]] = utils.get_compiler_args(*LIBRARIES)
-
-ffibuilder: FFI = cffi.FFI()
-
-ffibuilder.set_source(
- module_name="orcsome3.orcsome._xlib", source=source, extra_compile_args=compiler_args[0], libraries=compiler_args[1]
-)
-
-ffibuilder.cdef(csource=export_source, override=True)
-
-
-def main(verbose: bool = False) -> None:
- ffibuilder.compile(verbose=verbose)
-
-
-if __name__ == "__main__":
- main(verbose=True)
diff --git a/orcsome3/py.typed b/orcsome3/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/orcsome3/py.typed
@@ -0,0 +1 @@
+
diff --git a/orcsome3/run.py b/orcsome3/run.py
new file mode 100644
index 0000000..065311b
--- /dev/null
+++ b/orcsome3/run.py
@@ -0,0 +1,257 @@
+"""CLI entrypoint and the process-wide WindowManager singleton.
+
+`get_wm()` is the public way to reach the running manager from your config (`config.py`).
+`run()` parses argv, loads the config, and starts the libev loop.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from argparse import ArgumentParser, Namespace
+from pathlib import Path
+from signal import Signals
+from typing import Callable, Optional, TextIO, Union, cast
+
+from orcsome3.common import APPNAME, VERSION
+from orcsome3.instance import acquire_display_lock, display_key
+from orcsome3.libs.ev import Loop, SignalWatcher, StatWatcher, TimerWatcher
+from orcsome3.notify import Notification, NotificationBus
+from orcsome3.tray import report_error, set_config_path, take_pending_config_path
+from orcsome3.utils import SingletonMixin, execfile
+from orcsome3.window_manager import WindowManager
+
+_RC_DEBOUNCE_SECONDS: float = 0.3
+
+
+class _Orcsome3(SingletonMixin):
+ """Process-wide runtime: event loop, WindowManager, and config path."""
+
+ def __init__(self) -> None:
+ if type(self)._singleton_init_done():
+ return
+ self._wm: Optional[WindowManager] = None
+ self._config_file: Optional[Path] = None
+ self._loop: Optional[Loop] = None
+ self._lock_file: Optional[TextIO] = None
+ self._signal_watchers: list[SignalWatcher] = []
+ self._rc_watcher: Optional[StatWatcher] = None
+ self._rc_debounce: Optional[TimerWatcher] = None
+ self._restarting: bool = False
+
+ @property
+ def wm(self) -> WindowManager:
+ """The singleton WindowManager created in `run()`."""
+ return cast(WindowManager, self._wm)
+
+ @property
+ def config_file(self) -> Path:
+ """Resolved path of the loaded config file."""
+ return cast(Path, self._config_file)
+
+ @property
+ def loop(self) -> Loop:
+ """libev loop that drives X events and timers."""
+ return cast(Loop, self._loop)
+
+ def run(self, config_file: Path) -> None:
+ """Take the DISPLAY lock, load `config_file`, connect D-Bus, then run the event loop until stop."""
+ self._lock_file = acquire_display_lock()
+ if self._lock_file is None:
+ _logger.error(msg=f"orcsome3 already running on DISPLAY={display_key()}")
+ sys.exit(1)
+
+ self._config_file = config_file.resolve()
+ set_config_path(path=self._config_file)
+ self._loop = Loop.new()
+ self._wm = WindowManager(loop=self.loop)
+
+ self.wm.set_restart_handler(handler=self.restart)
+ self._watch_signal(signal_number=Signals.SIGINT, callback=lambda __loop__, __watcher__, __events__: self.stop())
+ self._watch_signal(
+ signal_number=Signals.SIGTERM, callback=lambda __loop__, __watcher__, __events__: self.stop()
+ )
+ self._watch_signal(
+ signal_number=Signals.SIGUSR1, callback=lambda __loop__, __watcher__, __events__: self.restart()
+ )
+ self._watch_signal(signal_number=Signals.SIGUSR2, callback=self._on_config_selected)
+
+ _ = self.load_config()
+ self._start_rc_watcher()
+
+ _ = NotificationBus()
+
+ self.wm.init()
+ self.loop.run()
+
+ def _watch_signal(self, signal_number: Signals, callback: Callable[[Loop, SignalWatcher, int], None]) -> None:
+ watcher: SignalWatcher = SignalWatcher.new(callback=callback, signal_number=signal_number)
+ watcher.start(loop=self.loop)
+ self._signal_watchers.append(watcher)
+
+ def _start_rc_watcher(self) -> None:
+ """Watch the config file via `ev_stat` (inotify, with a libev `stat` fallback)."""
+ self._rearm_rc_watcher()
+ self._rc_debounce = TimerWatcher.new(callback=self._on_rc_debounce, after=_RC_DEBOUNCE_SECONDS, repeat=0.0)
+
+ def _rearm_rc_watcher(self) -> None:
+ """(Re)point the `ev_stat` watcher at `self.config_file` — startup, and after switching files."""
+ if self._rc_watcher is not None:
+ self._rc_watcher.close(loop=self.loop)
+ self._rc_watcher = StatWatcher.new(callback=self._on_rc_stat, path=str(self.config_file), interval=0.0)
+ self._rc_watcher.start(loop=self.loop)
+
+ def _on_rc_stat(self, __loop__: Loop, __watcher__: StatWatcher, __events__: int) -> None:
+ if self._rc_debounce is None:
+ return
+ self._rc_debounce.stop(loop=self.loop)
+ self._rc_debounce.start(loop=self.loop, after=_RC_DEBOUNCE_SECONDS)
+
+ def _on_rc_debounce(self, __loop__: Loop, __watcher__: TimerWatcher, __events__: int) -> None:
+ _logger.info(msg=f"{self.config_file.name} changed; reloading...")
+ self.restart()
+
+ def _on_config_selected(self, __loop__: Loop, __watcher__: SignalWatcher, __events__: int) -> None:
+ """Sent by the tray's "Change config file..." picker (`tray._pick_new_config`) after a real pick."""
+ path: Optional[Path] = take_pending_config_path()
+ if path is None:
+ return
+ self._config_file = path.resolve()
+ set_config_path(path=self._config_file)
+ self._rearm_rc_watcher()
+ _logger.info(msg=f"Switched config file to {self._config_file}; reloading...")
+ self.restart()
+
+ def restart(self) -> None:
+ """Reload the config and re-init the window manager without exiting the process."""
+ if self._restarting:
+ return
+ self._restarting = True
+ try:
+ self.wm.stop()
+ _logger.info(msg="Restarting...")
+ loaded: bool = self.load_config()
+ self.wm.init()
+ if loaded:
+ _logger.info(msg="Restarted successfully")
+ else:
+ _logger.error(msg="Reload failed; running with no config handlers")
+ finally:
+ self._restarting = False
+
+ def stop(self) -> None:
+ """Ungrab keys, close the display, disconnect D-Bus, and break the event loop."""
+ _logger.info(msg="Stopping orcsome3...")
+ for watcher in self._signal_watchers:
+ watcher.close(loop=self.loop)
+ self._signal_watchers = []
+ if self._rc_watcher is not None:
+ self._rc_watcher.close(loop=self.loop)
+ self._rc_watcher = None
+ if self._rc_debounce is not None:
+ self._rc_debounce.close(loop=self.loop)
+ self._rc_debounce = None
+ if self._wm is not None:
+ self.wm.stop(exit=True)
+ NotificationBus.stop()
+ if self._lock_file is not None:
+ self._lock_file.close()
+ self._lock_file = None
+ if self._loop is not None:
+ self.loop.break_(how=Loop.BreakFlags.EVBREAK_ALL)
+ self.loop.destroy()
+ self._loop = None
+
+ def load_config(self) -> bool:
+ """Exec the config file with `__file__` / `__name__` set.
+
+ Never exits the process: a broken config (initial load or reload) leaves orcsome3 running
+ with no handlers installed rather than crashing, and reports the error via `tray.report_error`
+ (tray icon + tooltip + menu row) and a best-effort desktop notification.
+ """
+ config_dir: str = str(self.config_file.parent)
+ config_globals: dict[str, object] = {
+ "__file__": str(self.config_file),
+ "__name__": "__orcsome3_config__",
+ }
+ try:
+ sys.path.insert(0, config_dir)
+ execfile(filepath=self.config_file, globals_=config_globals)
+ except Exception as e:
+ _logger.exception(msg=f"Error on loading {self.config_file}")
+ message: str = f"{type(e).__name__}: {e}"
+ report_error(message=message)
+ Notification(summary="orcsome3: config error", body=message).show()
+ return False
+ finally:
+ if sys.path and sys.path[0] == config_dir:
+ _ = sys.path.pop(0)
+ report_error(message=None)
+ return True
+
+
+# Globals
+_logger: logging.Logger = logging.getLogger(name=__name__)
+_orcsome3: _Orcsome3 = _Orcsome3() # global _Orcsome3 single instance
+
+
+def get_wm() -> WindowManager:
+ """Return the process-wide WindowManager.
+
+ Call this from your config (or any code running after `orcsome3` has started).
+ Constructing `WindowManager()` yourself returns the same singleton once `run()` has created it.
+ """
+ return _orcsome3.wm
+
+
+def _config_dir() -> Path:
+ """XDG config dir for orcsome3 (`$XDG_CONFIG_HOME/orcsome3` or `~/.config/orcsome3`)."""
+ return Path(os.getenv(key="XDG_CONFIG_HOME", default=str(Path("~/.config").expanduser()))).joinpath("orcsome3")
+
+
+def _default_config_file() -> Path:
+ """`~/.config/orcsome3/config.py` (or `$XDG_CONFIG_HOME/orcsome3/config.py`)."""
+ return _config_dir().joinpath("config.py")
+
+
+def run() -> None:
+ """CLI entry: parse `--config` / logging flags and start the orcsome3 process."""
+
+ parser: ArgumentParser = ArgumentParser(prog=APPNAME)
+ _ = parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
+ _ = parser.add_argument(
+ "-l", "--log-file", dest="log", help="Path to log file (log to stdout by default)", type=Path
+ )
+ _ = parser.add_argument("--log-level", default="INFO", help="log level, default is INFO")
+ _ = parser.add_argument(
+ "-c",
+ "--config",
+ default=_default_config_file(),
+ help="Path to config file (default: ~/.config/orcsome3/config.py)",
+ type=Path,
+ required=False,
+ )
+
+ args: Namespace = parser.parse_args()
+
+ logger_handler: Union[logging.FileHandler, logging.StreamHandler[TextIO]] = (
+ logging.FileHandler(filename=str(args.log)) if args.log is not None else logging.StreamHandler()
+ )
+ logger_handler.setFormatter(
+ fmt=logging.Formatter(fmt="%(asctime)s %(name)s %(levelname)s: %(message)s", datefmt="%d/%m/%Y %H:%M:%S")
+ )
+ root_logger: logging.Logger = logging.getLogger()
+ root_logger.setLevel(level=args.log_level)
+ root_logger.addHandler(hdlr=logger_handler)
+
+ config_path: Path = cast(Path, args.config).expanduser().resolve()
+ if not config_path.is_file():
+ _logger.error(msg="The config file provided is not valid, exiting...")
+ return
+
+ _orcsome3.run(config_file=config_path)
+
+
+if __name__ == "__main__":
+ run()
diff --git a/orcsome3/tray.py b/orcsome3/tray.py
new file mode 100644
index 0000000..7d592c2
--- /dev/null
+++ b/orcsome3/tray.py
@@ -0,0 +1,452 @@
+"""StatusNotifierItem (SNI) tray icon plus a dbusmenu on the session bus.
+
+Clicks must not touch X/libev: Quit is SIGTERM, and picking a new config file via `zenity`/
+`kdialog` is SIGUSR2 (`run.py` handles both). No menu entry for reload — the config file watcher
+already reloads on any change; SIGUSR1 still triggers it for external/scripted use.
+
+dbus-next takes D-Bus signatures from annotations (`"s"`, `"i"`, …). A void method is `-> ""`
+(empty signature), not `-> None`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import shutil
+import signal as signal_module
+from pathlib import Path
+from typing import Any, Optional
+
+from dbus_next.aio.message_bus import MessageBus
+from dbus_next.constants import NameFlag, PropertyAccess, RequestNameReply
+from dbus_next.service import ServiceInterface, dbus_property, method, signal
+from dbus_next.signature import Variant
+
+from orcsome3.common import APPNAME
+from orcsome3.instance import display_key
+from orcsome3.libs import xlib
+
+_logger: logging.Logger = logging.getLogger(name=__name__)
+
+_ICONS_DIR: Path = Path(__file__).resolve().parent / "icons"
+_ICON_SVG: Path = _ICONS_DIR / "hicolor" / "scalable" / "apps" / f"{APPNAME}.svg"
+_ERROR_ICON_SVG: Path = _ICONS_DIR / "hicolor" / "scalable" / "apps" / f"{APPNAME}-error.svg"
+
+_ITEM_PATH: str = "/StatusNotifierItem"
+_MENU_PATH: str = "/MenuBar"
+_ID_QUIT: int = 2
+_ID_ERROR: int = 3
+_ID_CONFIG: int = 4
+_ID_CHANGE_CONFIG: int = 5
+_LABELS: dict[int, str] = {_ID_QUIT: "Quit", _ID_CHANGE_CONFIG: "Change config file..."}
+_WATCHERS: tuple[tuple[str, str], ...] = (
+ ("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher"),
+ ("org.freedesktop.StatusNotifierWatcher", "/StatusNotifierWatcher"),
+)
+
+
+def _detect_file_picker() -> Optional[tuple[str, ...]]:
+ """A GUI file-picker command, if one happens to be installed. No new dependency either way:
+ the "Change config file..." menu row only appears when this finds something to run."""
+ zenity: Optional[str] = shutil.which(cmd="zenity")
+ if zenity is not None:
+ return (zenity, "--file-selection", "--title=Select orcsome3 config file")
+ kdialog: Optional[str] = shutil.which(cmd="kdialog")
+ if kdialog is not None:
+ return (kdialog, "--getopenfilename", str(Path.home()), "*.py|Python files")
+ return None
+
+
+_FILE_PICKER: Optional[tuple[str, ...]] = _detect_file_picker()
+
+# Set once `start_status_notifier` registers on a watcher; read/written from any thread (plain
+# assignment is atomic under the GIL). `_last_error` and `_config_path` are meaningful before
+# that too: set during the very first config load, before the tray exists, and the item's
+# properties are read fresh on registration, so they pick it up without needing a push signal.
+_last_error: Optional[str] = None
+_config_path: Optional[str] = None
+_pending_config_path: Optional[Path] = None
+_item: Optional[StatusNotifierItem] = None
+_menu: Optional[DBusMenu] = None
+_loop: Optional[asyncio.AbstractEventLoop] = None
+
+
+_PIXMAP_SIZES: tuple[int, ...] = (24, 48)
+
+
+def _render_pixmaps(svg_path: Path) -> list[list[Any]]:
+ """Rasterize `svg_path` (via resvg, already a build dependency — no theme dir, no leftover
+ files) at each of `_PIXMAP_SIZES` for the SNI `a(iiay)` icon-pixmap format. Empty if the file
+ is missing or fails to parse, so a broken/missing SVG degrades to no icon rather than crashing.
+ """
+ pixmaps: list[list[Any]] = []
+ for size in _PIXMAP_SIZES:
+ argb: Optional[bytes] = xlib.render_svg_to_argb(svg_path=svg_path, size=size)
+ if argb is not None:
+ pixmaps.append([size, size, argb])
+ return pixmaps
+
+
+_ICON_PIXMAP: list[list[Any]] = _render_pixmaps(svg_path=_ICON_SVG)
+_ATTENTION_ICON_PIXMAP: list[list[Any]] = _render_pixmaps(svg_path=_ERROR_ICON_SVG)
+
+
+def _error_label() -> str:
+ """Short menu-row text for the last config-load error, truncated for display."""
+ text: str = _last_error or ""
+ limit: int = 60
+ return f"Error: {text[:limit]}{'…' if len(text) > limit else ''}"
+
+
+def _config_label() -> str:
+ """Menu-row text naming the config file currently in use."""
+ return f"Config: {_config_path}" if _config_path is not None else "Config: (none loaded)"
+
+
+def _bus_name(display: str, pid: int) -> str:
+ safe: str = "".join(ch if ch.isalnum() else "_" for ch in display)
+ return f"org.kde.StatusNotifierItem.{APPNAME}_{safe}_{pid}"
+
+
+_INFO_ROW_LABELS: dict[int, Any] = {_ID_CONFIG: _config_label, _ID_ERROR: _error_label}
+
+
+def _props(id_: int) -> dict[str, Variant]:
+ if id_ in _INFO_ROW_LABELS:
+ return {
+ "type": Variant(signature="s", value="standard"),
+ "label": Variant(signature="s", value=_INFO_ROW_LABELS[id_]()),
+ "enabled": Variant(signature="b", value=False),
+ "visible": Variant(signature="b", value=True),
+ }
+ return {
+ "type": Variant(signature="s", value="standard"),
+ "label": Variant(signature="s", value=_LABELS[id_]),
+ "enabled": Variant(signature="b", value=True),
+ "visible": Variant(signature="b", value=True),
+ }
+
+
+def _leaf(id_: int) -> Variant:
+ return Variant(signature="(ia{sv}av)", value=[id_, _props(id_=id_), []])
+
+
+def _layout(parent_id: int) -> list[Any]:
+ if parent_id == 0:
+ children: list[Variant] = [_leaf(id_=_ID_CONFIG)]
+ if _last_error is not None:
+ children.append(_leaf(id_=_ID_ERROR))
+ if _FILE_PICKER is not None:
+ children.append(_leaf(id_=_ID_CHANGE_CONFIG))
+ children.append(_leaf(id_=_ID_QUIT))
+ return [0, {"children-display": Variant(signature="s", value="submenu")}, children]
+ if parent_id in _LABELS or parent_id in _INFO_ROW_LABELS:
+ return [parent_id, _props(id_=parent_id), []]
+ return [0, {}, []]
+
+
+async def _pick_new_config() -> None:
+ """Run the detected file picker; on a real selection, hand it to `run.py` via SIGUSR2."""
+ if _FILE_PICKER is None:
+ return
+ proc: asyncio.subprocess.Process = await asyncio.create_subprocess_exec(
+ *_FILE_PICKER, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
+ )
+ stdout, _stderr = await proc.communicate()
+ if proc.returncode != 0:
+ return # cancelled
+ selected: str = stdout.decode().strip()
+ if not selected:
+ return
+ path: Path = Path(selected)
+ if not path.is_file():
+ _logger.warning(msg=f"File picker returned a non-file path: {path}")
+ return
+ global _pending_config_path
+ _pending_config_path = path
+ os.kill(os.getpid(), signal_module.SIGUSR2)
+
+
+def _clicked(id_: int) -> None:
+ """Quit goes through a real signal so `run.py`'s handler does the actual work; clicks must
+ not touch X/libev directly from this (asyncio) thread. Reload has no menu entry — the config
+ file watcher already reloads automatically on change (SIGUSR1 still works for external use)."""
+ if id_ == _ID_QUIT:
+ os.kill(os.getpid(), signal_module.SIGTERM)
+ return
+ if id_ == _ID_CHANGE_CONFIG:
+ _ = asyncio.ensure_future(coro_or_future=_pick_new_config())
+
+
+class StatusNotifierItem(ServiceInterface):
+ """org.kde.StatusNotifierItem with a dbusmenu at `Menu`."""
+
+ _display: str
+ _menu_path: str
+
+ def __init__(self, display: str, menu_path: str) -> None:
+ super().__init__(name="org.kde.StatusNotifierItem")
+ self._display = display
+ self._menu_path = menu_path
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Category(self) -> "s":
+ return "ApplicationStatus"
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Id(self) -> "s":
+ return APPNAME
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Title(self) -> "s":
+ return APPNAME
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Status(self) -> "s":
+ return "NeedsAttention" if _last_error is not None else "Active"
+
+ @dbus_property(access=PropertyAccess.READ)
+ def WindowId(self) -> "i":
+ return 0
+
+ @dbus_property(access=PropertyAccess.READ)
+ def IconName(self) -> "s":
+ # Pixels go straight over D-Bus (IconPixmap below) — no theme name to register, so
+ # nothing gets copied into ~/.local/share/icons and nothing is left behind on uninstall.
+ return ""
+
+ @dbus_property(access=PropertyAccess.READ)
+ def IconThemePath(self) -> "s":
+ return ""
+
+ @dbus_property(access=PropertyAccess.READ)
+ def IconPixmap(self) -> "a(iiay)":
+ return _ICON_PIXMAP
+
+ @dbus_property(access=PropertyAccess.READ)
+ def OverlayIconName(self) -> "s":
+ return ""
+
+ @dbus_property(access=PropertyAccess.READ)
+ def OverlayIconPixmap(self) -> "a(iiay)":
+ return []
+
+ @dbus_property(access=PropertyAccess.READ)
+ def AttentionIconName(self) -> "s":
+ return ""
+
+ @dbus_property(access=PropertyAccess.READ)
+ def AttentionIconPixmap(self) -> "a(iiay)":
+ return _ATTENTION_ICON_PIXMAP if _last_error is not None else []
+
+ @signal()
+ def NewIcon(self) -> "":
+ pass
+
+ @signal()
+ def NewAttentionIcon(self) -> "":
+ pass
+
+ @signal()
+ def NewToolTip(self) -> "":
+ pass
+
+ @signal()
+ def NewStatus(self, newStatus: str) -> "s":
+ return newStatus
+
+ @dbus_property(access=PropertyAccess.READ)
+ def ItemIsMenu(self) -> "b":
+ return True
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Menu(self) -> "o":
+ return self._menu_path
+
+ @dbus_property(access=PropertyAccess.READ)
+ def ToolTip(self) -> "(sa(iiay)ss)":
+ description: str = _last_error if _last_error is not None else f"DISPLAY={self._display}"
+ return ["", [], APPNAME, description]
+
+ @method()
+ def ContextMenu(self, x: "i", y: "i") -> "":
+ _logger.debug(msg=f"ContextMenu x={x} y={y}")
+
+ @method()
+ def Activate(self, x: "i", y: "i") -> "":
+ _logger.debug(msg=f"Activate x={x} y={y}")
+
+ @method()
+ def SecondaryActivate(self, x: "i", y: "i") -> "":
+ _logger.debug(msg=f"SecondaryActivate x={x} y={y}")
+
+ @method()
+ def Scroll(self, delta: "i", orientation: "s") -> "":
+ _logger.debug(msg=f"Scroll delta={delta} orientation={orientation}")
+
+
+class DBusMenu(ServiceInterface):
+ """com.canonical.dbusmenu: Quit, an always-visible config-path row, and (while `_last_error`
+ is set) an error row."""
+
+ _about_to_show: bool
+ _revision: int
+
+ def __init__(self) -> None:
+ super().__init__(name="com.canonical.dbusmenu")
+ self._about_to_show = False
+ self._revision = 1
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Version(self) -> "u":
+ return 3
+
+ @dbus_property(access=PropertyAccess.READ)
+ def TextDirection(self) -> "s":
+ return "ltr"
+
+ @dbus_property(access=PropertyAccess.READ)
+ def Status(self) -> "s":
+ return "normal"
+
+ @dbus_property(access=PropertyAccess.READ)
+ def IconThemePath(self) -> "as":
+ return []
+
+ @method()
+ def GetLayout(self, parentId: "i", recursionDepth: "i", propertyNames: "as") -> "u(ia{sv}av)":
+ _logger.debug(msg=f"GetLayout parentId={parentId} about_to_show={self._about_to_show}")
+ _ = (recursionDepth, propertyNames)
+ return [self._revision, _layout(parent_id=parentId)]
+
+ @method()
+ def GetGroupProperties(self, ids: "ai", propertyNames: "as") -> "a(ia{sv})":
+ _ = propertyNames
+ known: list[int] = [_ID_CONFIG, *_LABELS] + ([_ID_ERROR] if _last_error is not None else [])
+ wanted: list[int] = ids if ids else known
+ return [[i, _props(id_=i)] for i in wanted if i in known]
+
+ @method()
+ def GetProperty(self, id: "i", name: "s") -> "v":
+ if name != "label":
+ return Variant(signature="s", value="")
+ if id in _LABELS:
+ return Variant(signature="s", value=_LABELS[id])
+ if id == _ID_CONFIG:
+ return Variant(signature="s", value=_config_label())
+ if id == _ID_ERROR and _last_error is not None:
+ return Variant(signature="s", value=_error_label())
+ return Variant(signature="s", value="")
+
+ @method()
+ def Event(self, id: "i", eventId: "s", data: "v", timestamp: "u") -> "":
+ # "clicked" is authoritative on its own per the dbusmenu spec; AboutToShow is a lazy-
+ # populate hook, not a click gate. Gating on it here dropped real clicks: some hosts
+ # don't reliably leave `_about_to_show` True by the time the click event arrives.
+ _logger.debug(msg=f"Event id={id} eventId={eventId!r} labels={id in _LABELS}")
+ _ = (data, timestamp)
+ if eventId == "clicked" and id in _LABELS:
+ _clicked(id_=id)
+
+ @method()
+ def AboutToShow(self, id: "i") -> "b":
+ _logger.debug(msg=f"AboutToShow id={id}")
+ self._about_to_show = True
+ return False
+
+ @signal()
+ def LayoutUpdated(self, revision: int, parentId: int) -> "ui":
+ return [revision, parentId]
+
+ def layout_changed(self) -> None:
+ """Bump the revision and tell the host to call `GetLayout` again (the error row appeared/disappeared)."""
+ self._revision += 1
+ self.LayoutUpdated(revision=self._revision, parentId=0)
+
+
+def set_config_path(path: Path) -> None:
+ """Record which config file is active, for the menu's "Config: ..." row. Safe from any thread."""
+ global _config_path
+ _config_path = str(path)
+ if _loop is None:
+ return
+ _ = _loop.call_soon_threadsafe(callback=_refresh_config_path)
+
+
+def _refresh_config_path() -> None:
+ if _menu is not None:
+ _menu.layout_changed()
+
+
+def take_pending_config_path() -> Optional[Path]:
+ """Pop the config path chosen via the tray's file picker, if any.
+
+ Call from `run.py`'s SIGUSR2 handler (main thread) — that signal is what `_pick_new_config`
+ sends after a real selection, so this is only ever non-`None` right when it fires.
+ """
+ global _pending_config_path
+ path: Optional[Path] = _pending_config_path
+ _pending_config_path = None
+ return path
+
+
+def report_error(message: Optional[str]) -> None:
+ """Record the last config-load error (or clear it with `None`) and refresh the tray to match.
+
+ Safe to call before the tray exists (e.g. the very first config load, before `NotificationBus`
+ has started — the item's properties are read fresh once it does register, so they already
+ reflect this) or from any thread: the D-Bus signal emission itself is marshalled onto the
+ tray's own asyncio loop.
+ """
+ global _last_error
+ _last_error = message
+ if _loop is None:
+ return
+ _ = _loop.call_soon_threadsafe(callback=_refresh_error_state)
+
+
+def _refresh_error_state() -> None:
+ if _item is not None:
+ _item.NewStatus(newStatus="NeedsAttention" if _last_error is not None else "Active")
+ _item.NewAttentionIcon()
+ _item.NewToolTip()
+ if _menu is not None:
+ _menu.layout_changed()
+
+
+async def start_status_notifier(bus: MessageBus) -> None:
+ """Export SNI + dbusmenu and register with a StatusNotifierWatcher if one is on this session bus."""
+ global _item, _menu, _loop
+ display: str = display_key()
+ well_known: str = _bus_name(display=display, pid=os.getpid())
+ item: StatusNotifierItem = StatusNotifierItem(display=display, menu_path=_MENU_PATH)
+ menu: DBusMenu = DBusMenu()
+ bus.export(path=_ITEM_PATH, interface=item)
+ bus.export(path=_MENU_PATH, interface=menu)
+ _logger.debug(msg=f"icon pixmaps: normal={len(_ICON_PIXMAP)} attention={len(_ATTENTION_ICON_PIXMAP)}")
+ reply: RequestNameReply = await bus.request_name(name=well_known, flags=NameFlag.DO_NOT_QUEUE)
+ unique: Optional[str] = bus.unique_name
+ service: str = (
+ well_known
+ if reply in (RequestNameReply.PRIMARY_OWNER, RequestNameReply.ALREADY_OWNER)
+ else unique or well_known
+ )
+ for watcher_name, watcher_path in _WATCHERS:
+ try:
+ proxy: Any = bus.get_proxy_object(
+ bus_name=watcher_name,
+ path=watcher_path,
+ introspection=await bus.introspect(bus_name=watcher_name, path=watcher_path),
+ )
+ iface: Any = proxy.get_interface(name=watcher_name)
+ await iface.call_register_status_notifier_item(service)
+ item.NewIcon()
+ _item = item
+ _menu = menu
+ _loop = asyncio.get_running_loop()
+ _logger.info(msg=f"StatusNotifierItem registered on {watcher_name} ({service})")
+ return
+ except Exception:
+ continue
+ _logger.info(msg="No StatusNotifierWatcher on this session bus; tray icon skipped")
diff --git a/orcsome3/utils.py b/orcsome3/utils.py
new file mode 100644
index 0000000..6563223
--- /dev/null
+++ b/orcsome3/utils.py
@@ -0,0 +1,187 @@
+"""Small helpers used across orcsome3 (singleton, file/process utils)."""
+
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import subprocess
+import tarfile
+from pathlib import Path
+from typing import IO, Any, ClassVar, Optional, TypeVar, cast
+
+from typing_extensions import override
+
+_T = TypeVar("_T")
+
+
+class SingletonMixin:
+ """Mixin: at most one instance per concrete class."""
+
+ _singletons: ClassVar[dict[type[Any], Any]] = {}
+ _singleton_inited: ClassVar[set[type[Any]]] = set()
+
+ def __new__(cls, *args: Any, **kwargs: Any) -> Any:
+ instance: Any = cls._singletons.get(cls)
+ if instance is None:
+ instance = super().__new__(cls)
+ cls._singletons[cls] = instance
+ return instance
+
+ @classmethod
+ def delete_singleton(cls) -> None:
+ """Drop the cached instance so the next constructor call allocates a new one."""
+ cls._singletons.pop(cls, None)
+ cls._singleton_inited.discard(cls)
+
+ @classmethod
+ def _singleton_init_done(cls) -> bool:
+ """Return True when __init__ should be skipped (already ran for this class)."""
+ if cls in cls._singleton_inited:
+ return True
+ cls._singleton_inited.add(cls)
+ return False
+
+
+class Final(type):
+ """Class used as metaclass to avoid changing attributes of constant classes"""
+
+ @override
+ def __setattr__(self, __name__: str, __value__: Any) -> None:
+ raise AttributeError("Final classes can't be modified.")
+
+ @override
+ def __delattr__(self, __name__: str) -> None:
+ raise AttributeError("Final classes can't be modified.")
+
+
+_re_cache: dict[str, re.Pattern[str]] = {}
+
+
+def match_string(pattern: str, string: str) -> bool:
+ """True if `pattern` (compiled regex, cached) searches anywhere in `string`."""
+ compiled: Optional[re.Pattern[str]] = _re_cache.get(pattern)
+ if compiled is None:
+ compiled = re.compile(pattern=pattern)
+ _re_cache[pattern] = compiled
+ return compiled.search(string=string) is not None
+
+
+def rmdir(*directories: Path) -> None:
+ """Delete a file or a directory if it exists"""
+ for directory in directories:
+ if directory.is_symlink() or directory.is_file():
+ directory.unlink()
+ elif directory.is_dir():
+ shutil.rmtree(path=directory)
+
+
+def run_command_and_show_output(command: str, cwd: Optional[Path] = None) -> None:
+ """Run command and show output in real time"""
+ with subprocess.Popen(
+ args=["bash", "-c", command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, text=True
+ ) as sp:
+ for line in cast(IO[str], sp.stdout):
+ print(line.replace("\n", ""), flush=True)
+ _ = sp.wait()
+ if sp.returncode:
+ raise subprocess.CalledProcessError(returncode=sp.returncode, cmd=command)
+
+
+def execfile(filepath: Path, globals_: Optional[dict[str, Any]] = None) -> None:
+ """Execute a python 3 file"""
+ if globals_ is None:
+ globals_ = globals()
+ with filepath.open(mode="r") as fh:
+ exec(fh.read() + "\n", globals_)
+
+
+def extract_tar_strip_components(tar_path: Path, destination_path: Path, strip_components: int = 0) -> None:
+ """
+ Extracts a tarfile to a destination, optionally stripping leading path components.
+
+ Args:
+ tar_path: The path to the tarfile.
+ destination_path: The directory where the contents will be extracted.
+ strip_components: The number of leading path components to strip.
+ Defaults to 0 (no stripping).
+ """
+ destination_path.mkdir(parents=True, exist_ok=True)
+
+ with tarfile.open(name=tar_path, mode="r") as tar:
+ for member in tar.getmembers():
+ # Skip directories, they will be created implicitly
+ if member.isdir():
+ continue
+
+ parts: tuple[str, ...] = Path(member.name).parts
+ if len(parts) <= strip_components:
+ # If stripping would result in an empty path or stripping more components
+ # than available, skip or handle as appropriate.
+ # For this example, we'll skip to avoid errors.
+ print(
+ f"Skipping '{member.name}' as stripping {strip_components} components "
+ + "would result in an invalid path."
+ )
+ continue
+
+ # Construct the new path by stripping components
+ stripped_parts: tuple[str, ...] = parts[strip_components:]
+ relative_dest_path: Path = Path(*stripped_parts)
+ absolute_dest_path: Path = destination_path / relative_dest_path
+
+ # Ensure parent directories exist
+ absolute_dest_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Extract the file
+ # Using extractfile for individual members and writing to ensure security
+ # against path traversal vulnerabilities by not relying on member.name directly
+ # for the full path calculation within tarfile's internal extraction.
+ if member.isreg(): # Only extract regular files
+ fsrc: Optional[IO[bytes]] = tar.extractfile(member=member)
+ if fsrc is not None: # Ensure fsrc is not None for empty files/symlinks treated as regular
+ with fsrc, absolute_dest_path.open(mode="wb") as fdst:
+ shutil.copyfileobj(fsrc=fsrc, fdst=fdst)
+ absolute_dest_path.chmod(mode=member.mode & 0o777)
+ # Keep tarball mtimes so autotools does not rebuild Makefile.in (needs automake).
+ os.utime(absolute_dest_path, times=(member.mtime, member.mtime))
+ elif member.issym():
+ # Handle symbolic links: create a symlink at the destination
+ link_target: Path = Path(member.linkname)
+ # Check if the symlink target is absolute and needs adjustment
+ if link_target.is_absolute():
+ print(
+ f"Warning: Absolute symlink target '{member.linkname}' found for '{member.name}'. "
+ + "This might lead to issues outside the extracted directory."
+ )
+ # Create the symlink
+ if not absolute_dest_path.exists(): # Avoid overwriting if it exists
+ try:
+ absolute_dest_path.symlink_to(target=link_target)
+ except OSError as e:
+ print(f"Could not create symlink for {member.name} -> {member.linkname}: {e}")
+ elif member.islnk():
+ # Handle hard links: same as symlinks but often with relative paths
+ link_target = Path(member.linkname)
+ if not absolute_dest_path.exists():
+ try:
+ # For hard links, the target must exist and be a file
+ # This part is trickier as os.link needs the original path to the file
+ # which is not easily available from tarfile. We'll treat them like symlinks
+ # for simplicity, but a true hard link extraction would require
+ # knowing the original file's path within the extraction.
+ # As a fallback, we'll just extract the content if it's a regular file
+ # or skip if it's pointing to something we can't create directly.
+ print(
+ f"Warning: Hard link '{member.name}' to '{member.linkname}' detected. "
+ + "Direct hard link creation is complex without source file path. "
+ + "Treating as regular file if content exists, otherwise skipping."
+ )
+ fsrc = tar.extractfile(member=member)
+ if fsrc is not None:
+ with fsrc, absolute_dest_path.open(mode="wb") as fdst:
+ shutil.copyfileobj(fsrc=fsrc, fdst=fdst)
+ absolute_dest_path.chmod(mode=member.mode & 0o777)
+ os.utime(absolute_dest_path, times=(member.mtime, member.mtime))
+ except Exception as e:
+ print(f"Could not handle hard link for {member.name}: {e}")
diff --git a/orcsome3/version.py b/orcsome3/version.py
deleted file mode 100644
index 6c5007c..0000000
--- a/orcsome3/version.py
+++ /dev/null
@@ -1 +0,0 @@
-VERSION = "0.2.0"
diff --git a/orcsome3/window_manager.py b/orcsome3/window_manager.py
new file mode 100644
index 0000000..f6b96dc
--- /dev/null
+++ b/orcsome3/window_manager.py
@@ -0,0 +1,2056 @@
+"""Window manager singleton, Window objects, and config.py signal decorators (`on_key`, `on_create`, `on_focus`, …).
+
+`KeyboardModifiers`, `WindowMatchers`, and `KeyDefinition` live in `orcsome3.keys` and are re-exported here.
+"""
+
+from __future__ import annotations
+
+import logging
+from array import array
+from functools import wraps
+from pathlib import Path
+from typing import Callable, NamedTuple, Optional, TypeVar, Union, cast
+
+from typing_extensions import TypeAlias, final
+
+import orcsome3.libs.ev as ev
+import orcsome3.libs.xlib as xlib
+from orcsome3.keys import KeyboardModifiers as KeyboardModifiers
+from orcsome3.keys import KeyDefinition as KeyDefinition
+from orcsome3.keys import WindowMatchers as WindowMatchers
+from orcsome3.keys import keycode_from_string_or_keysym
+from orcsome3.utils import SingletonMixin, match_string
+
+# Globals
+_logger: logging.Logger = logging.getLogger(name=__name__)
+_ignore_logger: bool = False
+
+_KeyHandler: TypeAlias = Callable[[xlib.TYPES.Cython_Window, xlib.XKeyEvent], None]
+_ButtonHandler: TypeAlias = Callable[[xlib.TYPES.Cython_Window, xlib.XButtonEvent], None]
+_ClientMessageHandler: TypeAlias = Callable[[xlib.TYPES.Cython_Window, xlib.XClientMessageEvent], None]
+_PropertyHandler: TypeAlias = Callable[[xlib.TYPES.Cython_Window, xlib.XPropertyEvent], None]
+# Window isn't defined yet at module scope here, and the wrapper's own (Window, event) signature
+# is concrete rather than a passthrough TypeVar (unlike on_property_change) — a precisely-typed
+# alias would need a forward reference, so this stays as loose as _WindowEventCbs's value type.
+_CreateHandler: TypeAlias = Callable[..., None]
+_CbNone = TypeVar("_CbNone", bound=Callable[[], None])
+_CbKey = TypeVar("_CbKey", bound=Callable[..., None])
+_CbButton = TypeVar("_CbButton", bound=Callable[..., None])
+_CbClient = TypeVar("_CbClient", bound=Callable[..., None])
+_CbStruct = TypeVar("_CbStruct", bound=Callable[..., None])
+_CbTimer = TypeVar("_CbTimer", bound=Callable[[], Optional[bool]])
+_WindowEventCbs: TypeAlias = dict[Optional[xlib.TYPES.Cython_Window], list[Callable[..., None]]]
+# [press, release]; one XGrabKey is shared when both on_key and on_key_release bind the same combo
+_KeyGrabSlots: TypeAlias = list[Optional[_KeyHandler]]
+
+# ButtonPress.state includes other buttons held; strip those so Control+Button1 still matches.
+_BUTTON_STATE_MASK: int = (
+ xlib.BUTTON_MASKS.Button1Mask.value
+ | xlib.BUTTON_MASKS.Button2Mask.value
+ | xlib.BUTTON_MASKS.Button3Mask.value
+ | xlib.BUTTON_MASKS.Button4Mask.value
+ | xlib.BUTTON_MASKS.Button5Mask.value
+)
+
+
+def _modifiers_mask(modifiers: Union[int, KeyboardModifiers, list[KeyboardModifiers]]) -> int:
+ """OR modifier masks. A `KeyboardModifiers` member is an `int` subclass, so `int(member)` is the X mask."""
+ if isinstance(modifiers, list):
+ mask: int = KeyboardModifiers.NoModifiers.value
+ for modifier in modifiers:
+ mask |= int(modifier)
+ return mask
+ return int(modifiers)
+
+
+# CapsLock (Lock) and NumLock (Mod2) must be grabbed too or hotkeys miss when those locks are on.
+IGNORED_KEY_MASKS: list[int] = [
+ xlib.KEY_MASKS.NoModifiers.value,
+ xlib.KEY_MASKS.LockMask.value,
+ xlib.KEY_MASKS.Mod2Mask.value,
+ xlib.KEY_MASKS.LockMask.value | xlib.KEY_MASKS.Mod2Mask.value,
+]
+
+
+class AtomCache:
+ """Name ↔ Atom cache for the connected display (avoids repeated `XInternAtom` / `XGetAtomName`)."""
+
+ _wm: WindowManager
+
+ def __init__(self, wm: WindowManager) -> None:
+ self._wm = wm
+ self._cache: dict[str, xlib.TYPES.Cython_Atom] = {}
+ self._names: dict[xlib.TYPES.Cython_Atom, str] = {}
+
+ def get_atom(self, name: str, create_if_not_exists: bool = False) -> xlib.TYPES.Cython_Atom:
+ """Return the interned atom for `name`, creating it on first miss when `create_if_not_exists`."""
+ atom: Optional[xlib.TYPES.Cython_Atom] = self._cache.get(name)
+ if atom is None:
+ atom = xlib.x_get_atom_from_name(
+ display=self._wm.display, atom_name=name, create_if_not_exists=create_if_not_exists
+ )
+ if atom:
+ self._cache[name] = atom
+ self._names[atom] = name
+ return atom
+
+ def get_name(self, atom: xlib.TYPES.Cython_Atom) -> Optional[str]:
+ """Return the name for `atom`, or `None` if the server has no name for it."""
+ name: Optional[str] = self._names.get(atom)
+ if name is None:
+ name = xlib.x_get_atom_name(display=self._wm.display, atom=atom)
+ if name:
+ self._names[atom] = name
+ _ = self._cache.setdefault(name, atom)
+ return name
+
+
+class _RestartException(Exception):
+ """Exception raised by method `WindowManager.restart()`"""
+
+ pass
+
+
+@final
+class WindowManager(SingletonMixin):
+ """Core orcsome3 window-manager instance (one per process).
+
+ After `orcsome3` has started, get it from anywhere as::
+
+ from orcsome3 import get_wm
+
+ wm: WindowManager = get_wm()
+
+ `WindowManager()` returns the same singleton once it has been constructed with an event loop.
+
+ Attrs:
+ - `display`: Connected X display
+ - `root`: Root window of that display
+ - `atom_cache`: Interned-atom cache for this display
+ - `focus_history`: X window ids that received FocusIn, oldest first
+ - `track_kbd_layout`: If True, key grabs also lock the keyboard group (see `_handle_focus`)
+ """
+
+ def __init__(self, loop: Optional[ev.Loop] = None) -> None:
+ """Connect to X, wrap the root window, and (if `loop` is given) watch the display fd for events."""
+ if type(self)._singleton_init_done():
+ return
+ self.track_kbd_layout: bool = False
+ self._event_handlers: dict[xlib.XEvent.EVENT_TYPES, Callable[[xlib.XEvent], None]] = {
+ xlib.XEvent.EVENT_TYPES.KeyPress: self._key_press_event_handler,
+ xlib.XEvent.EVENT_TYPES.KeyRelease: self._handle_keyrelease,
+ xlib.XEvent.EVENT_TYPES.ButtonPress: self._handle_button,
+ xlib.XEvent.EVENT_TYPES.CreateNotify: self._handle_create,
+ xlib.XEvent.EVENT_TYPES.DestroyNotify: self._handle_destroy,
+ xlib.XEvent.EVENT_TYPES.MapNotify: self._handle_map,
+ xlib.XEvent.EVENT_TYPES.UnmapNotify: self._handle_unmap,
+ xlib.XEvent.EVENT_TYPES.ConfigureNotify: self._handle_configure,
+ xlib.XEvent.EVENT_TYPES.FocusIn: self._handle_focus,
+ xlib.XEvent.EVENT_TYPES.FocusOut: self._handle_focus,
+ xlib.XEvent.EVENT_TYPES.PropertyNotify: self._handle_property,
+ xlib.XEvent.EVENT_TYPES.ClientMessage: self._handle_client_message,
+ }
+ self._restart_handler: Optional[Callable[[], None]] = None
+ self.focus_history: list[xlib.TYPES.Cython_Window] = []
+ self._focus_ids: set[xlib.TYPES.Cython_Window] = set()
+ self._recently_destroyed_window: Optional[xlib.TYPES.Cython_Window] = None
+ self.display: xlib.TYPES.Cython_Display = xlib.x_open_display()
+ self.root: Window = Window(xlib.get_default_root_window(display=self.display))
+ self._loop: Optional[ev.Loop] = loop
+ self._xevent_watcher: Optional[ev.IOWatcher] = None
+ self.atom_cache: AtomCache = AtomCache(wm=self)
+ self._wm_name: Optional[str] = None
+ self._wm_name_loaded: bool = False
+ self._startup: bool = False
+ self._inited: bool = False
+ self._key_handlers: dict[Optional[WindowMatchers], dict[KeyDefinition, _KeyHandler]] = {}
+ self._key_release_handlers: dict[Optional[WindowMatchers], dict[KeyDefinition, _KeyHandler]] = {}
+ self._key_grabs: dict[xlib.TYPES.Cython_Window, dict[tuple[int, xlib.TYPES.Cython_KeyCode], _KeyGrabSlots]] = {}
+ self._button_handlers: dict[Optional[WindowMatchers], dict[tuple[int, int], _ButtonHandler]] = {}
+ self._button_grabs: dict[xlib.TYPES.Cython_Window, dict[tuple[int, int], _ButtonHandler]] = {}
+ self._create_handlers: list[_CreateHandler] = []
+ self._destroy_handlers: _WindowEventCbs = {}
+ self._focus_handlers: _WindowEventCbs = {}
+ self._unfocus_handlers: _WindowEventCbs = {}
+ self._map_handlers: _WindowEventCbs = {}
+ self._unmap_handlers: _WindowEventCbs = {}
+ self._configure_handlers: _WindowEventCbs = {}
+ self._property_handlers: dict[
+ xlib.TYPES.Cython_Atom, dict[Optional[xlib.TYPES.Cython_Window], list[_PropertyHandler]]
+ ] = {}
+ self._client_message_handlers: dict[
+ xlib.TYPES.Cython_Atom, dict[Optional[xlib.TYPES.Cython_Window], list[_ClientMessageHandler]]
+ ] = {}
+ self._timer_handlers: list[Callable[[], Optional[bool]]] = []
+ self._init_handlers: list[Callable[[], None]] = []
+ self._deinit_handlers: list[Callable[[], None]] = []
+ self._event_window: Optional[Window] = None
+ if loop is not None:
+ # Must stay alive for the life of the instance: __dealloc__ frees the underlying
+ # ev_io C struct, and libev keeps a pointer to it after start() (use-after-free
+ # crash in fd_reify otherwise, once the loop actually runs).
+ self._xevent_watcher = ev.IOWatcher.new(
+ callback=self._xevent_cb,
+ file_descriptor=xlib.get_connection_number(display=self.display),
+ event=ev.IOWatcher.Events.EV_READ,
+ )
+ self._xevent_watcher.start(loop=loop)
+ Window.set_wm(self)
+
+ @property
+ def event_window(self) -> Window:
+ """Window associated with the current event (create, destroy, property, key, map, focus, …)."""
+ return cast(Window, self._event_window)
+
+ @property
+ def startup(self) -> bool:
+ """True while orcsome3 is processing existing clients at startup."""
+ return self._startup
+
+ @property
+ def current_window(self) -> Optional[Window]:
+ """Current active (with input focus) window"""
+ result: Optional[xlib.WindowProperty] = self.root.get_property(property_="_NET_ACTIVE_WINDOW")
+ return None if result is None else Window(result.get_int_list()[0])
+
+ @property
+ def current_desktop(self) -> int:
+ """Current desktop number. This is always an integer between 0 and `_NET_NUMBER_OF_DESKTOPS` - 1"""
+ result: Optional[xlib.WindowProperty] = self.root.get_property(property_="_NET_CURRENT_DESKTOP")
+ return cast(xlib.WindowProperty, result).get_int_list()[0]
+
+ @property
+ def wm_name(self) -> Optional[str]:
+ """
+ Actual window manager name or `None` if there's no ICCCM2.0-compliant window manager running.
+
+ By the EWMH spec, a compliant window manager will set the `_NET_SUPPORTING_WM_CHECK` property on the root window to a window ID.
+
+ If the `_NET_SUPPORTING_WM_CHECK` property exists and contains the ID of an existing window, then a ICCCM2.0-compliant window manager is running.
+
+ If the property exists but does not contain the ID of an existing window, then a ICCCM2.0-compliant window manager exited without proper cleanup.
+
+ If the property does not exist, then no ICCCM2.0-compliant window manager is running.
+ """
+ if self._wm_name_loaded:
+ return self._wm_name
+ result: Optional[xlib.WindowProperty] = self.root.get_property(property_="_NET_SUPPORTING_WM_CHECK")
+ if not result:
+ self._wm_name = None
+ else:
+ name_prop: Optional[xlib.WindowProperty] = Window(result.get_int_list()[0]).get_property(
+ property_="_NET_WM_NAME"
+ )
+ self._wm_name = None if not name_prop else name_prop.get_string_list()[0]
+ self._wm_name_loaded = True
+ return self._wm_name
+
+ # ------------------------------------------ DECORATORS ------------------------------------------
+ def _grab_key_binding(
+ self,
+ window: xlib.TYPES.Cython_Window,
+ key_definition: KeyDefinition,
+ handler: _KeyHandler,
+ *,
+ release: bool = False,
+ ) -> None:
+ """XGrabKey on `window` for the binding, once per CapsLock/NumLock combination in `IGNORED_KEY_MASKS`."""
+ modifiers_value, keycode = key_definition.get_modifiers_value_and_keycode(display=self.display)
+ slot: int = 1 if release else 0
+ for ignored_key_mask in IGNORED_KEY_MASKS:
+ mask: int = modifiers_value | ignored_key_mask
+ xlib.x_grab_key(
+ display=self.display,
+ window=window,
+ keycode=keycode,
+ modifiers=mask,
+ owner_events=False,
+ pointer_mode=xlib.GRAB_MODE.GrabModeAsync,
+ keyboard_mode=xlib.GRAB_MODE.GrabModeAsync,
+ )
+ slots: _KeyGrabSlots = self._key_grabs.setdefault(window, {}).setdefault((mask, keycode), [None, None])
+ slots[slot] = handler
+
+ def _x_ungrab_key_binding(self, window: xlib.TYPES.Cython_Window, key_definition: KeyDefinition) -> None:
+ """XUngrabKey for the CapsLock/NumLock variants. Does not touch `_key_grabs` (press/release share the grab)."""
+ modifiers_value, keycode = key_definition.get_modifiers_value_and_keycode(display=self.display)
+ for ignored_key_mask in IGNORED_KEY_MASKS:
+ mask: int = modifiers_value | ignored_key_mask
+ xlib.x_ungrab_key(display=self.display, keycode=keycode, modifiers=mask, window=window)
+
+ def _x_grab_key_binding(self, window: xlib.TYPES.Cython_Window, key_definition: KeyDefinition) -> None:
+ """XGrabKey for the CapsLock/NumLock variants. Does not touch `_key_grabs`."""
+ modifiers_value, keycode = key_definition.get_modifiers_value_and_keycode(display=self.display)
+ for ignored_key_mask in IGNORED_KEY_MASKS:
+ mask: int = modifiers_value | ignored_key_mask
+ xlib.x_grab_key(
+ display=self.display,
+ window=window,
+ keycode=keycode,
+ modifiers=mask,
+ owner_events=False,
+ pointer_mode=xlib.GRAB_MODE.GrabModeAsync,
+ keyboard_mode=xlib.GRAB_MODE.GrabModeAsync,
+ )
+
+ def _ungrab_handler(self, handler: _KeyHandler) -> None:
+ """Drop every X grab whose callback is `handler` (window ids are reused by the server)."""
+ for window, grabs in list(self._key_grabs.items()):
+ for (mask, keycode), slots in list(grabs.items()):
+ if slots[0] is handler:
+ slots[0] = None
+ if slots[1] is handler:
+ slots[1] = None
+ if slots[0] is not None or slots[1] is not None:
+ continue
+ xlib.x_ungrab_key(display=self.display, keycode=keycode, modifiers=mask, window=window)
+ del grabs[(mask, keycode)]
+ if not grabs:
+ del self._key_grabs[window]
+
+ def _install_key_binding(
+ self,
+ window_matcher: Optional[WindowMatchers],
+ key_definition: KeyDefinition,
+ handler: _KeyHandler,
+ *,
+ release: bool = False,
+ ) -> None:
+ """Grab now: root if `window_matcher` is None, otherwise every matching mapped client."""
+ if window_matcher is None:
+ self._grab_key_binding(window=self.root, key_definition=key_definition, handler=handler, release=release)
+ return
+ for client in self.get_clients():
+ if client.matches(matcher=window_matcher):
+ self._grab_key_binding(window=client, key_definition=key_definition, handler=handler, release=release)
+
+ def _propagate_grabbed_key(self, key_definition: KeyDefinition, x_key_event: xlib.XKeyEvent) -> None:
+ """Temporarily ungrab so the key event can reach the focused client (or the grabbed window), then re-grab."""
+ grab_window: xlib.TYPES.Cython_Window = x_key_event.window
+ self._x_ungrab_key_binding(window=grab_window, key_definition=key_definition)
+ target: xlib.TYPES.Cython_Window = grab_window
+ if grab_window == self.root:
+ focused: Optional[Window] = self.current_window
+ if focused is not None:
+ target = focused
+ event_mask: xlib.INPUT_EVENT_MASKS = (
+ xlib.INPUT_EVENT_MASKS.KeyReleaseMask
+ if x_key_event.type == xlib.XEvent.EVENT_TYPES.KeyRelease
+ else xlib.INPUT_EVENT_MASKS.KeyPressMask
+ )
+ xlib.x_send_event(
+ display=self.display,
+ window=target,
+ propagate=False,
+ xevent=x_key_event,
+ event_masks=event_mask,
+ )
+ xlib.x_flush(display=self.display)
+ self._x_grab_key_binding(window=grab_window, key_definition=key_definition)
+
+ def _install_global_key_handlers(self) -> None:
+ """Grab `on_key` / `on_key_release` bindings that have no window matcher on the root window."""
+ for key_definition, handler in self._key_handlers.get(None, {}).items():
+ self._grab_key_binding(window=self.root, key_definition=key_definition, handler=handler)
+ for key_definition, handler in self._key_release_handlers.get(None, {}).items():
+ self._grab_key_binding(window=self.root, key_definition=key_definition, handler=handler, release=True)
+
+ def _install_key_handlers_for_window(self, window: Window) -> None:
+ """Grab `on_key` / `on_key_release` bindings whose `WindowMatchers` match `window`."""
+ for window_matcher, key_bindings in self._key_handlers.items():
+ if window_matcher is None or not window.matches(matcher=window_matcher):
+ continue
+ for key_definition, handler in key_bindings.items():
+ self._grab_key_binding(window=window, key_definition=key_definition, handler=handler)
+ for window_matcher, key_bindings in self._key_release_handlers.items():
+ if window_matcher is None or not window.matches(matcher=window_matcher):
+ continue
+ for key_definition, handler in key_bindings.items():
+ self._grab_key_binding(window=window, key_definition=key_definition, handler=handler, release=True)
+
+ def _grab_button_binding(
+ self,
+ window: xlib.TYPES.Cython_Window,
+ button: int,
+ modifiers_value: int,
+ handler: _ButtonHandler,
+ ) -> None:
+ """XGrabButton on `window`, once per CapsLock/NumLock combination in `IGNORED_KEY_MASKS`."""
+ for ignored_key_mask in IGNORED_KEY_MASKS:
+ mask: int = modifiers_value | ignored_key_mask
+ xlib.x_grab_button(
+ display=self.display,
+ window=window,
+ button=button,
+ modifiers=mask,
+ owner_events=False,
+ event_mask=xlib.INPUT_EVENT_MASKS.ButtonPressMask.value,
+ pointer_mode=xlib.GRAB_MODE.GrabModeAsync,
+ keyboard_mode=xlib.GRAB_MODE.GrabModeAsync,
+ )
+ self._button_grabs.setdefault(window, {})[(mask, button)] = handler
+
+ def _x_ungrab_button_binding(self, window: xlib.TYPES.Cython_Window, button: int, modifiers_value: int) -> None:
+ """XUngrabButton for the CapsLock/NumLock variants. Does not touch `_button_grabs`."""
+ for ignored_key_mask in IGNORED_KEY_MASKS:
+ mask: int = modifiers_value | ignored_key_mask
+ xlib.x_ungrab_button(display=self.display, button=button, modifiers=mask, window=window)
+
+ def _x_grab_button_binding(self, window: xlib.TYPES.Cython_Window, button: int, modifiers_value: int) -> None:
+ """XGrabButton for the CapsLock/NumLock variants. Does not touch `_button_grabs`."""
+ for ignored_key_mask in IGNORED_KEY_MASKS:
+ mask: int = modifiers_value | ignored_key_mask
+ xlib.x_grab_button(
+ display=self.display,
+ window=window,
+ button=button,
+ modifiers=mask,
+ owner_events=False,
+ event_mask=xlib.INPUT_EVENT_MASKS.ButtonPressMask.value,
+ pointer_mode=xlib.GRAB_MODE.GrabModeAsync,
+ keyboard_mode=xlib.GRAB_MODE.GrabModeAsync,
+ )
+
+ def _ungrab_button_handler(self, handler: _ButtonHandler) -> None:
+ """Drop every button grab whose callback is `handler`."""
+ for window, grabs in list(self._button_grabs.items()):
+ for (mask, button), bound in list(grabs.items()):
+ if bound is not handler:
+ continue
+ xlib.x_ungrab_button(display=self.display, button=button, modifiers=mask, window=window)
+ del grabs[(mask, button)]
+ if not grabs:
+ del self._button_grabs[window]
+
+ def _install_button_binding(
+ self,
+ window_matcher: Optional[WindowMatchers],
+ button: int,
+ modifiers_value: int,
+ handler: _ButtonHandler,
+ ) -> None:
+ """Grab now: root if `window_matcher` is None, otherwise every matching mapped client."""
+ if window_matcher is None:
+ self._grab_button_binding(window=self.root, button=button, modifiers_value=modifiers_value, handler=handler)
+ return
+ for client in self.get_clients():
+ if client.matches(matcher=window_matcher):
+ self._grab_button_binding(
+ window=client, button=button, modifiers_value=modifiers_value, handler=handler
+ )
+
+ def _propagate_grabbed_button(self, button: int, modifiers_value: int, x_button_event: xlib.XButtonEvent) -> None:
+ """Temporarily ungrab so the ButtonPress can reach the focused client (or the grabbed window), then re-grab."""
+ grab_window: xlib.TYPES.Cython_Window = x_button_event.window
+ self._x_ungrab_button_binding(window=grab_window, button=button, modifiers_value=modifiers_value)
+ target: xlib.TYPES.Cython_Window = grab_window
+ if grab_window == self.root:
+ focused: Optional[Window] = self.current_window
+ if focused is not None:
+ target = focused
+ xlib.x_send_event(
+ display=self.display,
+ window=target,
+ propagate=False,
+ xevent=x_button_event,
+ event_masks=xlib.INPUT_EVENT_MASKS.ButtonPressMask,
+ )
+ xlib.x_flush(display=self.display)
+ self._x_grab_button_binding(window=grab_window, button=button, modifiers_value=modifiers_value)
+
+ def _install_global_button_handlers(self) -> None:
+ """Grab `on_button` bindings that have no window matcher on the root window."""
+ for (modifiers_value, button), handler in self._button_handlers.get(None, {}).items():
+ self._grab_button_binding(window=self.root, button=button, modifiers_value=modifiers_value, handler=handler)
+
+ def _install_button_handlers_for_window(self, window: Window) -> None:
+ """Grab `on_button` bindings whose `WindowMatchers` match `window`."""
+ for window_matcher, bindings in self._button_handlers.items():
+ if window_matcher is None or not window.matches(matcher=window_matcher):
+ continue
+ for (modifiers_value, button), handler in bindings.items():
+ self._grab_button_binding(
+ window=window, button=button, modifiers_value=modifiers_value, handler=handler
+ )
+
+ def on_key(
+ self,
+ key_definition: Union[str, KeyDefinition],
+ window_matcher: Optional[WindowMatchers] = None,
+ propagate_event: bool = False,
+ *,
+ release: bool = False,
+ ) -> Callable[[_CbKey], _CbKey]:
+ """
+ Signal decorator to define a hotkey (`XGrabKey`).
+
+ Bindings are recorded when the decorator runs (config import). `init()` grabs them on the X server.
+ After `init()`, a new `@wm.on_key` grabs immediately (including on already-mapped matching clients).
+
+ Args:
+ - `key_definition`: `"Control + d"` (`Modifier + … + key`) or a `KeyDefinition`.
+ Modifiers: `NoModifiers`, `AnyModifier`, `Control`/`Ctrl`, `Alt`/`Meta`, `Shift`, `Win`/`Windows`/`Hyper`/`Super`.
+ Key: an X keysym name (`X11/keysymdef.h` without `XK_`) or `ANY_KEY`.
+ - `window_matcher`: `None` (default) grabs on the **root** window — a process-wide hotkey.
+ The callback's `window` is that root; use `wm.current_window` for the focused client.
+ If set, grabs on every client that matches (at create time, and immediately if already mapped after `init()`).
+ - `propagate_event`: If True, after the handler runs, replay the key event to the focused client
+ (global grab) or the grabbed window (matcher grab), then re-grab. Defaults to `False` (orcsome3 consumes the key).
+
+ Signature of the decorated function::
+
+ def function_cb(window: Window, event: XKeyEvent) -> None: ...
+
+ Global hotkey::
+
+ from orcsome3 import get_wm
+ from orcsome3.keys import KeyboardModifiers, KeyDefinition
+ from orcsome3.libs.xlib import XKeyEvent
+ from orcsome3.window_manager import Window
+
+ wm = get_wm()
+
+ @wm.on_key(key_definition=KeyDefinition(modifiers=KeyboardModifiers.Control, key=KeyDefinition.Key(name="a")))
+ def test_hotkey(window: Window, event: XKeyEvent) -> None:
+ print("hotkey Control + a pressed")
+
+ Per-class grab (every matching window, including ones that already exist 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: Window, event: XKeyEvent) -> None:
+ window.close()
+
+ String form::
+
+ @wm.on_key(key_definition="Control + d")
+ def change_window_desktop(window: Window, event: XKeyEvent) -> None:
+ window.change_desktop(desktop=1)
+
+ Call `.remove()` on the decorated function to ungrab and unregister.
+ """
+
+ def decorator_on_key(original_function: _CbKey) -> _CbKey:
+ try:
+ parsed_key_definition: KeyDefinition = (
+ KeyDefinition.new_from_string(keydef=key_definition)
+ if isinstance(key_definition, str)
+ else key_definition
+ )
+ _ = parsed_key_definition.get_modifiers_value_and_keycode(display=self.display)
+ except Exception as e:
+ _logger.error(msg=f"Invalid key definition: {key_definition}.\n{e}")
+ return original_function
+
+ if window_matcher is not None and window_matcher.is_empty():
+ _logger.error(msg="The window matcher provided is empty.")
+ return original_function
+
+ @wraps(wrapped=original_function)
+ def wrapper(window: xlib.TYPES.Cython_Window, x_key_event: xlib.XKeyEvent) -> None:
+ original_function(window, x_key_event)
+ if propagate_event:
+ self._propagate_grabbed_key(key_definition=parsed_key_definition, x_key_event=x_key_event)
+
+ table: dict[Optional[WindowMatchers], dict[KeyDefinition, _KeyHandler]] = (
+ self._key_release_handlers if release else self._key_handlers
+ )
+ table.setdefault(window_matcher, {})[parsed_key_definition] = wrapper
+
+ def remove() -> None:
+ _ = table.get(window_matcher, {}).pop(parsed_key_definition, None)
+ self._ungrab_handler(handler=wrapper)
+
+ setattr(wrapper, "remove", remove)
+ if self._inited:
+ self._install_key_binding(
+ window_matcher=window_matcher,
+ key_definition=parsed_key_definition,
+ handler=wrapper,
+ release=release,
+ )
+ return cast(_CbKey, wrapper)
+
+ return decorator_on_key
+
+ def on_key_release(
+ self,
+ key_definition: Union[str, KeyDefinition],
+ window_matcher: Optional[WindowMatchers] = None,
+ propagate_event: bool = False,
+ ) -> Callable[[_CbKey], _CbKey]:
+ """Like `on_key`, but KeyRelease. Shares the `XGrabKey` with a matching `on_key` if both are registered."""
+ return self.on_key(
+ key_definition=key_definition,
+ window_matcher=window_matcher,
+ propagate_event=propagate_event,
+ release=True,
+ )
+
+ def on_button(
+ self,
+ button: Union[int, xlib.BUTTONS],
+ modifiers: Union[int, KeyboardModifiers, list[KeyboardModifiers]] = KeyboardModifiers.NoModifiers,
+ window_matcher: Optional[WindowMatchers] = None,
+ propagate_event: bool = False,
+ ) -> Callable[[_CbButton], _CbButton]:
+ """
+ Signal decorator for a pointer grab (`XGrabButton`). ButtonPress only.
+
+ Args:
+ - `button`: `BUTTONS.Button1`…`Button5` or `BUTTONS.AnyButton`.
+ - `modifiers`: A `KeyboardModifiers` member, an int mask, or a list of members. Defaults to no modifiers.
+ - `window_matcher`: `None` (default) grabs on the **root** window. If set, grabs on matching clients.
+ - `propagate_event`: If True, replay the ButtonPress after the handler, then re-grab.
+
+ Signature of the decorated function::
+
+ def function_cb(window: Window, event: XButtonEvent) -> None: ...
+
+ @wm.on_button(button=BUTTONS.Button1, modifiers=KeyboardModifiers.Control)
+ def on_ctrl_click(window: Window, event: XButtonEvent) -> None:
+ print(event.x, event.y)
+
+ Call `.remove()` on the decorated function to ungrab and unregister.
+ """
+
+ def decorator_on_button(original_function: _CbButton) -> _CbButton:
+ if window_matcher is not None and window_matcher.is_empty():
+ _logger.error(msg="The window matcher provided is empty.")
+ return original_function
+
+ button_n: int = int(button)
+ modifiers_value: int = _modifiers_mask(modifiers=modifiers)
+
+ @wraps(wrapped=original_function)
+ def wrapper(window: xlib.TYPES.Cython_Window, x_button_event: xlib.XButtonEvent) -> None:
+ original_function(window, x_button_event)
+ if propagate_event:
+ self._propagate_grabbed_button(
+ button=button_n, modifiers_value=modifiers_value, x_button_event=x_button_event
+ )
+
+ self._button_handlers.setdefault(window_matcher, {})[(modifiers_value, button_n)] = wrapper
+
+ def remove() -> None:
+ _ = self._button_handlers.get(window_matcher, {}).pop((modifiers_value, button_n), None)
+ self._ungrab_button_handler(handler=wrapper)
+
+ setattr(wrapper, "remove", remove)
+ if self._inited:
+ self._install_button_binding(
+ window_matcher=window_matcher,
+ button=button_n,
+ modifiers_value=modifiers_value,
+ handler=wrapper,
+ )
+ return cast(_CbButton, wrapper)
+
+ return decorator_on_button
+
+ def on_create(self, matcher: Optional[WindowMatchers] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """
+ Run on CreateNotify, including the existing-client sweep in `init()`.
+
+ Use `@wm.on_create()` or `@wm.on_create(WindowMatchers(...))`. Filter with a `WindowMatchers`
+ instance (not `name=` / `cls=` kwargs).
+
+ `event` is `None` during the startup sweep (there is no real CreateNotify for windows that
+ already existed before orcsome3 started) and a real `XCreateWindowEvent` otherwise::
+
+ @wm.on_create()
+ def on_any_create(window: Window, event: Optional[XCreateWindowEvent]) -> None:
+ print(window.get_name_and_class())
+
+ @wm.on_create(matcher=WindowMatchers(class_="Opera"))
+ def replace_opera(window: Window, event: Optional[XCreateWindowEvent]) -> None:
+ window.close()
+
+ Call `.remove()` on the decorated function to unregister.
+ """
+ return self.on_create_manage(ignore_startup=False, matcher=matcher)
+
+ def on_manage(self, matcher: Optional[WindowMatchers] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """
+ Same as `on_create`, but skipped for windows already mapped when `init()` runs — so `event`
+ is always a real `XCreateWindowEvent` here, never `None`.
+
+ Use `@wm.on_manage()` or `@wm.on_manage(WindowMatchers(...))`.
+
+ Nested per-window hooks belong here so they are not installed once per existing client at startup::
+
+ @wm.on_manage(matcher=WindowMatchers(name="easyeffects", class_="easyeffects"))
+ def on_easyeffects(window: Window, event: XCreateWindowEvent) -> None:
+ @wm.on_destroy(window=window)
+ def on_easyeffects_gone(window: Window, event: XDestroyWindowEvent) -> None:
+ print("easyeffects closed")
+
+ Call `.remove()` to unregister.
+ """
+ return self.on_create_manage(ignore_startup=True, matcher=matcher)
+
+ def on_create_manage(
+ self, ignore_startup: bool, matcher: Optional[WindowMatchers] = None
+ ) -> Callable[[_CbStruct], _CbStruct]:
+ """Shared implementation of `on_create` / `on_manage`. Prefer those in the config.
+
+ Args:
+ - `ignore_startup`: If True, skip the existing-client sweep in `init()` (`on_manage`)
+ - `matcher`: If set, run only when `window` matches
+
+ Sets `.remove()` on the **user** function (the object `@wm.on_create()` returns).
+ """
+
+ def decorator(function: _CbStruct) -> _CbStruct:
+ @wraps(wrapped=function)
+ def wrapper(window: Window, event: Optional[xlib.XCreateWindowEvent]) -> None:
+ if ignore_startup and self._startup:
+ return
+ if matcher is None or window.matches(matcher=matcher):
+ function(window, event)
+
+ def remove() -> None:
+ try:
+ self._create_handlers.remove(wrapper)
+ except ValueError:
+ pass
+
+ self._create_handlers.append(wrapper)
+ setattr(function, "remove", remove)
+ return function
+
+ return decorator
+
+ def _window_event_cb_decorator(
+ self, handlers: _WindowEventCbs, window: Optional[xlib.TYPES.Cython_Window]
+ ) -> Callable[[_CbStruct], _CbStruct]:
+ """`on_destroy`-style: `window=None` is every window, else one id. Callback is `(window, event)`."""
+
+ def decorator(function: _CbStruct) -> _CbStruct:
+ def remove() -> None:
+ try:
+ handlers[window].remove(function)
+ except Exception:
+ _logger.exception(msg="An exception occurred removing the function.")
+
+ handlers.setdefault(window, []).append(function)
+ setattr(function, "remove", remove)
+ return function
+
+ return decorator
+
+ def _dispatch_window_event_cbs(
+ self,
+ handlers: _WindowEventCbs,
+ window: xlib.TYPES.Cython_Window,
+ event: xlib.XEvent,
+ ) -> None:
+ """Run `window=None` handlers, then per-id handlers. Sets `event_window`."""
+ self._event_window = Window(window)
+ event_window: Window = self._event_window
+ if None in handlers:
+ for handler in handlers[None]:
+ handler(event_window, event)
+ if window in handlers:
+ for handler in handlers[window]:
+ handler(event_window, event)
+
+ def on_destroy(self, window: Optional[xlib.TYPES.Cython_Window] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for DestroyNotify.
+
+ `@wm.on_destroy()` runs for every destroyed window. Pass `window=` (typically the `window`
+ parameter from `on_manage`) to listen to one id.
+
+ `window` is only that id; reading properties on it will fail (it is already gone)::
+
+ @wm.on_destroy()
+ def cb_destroy_window(window: Window, event: XDestroyWindowEvent) -> None:
+ print(f"The window {window} was destroyed")
+
+ @wm.on_manage(matcher=WindowMatchers(name="easyeffects", class_="easyeffects"))
+ def on_create_easyeffects(window: Window, event: XCreateWindowEvent) -> None:
+ @wm.on_destroy(window=window)
+ def on_destroy_easyeffects(window: Window, event: XDestroyWindowEvent) -> None:
+ print("easyeffect's window destroyed")
+
+ Call `.remove()` on the decorated function to unregister.
+ """
+ return self._window_event_cb_decorator(handlers=self._destroy_handlers, window=window)
+
+ def on_property_change(
+ self, property: str, window: Optional[xlib.TYPES.Cython_Window] = None
+ ) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for PropertyNotify (new value only).
+
+ `property` is an atom name (`_NET_WM_STATE`, …). `window=None` (default) is every window;
+ pass `window=` (typically the `window` parameter from `on_manage`) to watch one id.
+
+ @wm.on_property_change(property="_NET_WM_STATE")
+ def window_maximized_state_change(window: Window, event: XPropertyEvent) -> None:
+ if window.maximized_vert and window.maximized_horz:
+ print("The window is maximized now!")
+
+ @wm.on_manage()
+ def switch_to_desktop(window: Window, event: XCreateWindowEvent) -> None:
+ if window.activate_desktop() is None:
+
+ @wm.on_property_change(window=window, property="_NET_WM_DESKTOP")
+ def property_was_set(window: Window, event: XPropertyEvent) -> None:
+ window.activate_desktop()
+ property_was_set.remove()
+
+ Call `.remove()` on the decorated function to unregister.
+ """
+
+ def decorator(function: _CbStruct) -> _CbStruct:
+ def remove() -> None:
+ try:
+ self._property_handlers[self.atom_cache.get_atom(name=property)][window].remove(function)
+ except Exception:
+ _logger.exception(msg="An exception occurred removing the function.")
+
+ self._property_handlers.setdefault(self.atom_cache.get_atom(name=property), {}).setdefault(
+ window, []
+ ).append(function)
+ setattr(function, "remove", remove)
+ return function
+
+ return decorator
+
+ def on_focus(self, window: Optional[xlib.TYPES.Cython_Window] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for FocusIn (`NotifyNormal` / `NotifyWhileGrabbed`).
+
+ `@wm.on_focus()` runs for every focused window. Pass `window=` (typically the `window`
+ parameter from `on_manage`) to listen to one id.
+
+ Signature::
+
+ def function_cb(window: Window, event: XFocusChangeEvent) -> None: ...
+
+ Call `.remove()` on the decorated function to unregister.
+ """
+ return self._window_event_cb_decorator(handlers=self._focus_handlers, window=window)
+
+ def on_unfocus(self, window: Optional[xlib.TYPES.Cython_Window] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for FocusOut (`NotifyNormal` / `NotifyWhileGrabbed`). Same `window=` rules as `on_focus`."""
+ return self._window_event_cb_decorator(handlers=self._unfocus_handlers, window=window)
+
+ def on_map(self, window: Optional[xlib.TYPES.Cython_Window] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for MapNotify. Same `window=` rules as `on_destroy`.
+
+ Signature::
+
+ def function_cb(window: Window, event: XMapEvent) -> None: ...
+ """
+ return self._window_event_cb_decorator(handlers=self._map_handlers, window=window)
+
+ def on_unmap(self, window: Optional[xlib.TYPES.Cython_Window] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for UnmapNotify. Same `window=` rules as `on_destroy`.
+
+ Signature::
+
+ def function_cb(window: Window, event: XUnmapEvent) -> None: ...
+ """
+ return self._window_event_cb_decorator(handlers=self._unmap_handlers, window=window)
+
+ def on_configure(self, window: Optional[xlib.TYPES.Cython_Window] = None) -> Callable[[_CbStruct], _CbStruct]:
+ """Signal decorator for ConfigureNotify. Same `window=` rules as `on_destroy`.
+
+ Fires often during interactive resize. `event.x`/`event.y` are relative to the parent;
+ `event.width`/`event.height` are the size from this event (not a later `get_geometry()`).
+
+ Signature::
+
+ def function_cb(window: Window, event: XConfigureEvent) -> None: ...
+ """
+ return self._window_event_cb_decorator(handlers=self._configure_handlers, window=window)
+
+ def on_client_message(
+ self, message_type: str, window: Optional[xlib.TYPES.Cython_Window] = None
+ ) -> Callable[[_CbClient], _CbClient]:
+ """Signal decorator for ClientMessage.
+
+ `message_type` is an atom name (`_NET_WM_STATE`, `_NET_ACTIVE_WINDOW`, …). `window=None` (default)
+ is every destination this connection sees; pass `window=` to watch one id.
+
+ EWMH messages sent to the root with `SubstructureNotify` arrive here (`event_window` is the root).
+ Messages targeted at other clients' windows are only delivered to this connection if the sender
+ used a non-zero event mask this process selected.
+
+ Signature of the decorated function::
+
+ def function_cb(window: Window, event: XClientMessageEvent) -> None: ...
+
+ Call `.remove()` on the decorated function to unregister.
+ """
+
+ def decorator(original_function: _CbClient) -> _CbClient:
+ atom: xlib.TYPES.Cython_Atom = self.atom_cache.get_atom(name=message_type)
+
+ def remove() -> None:
+ try:
+ self._client_message_handlers[atom][window].remove(original_function)
+ except Exception:
+ _logger.exception(msg="An exception occurred removing the function.")
+
+ self._client_message_handlers.setdefault(atom, {}).setdefault(window, []).append(original_function)
+ setattr(original_function, "remove", remove)
+ return original_function
+
+ return decorator
+
+ def on_timer(
+ self, timeout: float, start: bool = True, first_timeout: Optional[float] = None
+ ) -> Callable[[_CbTimer], _CbTimer]:
+ """Signal decorator for a repeating libev timer.
+
+ Signature of decorated function should be::
+
+ function_cb() -> Optional[bool]:
+ # return True to stop the timer
+
+ The wrapper gains `.start()`, `.stop()`, `.again()`, `.remaining()`, `.overdue(seconds)`, and `.remove()`.
+
+ Args:
+ - `timeout`: Repeat interval in seconds
+ - `start`: If True, start the timer immediately
+ - `first_timeout`: Delay before the first fire; `None` means `timeout`. `0` fires as soon as the loop runs.
+ """
+
+ def decorator(function: _CbTimer) -> _CbTimer:
+ loop: Optional[ev.Loop] = self._loop
+ if loop is None:
+ raise RuntimeError("WindowManager was created without an event loop")
+
+ def callback_of_timer(__loop__: ev.Loop, __watcher__: ev.TimerWatcher, __events__: int) -> None:
+ if function():
+ timer.stop(loop=loop)
+ else:
+ timer.update_next_stop()
+
+ timer: ev.TimerWatcher = ev.TimerWatcher.new(
+ callback=callback_of_timer,
+ after=timeout if first_timeout is None else first_timeout,
+ repeat=timeout,
+ )
+
+ self._timer_handlers.append(function)
+ setattr(
+ function,
+ "start",
+ lambda after=0.0, repeat=0.0: timer.start(loop=loop, after=after, repeat=repeat),
+ )
+ setattr(function, "stop", lambda: timer.stop(loop=loop))
+ setattr(function, "again", lambda: timer.again(loop=loop))
+ setattr(function, "remaining", lambda: timer.remaining(loop=loop))
+
+ def overdue(overdue_by: float) -> bool:
+ return timer.overdue(timeout=overdue_by)
+
+ setattr(function, "overdue", overdue)
+
+ if start:
+ getattr(function, "start")()
+
+ def remove() -> None:
+ try:
+ getattr(function, "stop")()
+ self._timer_handlers.remove(function)
+ except Exception:
+ _logger.exception(msg="An exception occurred removing the function.")
+
+ setattr(function, "remove", remove)
+ return function
+
+ return decorator
+
+ def on_init(self, func: _CbNone) -> _CbNone:
+ """`@wm.on_init` (no parentheses). Runs in `init()` after root events are selected, before existing clients."""
+ self._init_handlers.append(func)
+ return func
+
+ def on_deinit(self, func: _CbNone) -> _CbNone:
+ """`@wm.on_deinit` (no parentheses). Runs in `stop()` after key grabs and timers are torn down."""
+ self._deinit_handlers.append(func)
+ return func
+
+ def init(self) -> None:
+ """Select root events, run `on_init` handlers, grab global keys, then fire create handlers for existing clients."""
+ xlib.x_select_input(
+ display=self.display, window=self.root, event_mask=xlib.INPUT_EVENT_MASKS.SubstructureNotifyMask
+ )
+
+ for handler in self._init_handlers:
+ handler()
+
+ self._install_global_key_handlers()
+ self._install_global_button_handlers()
+ self._startup = True
+ for window in self.get_clients():
+ self._process_create_window(window=window)
+
+ xlib.x_sync(display=self.display, discard=False)
+ self._startup = False
+ self._inited = True
+
+ def default_error_handler(
+ __display__: xlib.TYPES.Cython_Display, error: xlib.TYPES.EVENTS.Cython_XErrorEvent
+ ) -> None:
+ """Default callback for errors"""
+ if not _ignore_logger:
+ err: xlib.XErrorEvent = xlib.XErrorEvent(error_event=error)
+ msg_resource: str = f"{'0x%0.2X' % int(err.resourceid)}:{int(err.resourceid)}"
+ _logger.error(msg=f"{err.msg} ({msg_resource})")
+
+ xlib.x_set_error_handler(handler=default_error_handler)
+
+ def stop(self, exit: bool = False) -> None:
+ """Clear handlers and ungrab keys and buttons.
+
+ Args:
+ - `exit`: If True, close the X display (process shutdown). If False, ungrab keys/buttons so a restart can re-init.
+ """
+ self._inited = False
+ self._startup = False
+ self._wm_name = None
+ self._wm_name_loaded = False
+ self._key_handlers.clear()
+ self._key_release_handlers.clear()
+ self._key_grabs.clear()
+ self._button_handlers.clear()
+ self._button_grabs.clear()
+ self._property_handlers.clear()
+ self._client_message_handlers.clear()
+ self._create_handlers[:] = []
+ self._destroy_handlers.clear()
+ self._focus_handlers.clear()
+ self._unfocus_handlers.clear()
+ self._map_handlers.clear()
+ self._unmap_handlers.clear()
+ self._configure_handlers.clear()
+ self.focus_history[:] = []
+ self._focus_ids.clear()
+
+ if not exit:
+ xlib.x_ungrab_key(
+ display=self.display,
+ keycode=xlib.CONSTANTS.KB.ANY_KEY,
+ modifiers=xlib.KEY_MASKS.AnyModifier.value,
+ window=self.root,
+ )
+ xlib.x_ungrab_button(
+ display=self.display,
+ button=xlib.BUTTONS.AnyButton.value,
+ modifiers=xlib.KEY_MASKS.AnyModifier.value,
+ window=self.root,
+ )
+ for window in self.get_clients():
+ xlib.x_ungrab_key(
+ display=self.display,
+ keycode=xlib.CONSTANTS.KB.ANY_KEY,
+ modifiers=xlib.KEY_MASKS.AnyModifier.value,
+ window=window,
+ )
+ xlib.x_ungrab_button(
+ display=self.display,
+ button=xlib.BUTTONS.AnyButton.value,
+ modifiers=xlib.KEY_MASKS.AnyModifier.value,
+ window=window,
+ )
+ else:
+ xlib.x_close_display(display=self.display)
+
+ for handler in self._timer_handlers:
+ try:
+ getattr(handler, "stop")()
+ except:
+ _logger.exception(msg="Shutdown error")
+
+ self._timer_handlers[:] = []
+
+ for handler in self._deinit_handlers:
+ try:
+ handler()
+ except:
+ _logger.exception(msg="Shutdown error")
+
+ self._init_handlers[:] = []
+ self._deinit_handlers[:] = []
+
+ def get_atom(self, name: str, create_if_not_exists: bool = False) -> xlib.TYPES.Cython_Atom:
+ """Intern `name` on this display (`XInternAtom`). Prefer `atom_cache.get_atom` for repeated lookups."""
+ return self.atom_cache.get_atom(name=name, create_if_not_exists=create_if_not_exists)
+
+ def get_keycode_from_string_or_keysym(
+ self, key: Union[str, xlib.TYPES.Cython_KeySym]
+ ) -> Optional[xlib.TYPES.Cython_KeyCode]:
+ """Get keycode for `key`"""
+ return keycode_from_string_or_keysym(display=self.display, key=key)
+
+ def get_clients(self) -> list[Window]:
+ """Return wm client list"""
+ result: Optional[xlib.WindowProperty] = self.root.get_property(property_="_NET_CLIENT_LIST")
+ return [] if result is None else [Window(x) for x in result.get_int_list()]
+
+ def get_stacked_clients(self) -> list[Window]:
+ """Return client list in stacked order.
+
+ Most top window will be last in list. Can be useful to determine window visibility.
+ """
+ result: Optional[xlib.WindowProperty] = self.root.get_property(property_="_NET_CLIENT_LIST_STACKING")
+ return [] if result is None else [Window(r) for r in result.get_int_list()]
+
+ def send_event_to_x_server_to_root_window(
+ self, window: int, message_type: str, format_: xlib.PROPERTY_FORMAT, data: Union[str, list[int]]
+ ) -> None:
+ """Send an event to the X server on the root window"""
+ xlib.x_send_event(
+ display=self.display,
+ window=self.root,
+ propagate=False,
+ event_masks=xlib.INPUT_EVENT_MASKS.SubstructureRedirectMask,
+ xevent=xlib.XClientMessageEvent(
+ display=self.display,
+ window=window,
+ message_type=self.atom_cache.get_atom(name=message_type, create_if_not_exists=True),
+ format_=format_,
+ data=data,
+ send_event=True,
+ ),
+ )
+
+ def flush(self) -> None:
+ """Flush the output buffer to the X server (`XFlush`)."""
+ xlib.x_flush(display=self.display)
+
+ def find_clients(self, clients: list[Window], matcher: WindowMatchers) -> list[Window]:
+ """
+ Return matching clients list.
+
+ - `clients`: Window list. It can be returned by methods `self.get_clients()` or `self.get_stacked_clients()`
+ - `matcher`: Window Matcher. See class `orcsome3.window_manager.WindowMatchers` for description
+ """
+ return [x for x in clients if x.matches(matcher=matcher)]
+
+ def find_client(self, clients: list[Window], matcher: WindowMatchers) -> Optional[Window]:
+ """
+ Return first matching client.
+
+ - `clients`: Window list. It can be returned by methods `self.get_clients()` or `self.get_stacked_clients()`
+ - `matcher`: Window Matcher. See class `orcsome3.window_manager.WindowMatchers` for description
+ """
+ for client in clients:
+ if client.matches(matcher=matcher):
+ return client
+ return None
+
+ def _process_create_window(self, window: Window, event: Optional[xlib.XCreateWindowEvent] = None) -> None:
+ """Select events on `window`, run create handlers, then grab matching per-window hotkeys.
+
+ `event` is `None` for the startup sweep in `init()` (no real CreateNotify exists for those).
+ """
+ xlib.x_select_input(
+ display=self.display,
+ window=window,
+ event_mask=[
+ xlib.INPUT_EVENT_MASKS.StructureNotifyMask,
+ xlib.INPUT_EVENT_MASKS.PropertyChangeMask,
+ xlib.INPUT_EVENT_MASKS.FocusChangeMask,
+ ],
+ )
+ self._event_window = window
+ for handler in self._create_handlers:
+ handler(window, event)
+ self._install_key_handlers_for_window(window=window)
+ self._install_button_handlers_for_window(window=window)
+
+ def _remember_focus(self, window: xlib.TYPES.Cython_Window) -> None:
+ """Move `window` to the end of `focus_history` (oldest first)."""
+ if window in self._focus_ids:
+ self.focus_history.remove(window)
+ else:
+ self._focus_ids.add(window)
+ self.focus_history.append(window)
+
+ def _forget_focus(self, window: xlib.TYPES.Cython_Window) -> None:
+ """Drop `window` from `focus_history` if present."""
+ if window not in self._focus_ids:
+ return
+ self._focus_ids.discard(window)
+ try:
+ self.focus_history.remove(window)
+ except ValueError:
+ pass
+
+ def _process_remove_window(self, window: xlib.TYPES.Cython_Window) -> None:
+ """Drop destroy/property/key-grab/button-grab state for a window that is gone (X ids are reused)."""
+ _ = self._key_grabs.pop(window, None)
+ _ = self._button_grabs.pop(window, None)
+ for table in (
+ self._destroy_handlers,
+ self._focus_handlers,
+ self._unfocus_handlers,
+ ):
+ _ = table.pop(window, None)
+ for table in (self._map_handlers, self._unmap_handlers, self._configure_handlers):
+ _ = table.pop(window, None)
+
+ self._forget_focus(window=window)
+
+ for atom, whandlers in list(self._property_handlers.items()):
+ if window in whandlers:
+ del whandlers[window]
+
+ if not len(self._property_handlers[atom]):
+ del self._property_handlers[atom]
+
+ for atom, cwhandlers in list(self._client_message_handlers.items()):
+ if window in cwhandlers:
+ del cwhandlers[window]
+
+ if not len(self._client_message_handlers[atom]):
+ del self._client_message_handlers[atom]
+
+ def _lookup_key_handler(self, event: xlib.XKeyEvent, *, release: bool) -> Optional[_KeyHandler]:
+ slots: Optional[_KeyGrabSlots] = self._key_grabs.get(event.window, {}).get((event.state, event.keycode))
+ if slots is None:
+ return None
+ return slots[1 if release else 0]
+
+ def _key_press_event_handler(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XKeyEvent):
+ return
+
+ handler: Optional[_KeyHandler] = self._lookup_key_handler(event=event, release=False)
+ if handler is None:
+ return
+
+ self._event_window = Window(event.window)
+ handler(self._event_window, event)
+
+ def _handle_keyrelease(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XKeyEvent):
+ return
+
+ handler: Optional[_KeyHandler] = self._lookup_key_handler(event=event, release=True)
+ if handler is None:
+ return
+
+ self._event_window = Window(event.window)
+ handler(self._event_window, event)
+
+ def _handle_button(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XButtonEvent):
+ return
+ state: int = event.state & ~_BUTTON_STATE_MASK
+ grabs: dict[tuple[int, int], _ButtonHandler] = self._button_grabs.get(event.window, {})
+ handler: Optional[_ButtonHandler] = grabs.get((state, int(event.button)))
+ if handler is None:
+ handler = grabs.get((state, int(xlib.BUTTONS.AnyButton)))
+ if handler is None:
+ return
+ self._event_window = Window(event.window)
+ handler(self._event_window, event)
+
+ def _handle_create(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XCreateWindowEvent):
+ return
+ _logger.debug(msg=event)
+ self._startup = False
+ window: Window = Window(event.window)
+ global _ignore_logger
+ _ignore_logger = True
+ attrs: Optional[WindowAttributes] = window.attributes
+ if attrs is None or attrs.override_redirect:
+ _ignore_logger = False
+ return
+ _ignore_logger = False
+ self._process_create_window(window=window, event=event)
+
+ def _handle_destroy(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XDestroyWindowEvent):
+ return
+ _logger.debug(msg=event)
+ if event.window == self._recently_destroyed_window:
+ return
+ self._recently_destroyed_window = event.window
+ self._dispatch_window_event_cbs(handlers=self._destroy_handlers, window=event.window, event=event)
+ self._process_remove_window(window=event.window)
+
+ def _handle_property(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XPropertyEvent):
+ return
+ atom: xlib.TYPES.Cython_Atom = event.atom
+ if event.state == xlib.XPropertyEvent.STATE.PropertyNewValue and atom in self._property_handlers:
+ self._event_window = Window(event.window)
+ event_window: Window = self._event_window
+ wphandlers: dict[Optional[xlib.TYPES.Cython_Window], list[_PropertyHandler]] = self._property_handlers[atom]
+ if event.window in wphandlers:
+ for handler in wphandlers[event.window]:
+ handler(event_window, event)
+
+ if None in wphandlers:
+ for handler in wphandlers[None]:
+ handler(event_window, event)
+
+ def _deliver_structure(
+ self,
+ event: Union[xlib.XMapEvent, xlib.XUnmapEvent, xlib.XConfigureEvent],
+ handlers: _WindowEventCbs,
+ ) -> None:
+ # SubstructureNotify on root duplicates StructureNotify on the client; skip the root copy.
+ if event.event == self.root:
+ return
+ self._dispatch_window_event_cbs(handlers=handlers, window=event.window, event=event)
+
+ def _handle_map(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XMapEvent):
+ return
+ self._deliver_structure(event=event, handlers=self._map_handlers)
+
+ def _handle_unmap(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XUnmapEvent):
+ return
+ self._deliver_structure(event=event, handlers=self._unmap_handlers)
+
+ def _handle_configure(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XConfigureEvent):
+ return
+ self._deliver_structure(event=event, handlers=self._configure_handlers)
+
+ def _handle_client_message(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XClientMessageEvent):
+ return
+ atom: xlib.TYPES.Cython_Atom = event.message_type
+ if atom not in self._client_message_handlers:
+ return
+ self._event_window = Window(event.window)
+ wphandlers: dict[Optional[xlib.TYPES.Cython_Window], list[_ClientMessageHandler]] = (
+ self._client_message_handlers[atom]
+ )
+ event_window: Window = self._event_window
+ if event.window in wphandlers:
+ for handler in wphandlers[event.window]:
+ handler(event_window, event)
+ if None in wphandlers:
+ for handler in wphandlers[None]:
+ handler(event_window, event)
+
+ def _handle_focus(self, event: xlib.XEvent) -> None:
+ if not isinstance(event, xlib.XFocusChangeEvent):
+ return
+ _logger.debug(msg=event)
+ is_real_focus: bool = event.mode in (
+ xlib.XFocusChangeEvent.NOTIFY_MODE.NotifyNormal,
+ xlib.XFocusChangeEvent.NOTIFY_MODE.NotifyWhileGrabbed,
+ )
+ if event.type == xlib.XEvent.EVENT_TYPES.FocusIn:
+ self._remember_focus(window=event.window)
+ if is_real_focus:
+ self._dispatch_window_event_cbs(handlers=self._focus_handlers, window=event.window, event=event)
+ if is_real_focus and self.track_kbd_layout:
+ prop: Optional[xlib.WindowProperty] = Window(event.window).get_property(property_="_ORCSOME_KBD_GROUP")
+ if prop is not None:
+ _ = xlib.x_kb_lock_group(
+ display=self.display,
+ group=xlib.KEYSYM_GROUPS(prop.get_int_list()[0]) if prop else xlib.KEYSYM_GROUPS.XkbGroup1Index,
+ )
+ else:
+ if is_real_focus:
+ self._dispatch_window_event_cbs(handlers=self._unfocus_handlers, window=event.window, event=event)
+ if is_real_focus and self.track_kbd_layout:
+ _ = Window(event.window).set_property(
+ property_name="_ORCSOME_KBD_GROUP",
+ type_="CARDINAL",
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ property_data=array("l", [xlib.x_kb_get_state(display=self.display).group]),
+ )
+
+ def _xevent_cb(self, __loop__: ev.Loop, __watcher__: ev.IOWatcher, __revents__: int) -> None:
+ try:
+ while True:
+ pending_events: int = xlib.x_pending(display=self.display)
+ if not pending_events:
+ break
+
+ while pending_events > 0:
+ try:
+ event: xlib.XEvent = xlib.x_next_event(display=self.display)
+ except Exception:
+ _logger.exception(msg="XNextEvent failed")
+ return
+ pending_events -= 1
+
+ try:
+ handler: Callable[[xlib.XEvent], None] = self._event_handlers[event.type]
+ except KeyError:
+ continue
+
+ try:
+ handler(event)
+ except _RestartException:
+ if self._restart_handler is not None:
+ self._restart_handler()
+ return
+ except Exception as e:
+ _logger.exception(msg=e)
+ except Exception as e:
+ _logger.exception(msg=e)
+
+ def get_screen_size(self) -> Optional[xlib.XWindowGeometry]:
+ """Get size of screen (root window)"""
+ return self.root.get_geometry()
+
+ def get_workarea(self, desktop: Optional[int] = None) -> list[int]:
+ """
+ Get workarea geometry.
+
+ - `desktop`: Desktop for working area receiving. If `None` then `self.current_desktop` is used
+ """
+ result: Optional[xlib.WindowProperty] = self.root.get_property(property_="_NET_WORKAREA")
+ if desktop is None:
+ desktop = self.current_desktop
+ if not desktop or not result:
+ return []
+ return result.get_int_list()[4 * desktop : 4 * desktop + 4]
+
+ def get_screen_saver_info(self) -> Optional[xlib.XScreenSaverInfo]:
+ """
+ This is a wrapper around `XScreenSaverQueryInfo`, returns information about the current
+ state of the screen saver or `None` if there's no screensaver active.
+ """
+ return xlib.x_get_screen_saver_info(display=self.display, drawable=self.root)
+
+ def get_atom_name(self, atom: xlib.TYPES.Cython_Atom) -> Optional[str]:
+ """Return the name associated with an atom"""
+ return self.atom_cache.get_name(atom=atom)
+
+ def reset_dpms(self) -> None:
+ """Reset Display Power Management Signaling (DPMS)"""
+ xlib.reset_dpms(display=self.display)
+
+ def activate_desktop(self, num: int) -> None:
+ """Activate desktop `num`"""
+ if num < 0:
+ return
+
+ self.send_event_to_x_server_to_root_window(
+ window=self.root, message_type="_NET_CURRENT_DESKTOP", format_=xlib.PROPERTY_FORMAT.LONG, data=[num]
+ )
+ self.flush()
+
+ def restart(self) -> None:
+ """Restarts orcsome3"""
+ raise _RestartException()
+
+ def set_restart_handler(self, handler: Callable[[], None]) -> None:
+ """Called when `restart()` raises; the CLI sets this to reload the config without exiting."""
+ self._restart_handler = handler
+
+
+class Window(int):
+ """X window id (`int` subclass) with EWMH helpers.
+
+ Use `wm`, `event_window`, and `current_window` from `WindowManager`. The class holds a process-wide
+ `WindowManager` set by `Window.set_wm` during manager construction.
+ """
+
+ _wm: Optional[WindowManager] = None
+
+ def get_property(self, property_: str) -> Optional[xlib.WindowProperty]:
+ """
+ Returns the property for window.
+
+ The result can be `None` if no property was found or an instance of `orcsome3.libs.xlib.WindowProperty`.
+
+ Params:
+ - `property_`: Property to get
+ """
+ return xlib.x_get_window_property(display=self.wm.display, window=self, property_=property_)
+
+ def set_property(
+ self,
+ property_name: str,
+ type_: str,
+ format_: xlib.PROPERTY_FORMAT,
+ property_data: array[int],
+ mode: xlib.SET_PROPERTY_MODE = xlib.SET_PROPERTY_MODE.PropModeReplace,
+ ) -> bool:
+ """
+ Alters a property of window or sets it if it doesn't exist.
+
+ Params:
+ - `property_name`: Name of the property.
+ - `type_`: Name of the type of the property.
+ - `format_`: Format of the property. See enum `orcsome3.libs.xlib.PROPERTY_FORMAT`.
+ - `property_data`: Actual data of the property, it must be an array of char ('b'), short ('h') or long ('l').
+ - `mode`: Mode of setting the data. Defaults to `xlib.SET_PROPERTY_MODE.PropModeReplace`.
+
+ Returns `True` if the property was succesfully changed, `False` otherwise.
+ """
+ return xlib.x_change_window_property(
+ display=self.wm.display,
+ window_property=xlib.WindowProperty(
+ window=self,
+ property_name=property_name,
+ type_=type_,
+ atom_type=self.wm.atom_cache.get_atom(name=type_, create_if_not_exists=True),
+ format_=format_,
+ property_data=property_data,
+ ),
+ mode=mode,
+ )
+
+ def get_geometry(self) -> Optional[xlib.XWindowGeometry]:
+ """Get geometry without decorations"""
+ return xlib.x_get_window_geometry(display=self.wm.display, window=self)
+
+ def get_tree(self) -> Optional[WindowTree]:
+ """
+ Get window tree of window, returns `None` if no tree was found.
+
+ Returns a `orcsome3.window_manager.WindowTree` object containing info about the tree (root, parent and children windows).
+ """
+ x_window_tree: Optional[xlib.XWindowTree] = xlib.x_get_window_tree(display=self.wm.display, window=self)
+ return None if x_window_tree is None else WindowTree.new_from_x_window_tree(x_window_tree=x_window_tree)
+
+ def get_name_and_class(self) -> Optional[tuple[str, str]]:
+ """Return `WM_CLASS` property"""
+ result: Optional[xlib.WindowProperty] = self.get_property(property_="WM_CLASS")
+ if result is None:
+ return None
+ try:
+ result_list: list[str] = result.get_string_list()
+ return result_list[0], result_list[1]
+ except:
+ return None
+
+ def matches(self, matcher: WindowMatchers) -> bool:
+ """
+ Check if window suits given matchers defined in `matcher`. If `matcher` is empty `False` is returned.
+
+ Params:
+ - `matcher`: a `WindowMatchers` instance containing the desired matchers
+
+ See class `orcsome3.window_manager.WindowMatchers` for description.
+ """
+ if matcher.is_empty():
+ return False
+ if matcher.name is not None or matcher.class_ is not None:
+ name_and_class: Optional[tuple[str, str]] = self.get_name_and_class()
+ name: str = "" if name_and_class is None else name_and_class[0]
+ class_: str = "" if name_and_class is None else name_and_class[1]
+ if matcher.name is not None and not match_string(pattern=matcher.name, string=name):
+ return False
+ if matcher.class_ is not None and not match_string(pattern=matcher.class_, string=class_):
+ return False
+ if matcher.role is not None and not match_string(pattern=matcher.role, string=self.role or ""):
+ return False
+ if matcher.title is not None and not match_string(pattern=matcher.title, string=self.title or ""):
+ return False
+ if matcher.desktop is not None and matcher.desktop != self.desktop:
+ return False
+ if matcher.window_type is not None:
+ if not len(matcher.window_type) or not self.type_:
+ return False
+ for desired_type in matcher.window_type:
+ if not match_string(pattern=desired_type, string=self.type_):
+ return False
+ return True
+
+ def get_windows_same_pid(self) -> list[Window]:
+ """Windows under the root tree that share this window's `_NET_WM_PID` (empty if pid is unknown)."""
+ if self.pid is None:
+ return []
+
+ windows_associated: list[Window] = []
+
+ window_tree: Optional[WindowTree] = self.wm.root.get_tree()
+
+ if window_tree is None or not len(window_tree.children):
+ return []
+
+ for window_child in window_tree.children:
+ if window_child.pid == self.pid:
+ windows_associated.append(window_child)
+
+ return windows_associated
+
+ def set_icon(self, icon: Union[Path, str]) -> bool:
+ """Set `_NET_WM_ICON` from an image file. Fails if the path is missing or larger than 10 MB."""
+ if isinstance(icon, str):
+ icon = Path(icon)
+ if not icon.is_file():
+ _logger.error(msg=f'The path "{icon}" is not a valid file')
+ return False
+ if icon.stat().st_size > (10 * 1000000):
+ _logger.error(msg="The maximum icon size is 10Mb")
+ return False
+ return xlib.set_window_icon(display=self.wm.display, window=self, icon_path=icon)
+
+ def focus(self) -> None:
+ """Activate window"""
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type="_NET_ACTIVE_WINDOW",
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[2, xlib.CONSTANTS.CURRENT_TIME],
+ )
+ self.wm.flush()
+
+ def focus_and_raise(self) -> None:
+ """Activate window desktop, set input focus and raise `self`"""
+ _ = self.activate_desktop()
+ xlib.x_configure_window(
+ display=self.wm.display,
+ window=self,
+ value_mask=[xlib.WINDOW_VALUE_MASK.CWStackMode],
+ window_changes=xlib.XWindowChanges(stack_mode=xlib.XWindowChanges.StackMode.Above),
+ )
+ self.wm.flush()
+ self.focus()
+
+ def place_above(self) -> None:
+ """Float up window in wm stack"""
+ xlib.x_configure_window(
+ display=self.wm.display,
+ window=self,
+ value_mask=[xlib.WINDOW_VALUE_MASK.CWStackMode],
+ window_changes=xlib.XWindowChanges(stack_mode=xlib.XWindowChanges.StackMode.Above),
+ )
+ self.wm.flush()
+
+ def place_below(self) -> None:
+ """Float down window in wm stack"""
+ xlib.x_configure_window(
+ display=self.wm.display,
+ window=self,
+ value_mask=[xlib.WINDOW_VALUE_MASK.CWStackMode],
+ window_changes=xlib.XWindowChanges(stack_mode=xlib.XWindowChanges.StackMode.Below),
+ )
+ self.wm.flush()
+
+ def _change_hidden_state(self, minimize: bool) -> None:
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type="_NET_WM_STATE",
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(minimize), self.wm.get_atom(name="_NET_WM_STATE_HIDDEN")],
+ )
+ self.wm.flush()
+
+ def minimize(self) -> None:
+ """Minimize window"""
+ self._change_hidden_state(minimize=True)
+
+ def restore(self) -> None:
+ """Restore window"""
+ self._change_hidden_state(minimize=False)
+
+ def activate_desktop(self) -> Optional[bool]:
+ """
+ Activate window desktop.
+
+ Returns:
+
+ - `True` if window is placed on different from current desktop (activates window's desktop)
+ - `False` if window desktop and current desktop are the same
+ - `None` if window does not have a desktop property
+ """
+ if self.desktop is not None:
+ if self.wm.current_desktop != self.desktop:
+ self.wm.activate_desktop(num=self.desktop)
+ return True
+ else:
+ return False
+ else:
+ return None
+
+ def set_state(
+ self,
+ taskbar: Optional[bool] = None,
+ pager: Optional[bool] = None,
+ decorate: Optional[bool] = None,
+ otaskbar: Optional[bool] = None,
+ vmax: Optional[bool] = None,
+ hmax: Optional[bool] = None,
+ fullscreen: Optional[bool] = None,
+ ) -> None:
+ """
+ Set window state.
+
+ Params:
+ - `taskbar`: Indicates if the window is going to be included on the taskbar (`_NET_WM_STATE_SKIP_TASKBAR` property)
+ - `pager`: Indicates if the window is going to be included on the Pager (`_NET_WM_STATE_SKIP_PAGER` property)
+ - `decorate`: Indicates if the window is going to have decorations
+ - `otaskbar`: Indicates if the window is going to be ignored by orcsome3
+ - `vmax`: Indicates if the window is going to be maximized vertically (`_NET_WM_STATE_MAXIMIZED_VERT` property)
+ - `hmax`: Indicates if the window is going to be maximized horizontally (`_NET_WM_STATE_MAXIMIZED_HORZ` property)
+ - `fullscreen`: Indicates if the window is going to be fullscreen (`_NET_WM_STATE_FULLSCREEN` property)
+ """
+ state_property_name: str = "_NET_WM_STATE"
+
+ if decorate is not None:
+ if self.wm.wm_name is not None and self.wm.wm_name.lower() == "openbox":
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type=state_property_name,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(not decorate), self.wm.get_atom(name="_OB_WM_STATE_UNDECORATED")],
+ )
+ else:
+ params: list[int] = []
+ motif_hints: Optional[xlib.WindowProperty] = self.get_property(property_="_MOTIF_WM_HINTS")
+ if motif_hints is not None:
+ params = motif_hints.get_int_list()
+ if len(params) == 5:
+ params[2] = int(decorate)
+ else:
+ params = [2, 0, int(decorate), 0, 0]
+ else:
+ # "0x2, 0x0, 0x0, 0x0, 0x0" to undecorate and "0x2, 0x0, 0x1, 0x0, 0x0" to redecorate
+ params = [2, 0, int(decorate), 0, 0]
+ _ = self.set_property(
+ property_name="_MOTIF_WM_HINTS",
+ type_="_MOTIF_WM_HINTS",
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ property_data=array("l", params),
+ )
+
+ if taskbar is not None:
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type=state_property_name,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(not taskbar), self.wm.get_atom(name="_NET_WM_STATE_SKIP_TASKBAR")],
+ )
+
+ if vmax is not None:
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type=state_property_name,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(vmax), self.wm.get_atom(name="_NET_WM_STATE_MAXIMIZED_VERT")],
+ )
+
+ if hmax is not None:
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type=state_property_name,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(hmax), self.wm.get_atom(name="_NET_WM_STATE_MAXIMIZED_HORZ")],
+ )
+
+ if fullscreen is not None:
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type=state_property_name,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(fullscreen), self.wm.get_atom(name="_NET_WM_STATE_FULLSCREEN")],
+ )
+
+ if otaskbar is not None:
+ arr: array[int] = array("l", [] if otaskbar else [self.wm.get_atom(name="_ORCSOME_SKIP_TASKBAR")])
+ _ = self.set_property(
+ property_name="_ORCSOME_STATE", type_="ATOM", format_=xlib.PROPERTY_FORMAT.LONG, property_data=arr
+ )
+
+ if pager is not None:
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type=state_property_name,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[int(not pager), self.wm.get_atom(name="_NET_WM_STATE_SKIP_PAGER")],
+ )
+
+ self.wm.flush()
+
+ def move_resize(
+ self, x: Optional[int] = None, y: Optional[int] = None, w: Optional[int] = None, h: Optional[int] = None
+ ) -> None:
+ """Move/resize relative to the current desktop workarea (`_NET_MOVERESIZE_WINDOW`). Omitted edges stay 0 / 1px min size."""
+
+ flags: int = 0
+ flags |= 2 << 12
+ if x is not None:
+ flags |= 1 << 8
+ if y is not None:
+ flags |= 1 << 9
+ if w is not None:
+ flags |= 1 << 10
+ if h is not None:
+ flags |= 1 << 11
+
+ # Workarea offsets
+ o_x, o_y, _, _ = tuple(self.wm.get_workarea())
+ params: list[int] = [
+ flags,
+ (0 if x == None else x) + o_x,
+ (0 if y == None else y) + o_y,
+ max(1, (0 if w == None else w)),
+ max(1, (0 if h == None else h)),
+ ]
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self, message_type="_NET_MOVERESIZE_WINDOW", format_=xlib.PROPERTY_FORMAT.LONG, data=params
+ )
+ self.wm.flush()
+
+ def move_resize2(self, left: int, top: int, right: int, bottom: int) -> None:
+ """Place the window by workarea insets: `left`/`top` origin, width/height = workarea minus `right`/`bottom`."""
+ flags: int = 0x2F00
+ # Workarea offsets
+ dl, dt, dw, dh = tuple(self.wm.get_workarea(desktop=self.desktop))
+ params: list[int] = [flags, left + dl, top + dt, max(1, dw - right - left), max(1, dh - bottom - top)]
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self, message_type="_NET_MOVERESIZE_WINDOW", format_=xlib.PROPERTY_FORMAT.LONG, data=params
+ )
+ self.wm.flush()
+
+ def close(self) -> None:
+ """Send request to wm to close window"""
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self,
+ message_type="_NET_CLOSE_WINDOW",
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[xlib.CONSTANTS.CURRENT_TIME],
+ )
+ self.wm.flush()
+
+ def change_desktop(self, desktop: int) -> None:
+ """Move window to `desktop`"""
+ if desktop < 0:
+ return
+ self.wm.send_event_to_x_server_to_root_window(
+ window=self, message_type="_NET_WM_DESKTOP", format_=xlib.PROPERTY_FORMAT.LONG, data=[desktop]
+ )
+ self.wm.flush()
+
+ @property
+ def wm(self) -> WindowManager:
+ """Process-wide WindowManager bound by `set_wm` (raises if accessed before the manager exists)."""
+ return cast(WindowManager, self._wm)
+
+ @classmethod
+ def set_wm(cls, window_manager: WindowManager) -> None:
+ """Bind every Window instance to this manager. Called from `WindowManager.__init__`."""
+ cls._wm = window_manager
+
+ @property
+ def desktop(self) -> Optional[int]:
+ """
+ Return window desktop (`_NET_WM_DESKTOP` property). The result can be:
+
+ - Number from 0 to desktop_count - 1
+ - -1 if window placed on all desktops
+ - `None` if window does not have desktop property
+ """
+ result: Optional[xlib.WindowProperty] = self.get_property(property_="_NET_WM_DESKTOP")
+ if result is None:
+ return None
+ try:
+ desktop: int = result.get_int_list()[0]
+ if desktop == 0xFFFFFFF or desktop == 0xFFFFFFFFFFFFFFFF:
+ desktop = -1
+ return desktop
+ except:
+ return None
+
+ @property
+ def role(self) -> Optional[str]:
+ """Return `WM_WINDOW_ROLE` property"""
+ result: Optional[xlib.WindowProperty] = self.get_property(property_="WM_WINDOW_ROLE")
+ if result is None:
+ return None
+ try:
+ return result.get_string_list()[0]
+ except:
+ return None
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return first part from `WM_CLASS` property"""
+ name_and_class: Optional[tuple[str, str]] = self.get_name_and_class()
+ return None if name_and_class is None else name_and_class[0]
+
+ @property
+ def class_(self) -> Optional[str]:
+ """Return second part from `WM_CLASS` property"""
+ name_and_class: Optional[tuple[str, str]] = self.get_name_and_class()
+ return None if name_and_class is None else name_and_class[1]
+
+ @property
+ def title(self) -> Optional[str]:
+ """Return `_NET_WM_NAME` property"""
+ result: Optional[xlib.WindowProperty] = self.get_property(property_="_NET_WM_NAME")
+ if result is None:
+ return None
+ try:
+ return result.get_string_list()[0]
+ except:
+ return None
+
+ @property
+ def attributes(self) -> Optional[WindowAttributes]:
+ """Get current attributes for window"""
+ attrs: Optional[xlib.XWindowAttributes] = xlib.x_get_window_attributes(display=self.wm.display, window=self)
+ return None if attrs is None else WindowAttributes(window=self, root=self.wm.root, xwindowattributes=attrs)
+
+ def _state_atoms(self) -> Optional[list[int]]:
+ """`_NET_WM_STATE` atom ids, or `None` if the property is missing."""
+ states: Optional[xlib.WindowProperty] = self.get_property(property_="_NET_WM_STATE")
+ if states is None or not len(states.property_data):
+ return None
+ return states.get_int_list()
+
+ def _has_net_state(self, atom_name: str) -> bool:
+ """True if `_NET_WM_STATE` contains `atom_name` (one property read)."""
+ atoms: Optional[list[int]] = self._state_atoms()
+ if not atoms:
+ return False
+ return self.wm.atom_cache.get_atom(name=atom_name) in atoms
+
+ @property
+ def state(self) -> Optional[list[str]]:
+ """Return `_NET_WM_STATE` property"""
+ atoms: Optional[list[int]] = self._state_atoms()
+ if atoms is None:
+ return None
+ try:
+ states_: list[str] = []
+ for state in atoms:
+ state_atom_name: Optional[str] = self.wm.get_atom_name(atom=state)
+ if state_atom_name is None:
+ continue
+ states_.append(state_atom_name)
+ return states_
+ except:
+ return None
+
+ @property
+ def maximized_vert(self) -> bool:
+ """Check if atom `_NET_WM_STATE_MAXIMIZED_VERT` is present in window state"""
+ return self._has_net_state(atom_name="_NET_WM_STATE_MAXIMIZED_VERT")
+
+ @property
+ def maximized_horz(self) -> bool:
+ """Check if atom `_NET_WM_STATE_MAXIMIZED_HORZ` is present in window state"""
+ return self._has_net_state(atom_name="_NET_WM_STATE_MAXIMIZED_HORZ")
+
+ @property
+ def decorated(self) -> bool:
+ """
+ Return `False` if window is not decorated otherwise `True`
+
+ window is considered non-decorated when:
+ - There's no window manager running
+ - It has the attribute override-redirect in its window attributes
+ - It has a 0 on the third bit in the property `_MOTIF_WM_HINTS` if the property is present
+ """
+ wm_name: Optional[str] = self.wm.wm_name
+ if wm_name is None:
+ return False
+ attrs: Optional[WindowAttributes] = self.attributes
+ if attrs is not None and attrs.override_redirect:
+ return False
+ if wm_name == "Openbox":
+ return not self._has_net_state(atom_name="_OB_WM_STATE_UNDECORATED")
+ motif_hints: Optional[xlib.WindowProperty] = self.get_property(property_="_MOTIF_WM_HINTS")
+ try:
+ if motif_hints is not None and motif_hints.get_int_list()[2] == 0:
+ return False
+ except:
+ pass
+ return True
+
+ @property
+ def urgent(self) -> bool:
+ """Check if atom `_NET_WM_STATE_DEMANDS_ATTENTION` is present in window state"""
+ return self._has_net_state(atom_name="_NET_WM_STATE_DEMANDS_ATTENTION")
+
+ @property
+ def fullscreen(self) -> bool:
+ """Check if atom `_NET_WM_STATE_FULLSCREEN` is present in window state"""
+ return self._has_net_state(atom_name="_NET_WM_STATE_FULLSCREEN")
+
+ @property
+ def pid(self) -> Optional[int]:
+ """Return `_NET_WM_PID` property"""
+ result: Optional[xlib.WindowProperty] = self.get_property(property_="_NET_WM_PID")
+ if result is None:
+ return None
+ try:
+ return result.get_int_list()[0]
+ except:
+ return None
+
+ @property
+ def type_(self) -> Optional[str]:
+ """Return `_NET_WM_WINDOW_TYPE` property"""
+ window_type: Optional[xlib.WindowProperty] = self.get_property(property_="_NET_WM_WINDOW_TYPE")
+ if window_type is None:
+ return None
+ try:
+ return self.wm.get_atom_name(atom=window_type.get_int_list()[0])
+ except:
+ return None
+
+
+class WindowTree(NamedTuple):
+ """
+ Class representation of a window tree.
+
+ Attrs:
+ - `window`: Window
+ - `root`: Root window
+ - `parent`: Parent window
+ - `children`: Children windows
+ """
+
+ window: Window
+ root: Window
+ parent: Window
+ children: list[Window]
+
+ @classmethod
+ def new_from_x_window_tree(cls, x_window_tree: xlib.XWindowTree) -> WindowTree:
+ """Wrap an `xlib.XWindowTree` so every id is a `Window`."""
+ return cls(
+ window=Window(x_window_tree.window),
+ root=Window(x_window_tree.root),
+ parent=Window(x_window_tree.parent),
+ children=[Window(x) for x in x_window_tree.children],
+ )
+
+
+class WindowAttributes:
+ """
+ Class representation of window attributes.
+
+ Attrs:
+ - `window`: Window
+ - `root`: Root window
+ - `x`: Location of window
+ - `y`: Location of window
+ - `width`: Width of window
+ - `height`: Height of window
+ - `border_width`: Border width of window
+ - `depth`: Depth of window
+ - `override_redirect`: The override-redirect flag specifies whether map and configure requests on this window
+ should override a SubstructureRedirectMask on the parent
+ - `map_state`: Map state. See enum `orcsome3.libs.xlib.XWindowAttributes.MapState`
+ """
+
+ def __init__(self, window: Window, root: Window, xwindowattributes: xlib.XWindowAttributes) -> None:
+ """Copy geometry and map state from the Xlib attributes struct onto this window."""
+ self.window: Window = window
+ self.root: Window = root
+ self.x: int = xwindowattributes.x
+ self.y: int = xwindowattributes.y
+ self.width: int = xwindowattributes.width
+ self.height: int = xwindowattributes.height
+ self.border_width: int = xwindowattributes.border_width
+ self.depth: int = xwindowattributes.depth
+ self.override_redirect: bool = xwindowattributes.override_redirect
+ self.map_state: xlib.XWindowAttributes.MapState = xwindowattributes.map_state
diff --git a/orcsome3_backend.pyi b/orcsome3_backend.pyi
new file mode 100644
index 0000000..ec1841d
--- /dev/null
+++ b/orcsome3_backend.pyi
@@ -0,0 +1,608 @@
+"""Type stub for the compiled orcsome3_backend extension (generated from orcsome3_backend.pyx)."""
+
+from __future__ import annotations
+
+from array import array
+from collections.abc import Callable
+from enum import Enum, IntEnum
+from pathlib import Path
+from typing import Any, Optional, Union
+
+class CONSTANTS(IntEnum):
+ CurrentTime = 0
+ AnyPropertyType = 0
+ NoSymbol = 0
+ AnyKey = 0
+ XkbUseCoreKbd = 256
+ RevertToNone = 0
+ RevertToPointerRoot = 1
+ RevertToParent = 2
+
+class EVENT_TYPES(IntEnum):
+ GenericEvent = 35
+ ErrorEvent = 0
+ KeyPress = 2
+ KeyRelease = 3
+ ButtonPress = 4
+ ButtonRelease = 5
+ MotionNotify = 6
+ EnterNotify = 7
+ LeaveNotify = 8
+ FocusIn = 9
+ FocusOut = 10
+ Expose = 12
+ GraphicsExpose = 13
+ NoExpose = 14
+ VisibilityNotify = 15
+ CreateNotify = 16
+ DestroyNotify = 17
+ UnmapNotify = 18
+ MapNotify = 19
+ MapRequest = 20
+ ReparentNotify = 21
+ ConfigureNotify = 22
+ GravityNotify = 24
+ ResizeRequest = 25
+ ConfigureRequest = 23
+ CirculateNotify = 26
+ CirculateRequest = 27
+ PropertyNotify = 28
+ SelectionClear = 29
+ SelectionRequest = 30
+ SelectionNotify = 31
+ ColormapNotify = 32
+ ClientMessage = 33
+ MappingNotify = 34
+ KeymapNotify = 11
+
+class INPUT_EVENT_MASKS(IntEnum):
+ NoEventMask = 0
+ StructureNotifyMask = 131072
+ SubstructureNotifyMask = 524288
+ SubstructureRedirectMask = 1048576
+ PropertyChangeMask = 4194304
+ FocusChangeMask = 2097152
+ KeyPressMask = 1
+ KeyReleaseMask = 2
+ ButtonPressMask = 4
+ ButtonReleaseMask = 8
+
+class KEY_MASKS(IntEnum):
+ AnyModifier = 32768
+ Mod1Mask = 8
+ ControlMask = 4
+ ShiftMask = 1
+ Mod2Mask = 16
+ Mod4Mask = 64
+ LockMask = 2
+
+class BUTTON_MASKS(IntEnum):
+ AnyModifier = 32768
+ Button1Mask = 256
+ Button2Mask = 512
+ Button3Mask = 1024
+ Button4Mask = 2048
+ Button5Mask = 4096
+
+class BUTTONS(IntEnum):
+ AnyButton = 0
+ Button1 = 1
+ Button2 = 2
+ Button3 = 3
+ Button4 = 4
+ Button5 = 5
+
+class GRAB_MODE(IntEnum):
+ GrabModeSync = 0
+ GrabModeAsync = 1
+
+class WINDOW_VALUE_MASK(IntEnum):
+ CWX = 1
+ CWY = 2
+ CWWidth = 4
+ CWHeight = 8
+ CWBorderWidth = 16
+ CWSibling = 32
+ CWStackMode = 64
+
+class WINDOW_STACKING_METHOD(IntEnum):
+ Above = 0
+ Below = 1
+ TopIf = 2
+ BottomIf = 3
+ Opposite = 4
+
+class SET_PROPERTY_MODE(IntEnum):
+ PropModeReplace = 0
+ PropModePrepend = 1
+ PropModeAppend = 2
+
+class WINDOW_MAP_STATE(IntEnum):
+ IsUnmapped = 0
+ IsUnviewable = 1
+ IsViewable = 2
+
+class SCREENSAVER_STATE(IntEnum):
+ ScreenSaverOff = 0
+ ScreenSaverOn = 1
+ ScreenSaverCycle = 2
+ ScreenSaverDisabled = 3
+
+class SCREENSAVER_KIND(IntEnum):
+ ScreenSaverBlanked = 0
+ ScreenSaverInternal = 1
+ ScreenSaverExternal = 2
+
+class KB_GROUP_INDEX(IntEnum):
+ XkbGroup1Index = 0
+ XkbGroup2Index = 1
+ XkbGroup3Index = 2
+ XkbGroup4Index = 3
+ XkbAnyGroup = 254
+ XkbAllGroups = 255
+
+class NOTIFY_MODES(IntEnum):
+ NotifyNormal = 0
+ NotifyGrab = 1
+ NotifyUngrab = 2
+ NotifyWhileGrabbed = 3
+
+class NOTIFY_DETAILS(IntEnum):
+ NotifyAncestor = 0
+ NotifyVirtual = 1
+ NotifyInferior = 2
+ NotifyNonlinear = 3
+ NotifyNonlinearVirtual = 4
+ NotifyPointer = 5
+ NotifyPointerRoot = 6
+ NotifyDetailNone = 7
+
+class PROPERTY_NOTIFICATION(IntEnum):
+ PropertyNewValue = 0
+ PropertyDelete = 1
+
+class PROPERTY_FORMAT(Enum):
+ CHAR = (8, "b")
+ SHORT = (16, "h")
+ LONG = (32, "l")
+
+ @classmethod
+ def new_from_value(cls, value: Union[str, int]) -> PROPERTY_FORMAT: ...
+
+class DPMS_POWER_LEVEL(IntEnum):
+ DPMSModeOn = 0
+ DPMSModeStandby = 1
+ DPMSModeSuspend = 2
+ DPMSModeOff = 3
+
+class PYLOOP_NEW_LOOP_FLAGS(IntEnum):
+ EVFLAG_AUTO = 0
+ EVFLAG_NOENV = 16777216
+ EVFLAG_FORKCHECK = 33554432
+ EVFLAG_NOINOTIFY = 1048576
+ EVFLAG_SIGNALFD = 2097152
+ EVFLAG_NOSIGMASK = 4194304
+ EVBACKEND_SELECT = 1
+ EVBACKEND_POLL = 2
+ EVBACKEND_EPOLL = 4
+ EVBACKEND_KQUEUE = 8
+ EVBACKEND_DEVPOLL = 16
+ EVBACKEND_PORT = 32
+ EVBACKEND_ALL = 255
+ EVBACKEND_MASK = 65535
+
+class PYLOOP_RUN_LOOP_FLAGS(IntEnum):
+ EVRUN_ALWAYS = 0
+ EVRUN_ONCE = 2
+ EVRUN_NOWAIT = 1
+
+class PYLOOP_BREAK_LOOP_FLAGS(IntEnum):
+ EVBREAK_ALL = 2
+ EVBREAK_ONE = 1
+ EVBREAK_CANCEL = 0
+
+class PYIOWATCHER_INIT_FLAGS(IntEnum):
+ EV_READ = 1
+ EV_WRITE = 2
+ EV_READ_WRITE = 3
+
+class PyDisplay: ...
+
+class PyXEvent:
+ serial: int
+ send_event: int
+ display: PyDisplay
+ window: int
+ type: EVENT_TYPES
+ def _get_specific_event_(self) -> PyXEvent: ...
+
+class PyXErrorEvent(PyXEvent):
+ resourceid: int
+ error_code: int
+ request_code: int
+ minor_code: int
+ msg: str
+
+class PyXKeyEvent(PyXEvent):
+ root: int
+ subwindow: int
+ time: int
+ x: int
+ y: int
+ x_root: int
+ y_root: int
+ state: int
+ keycode: int
+ same_screen: int
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ window: int,
+ root: int,
+ subwindow: int,
+ time: int,
+ x: int,
+ y: int,
+ x_root: int,
+ y_root: int,
+ state: int,
+ keycode: int,
+ same_screen: bool,
+ ) -> PyXKeyEvent: ...
+
+class PyXButtonEvent(PyXEvent):
+ root: int
+ subwindow: int
+ time: int
+ x: int
+ y: int
+ x_root: int
+ y_root: int
+ state: int
+ same_screen: int
+ button: BUTTONS
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ window: int,
+ root: int,
+ subwindow: int,
+ time: int,
+ x: int,
+ y: int,
+ x_root: int,
+ y_root: int,
+ state: int,
+ button: int,
+ same_screen: bool,
+ ) -> PyXButtonEvent: ...
+
+class PyXFocusChangeEvent(PyXEvent):
+ mode: NOTIFY_MODES
+ detail: NOTIFY_DETAILS
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ window: int,
+ mode: int,
+ detail: int,
+ ) -> PyXFocusChangeEvent: ...
+
+class PyXCreateWindowEvent(PyXEvent):
+ parent: int
+ x: int
+ y: int
+ width: int
+ height: int
+ border_width: int
+ override_redirect: int
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ parent: int,
+ window: int,
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ border_width: int,
+ override_redirect: bool,
+ ) -> PyXCreateWindowEvent: ...
+
+class PyXDestroyWindowEvent(PyXEvent):
+ event: int
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ event: int,
+ window: int,
+ ) -> PyXDestroyWindowEvent: ...
+
+class PyXMapEvent(PyXEvent):
+ event: int
+ override_redirect: int
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ event: int,
+ window: int,
+ override_redirect: bool,
+ ) -> PyXMapEvent: ...
+
+class PyXUnmapEvent(PyXEvent):
+ event: int
+ from_configure: int
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ event: int,
+ window: int,
+ from_configure: bool,
+ ) -> PyXUnmapEvent: ...
+
+class PyXConfigureEvent(PyXEvent):
+ event: int
+ x: int
+ y: int
+ width: int
+ height: int
+ border_width: int
+ above: int
+ override_redirect: int
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ event: int,
+ window: int,
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ border_width: int,
+ above: int,
+ override_redirect: bool,
+ ) -> PyXConfigureEvent: ...
+
+class PyXPropertyEvent(PyXEvent):
+ atom: int
+ time: int
+ state: PROPERTY_NOTIFICATION
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ window: int,
+ atom: int,
+ time: int,
+ state: int,
+ ) -> PyXPropertyEvent: ...
+
+class PyXClientMessageEvent(PyXEvent):
+ message_type: int
+ data: array[int]
+ format: PROPERTY_FORMAT
+ @staticmethod
+ def _new_from_python_(
+ type: int,
+ serial: int,
+ send_event: bool,
+ display: PyDisplay,
+ window: int,
+ message_type: int,
+ format: int,
+ data: array[int],
+ ) -> PyXClientMessageEvent: ...
+
+class PyXWindowChanges:
+ x: int
+ y: int
+ width: int
+ height: int
+ border_width: int
+ sibling_window: int
+ stack_mode: WINDOW_STACKING_METHOD
+ @staticmethod
+ def _new_from_python_(
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ border_width: int,
+ sibling_window: int,
+ stack_mode: int,
+ ) -> PyXWindowChanges: ...
+
+class PyXWindowAttributes:
+ x: int
+ y: int
+ width: int
+ height: int
+ border_width: int
+ depth: int
+ root: int
+ map_state: int
+ override_redirect: int
+
+class PyXWindowTree:
+ window: int
+ root: int
+ parent: int
+ children: list[int]
+
+class PyXWindowGeometry:
+ window: int
+ root: int
+ x: int
+ y: int
+ width: int
+ height: int
+ border_width: int
+ depth: int
+
+class PyXScreenSaverInfo:
+ window: int
+ state: int
+ kind: int
+ til_or_since: int
+ idle: int
+ event_mask: int
+
+class PyXkbStateRec:
+ group: int
+ locked_group: int
+ base_group: int
+ latched_group: int
+ mods: int
+ base_mods: int
+ latched_mods: int
+ locked_mods: int
+ compat_state: int
+ grab_mods: int
+ compat_grab_mods: int
+ lookup_mods: int
+ compat_lookup_mods: int
+ ptr_buttons: int
+
+class PyDPMSInfo:
+ state: bool
+ power_level: DPMS_POWER_LEVEL
+
+class PyLoop:
+ @staticmethod
+ def _new_from_python_(flags: Union[int, list[int]]) -> PyLoop: ...
+ def run(self, flags: Union[int, list[int]]) -> None: ...
+ def break_(self, how_to_break_flag: int) -> None: ...
+ def destroy(self) -> None: ...
+
+class PyIOWatcher:
+ callbacks: dict[str, Callable[..., Any]]
+ @staticmethod
+ def _new_from_python_() -> PyIOWatcher: ...
+ def init(self, callbacks: dict[str, Callable[..., Any]], file_descriptor: int, events: int) -> None: ...
+ def start(self, loop: PyLoop) -> None: ...
+ def stop(self, loop: PyLoop) -> None: ...
+
+class PySignalWatcher:
+ callbacks: dict[str, Callable[..., Any]]
+ @staticmethod
+ def _new_from_python_() -> PySignalWatcher: ...
+ def init(self, callbacks: dict[str, Callable[..., Any]], signal_number: int) -> None: ...
+ def start(self, loop: PyLoop) -> None: ...
+ def stop(self, loop: PyLoop) -> None: ...
+
+class PyTimerWatcher:
+ callbacks: dict[str, Callable[..., Any]]
+ @staticmethod
+ def _new_from_python_() -> PyTimerWatcher: ...
+ def init(self, callbacks: dict[str, Callable[..., Any]], after: float, repeat: float) -> None: ...
+ def set_timer(self, after: float, repeat: float) -> None: ...
+ def start(self, loop: PyLoop) -> None: ...
+ def stop(self, loop: PyLoop) -> None: ...
+ def again(self, loop: PyLoop) -> None: ...
+ def remaining(self, loop: PyLoop) -> float: ...
+
+class PyStatWatcher:
+ callbacks: dict[str, Callable[..., Any]]
+ @staticmethod
+ def _new_from_python_() -> PyStatWatcher: ...
+ def init(self, callbacks: dict[str, Callable[..., Any]], path: str, interval: float) -> None: ...
+ def start(self, loop: PyLoop) -> None: ...
+ def stop(self, loop: PyLoop) -> None: ...
+
+def PyXOpenDisplay(display_name: Optional[str]) -> Optional[PyDisplay]: ...
+def PyXCloseDisplay(display: PyDisplay) -> None: ...
+def PyXDefaultRootWindow(display: PyDisplay) -> int: ...
+def PyXConnectionNumber(display: PyDisplay) -> int: ...
+def PyXGrabKey(
+ display: PyDisplay,
+ keycode: int,
+ modifiers: int,
+ window: int,
+ owner_events: bool,
+ pointer_mode: int,
+ keyboard_mode: int,
+) -> int: ...
+def PyXUngrabKey(display: PyDisplay, keycode: int, modifiers: int, window: int) -> int: ...
+def PyXCreateOverrideRedirectWindow(display: PyDisplay, parent: int, width: int, height: int) -> int: ...
+def PyXMapWindow(display: PyDisplay, window: int) -> int: ...
+def PyXDestroyWindow(display: PyDisplay, window: int) -> int: ...
+def PyXSetInputFocus(display: PyDisplay, window: int, revert_to: int) -> int: ...
+def PyXGetInputFocus(display: PyDisplay) -> tuple[int, int]: ...
+def PyXGrabButton(
+ display: PyDisplay,
+ button: int,
+ modifiers: int,
+ window: int,
+ owner_events: bool,
+ event_mask: int,
+ pointer_mode: int,
+ keyboard_mode: int,
+ confine_to: int,
+ cursor: int,
+) -> int: ...
+def PyXUngrabButton(display: PyDisplay, button: int, modifiers: int, window: int) -> int: ...
+def PyXSelectInput(display: PyDisplay, window: int, event_mask: int) -> int: ...
+def PyXConfigureWindow(display: PyDisplay, window: int, value_mask: int, window_changes: PyXWindowChanges) -> int: ...
+def PyXSync(display: PyDisplay, discard: bool) -> int: ...
+def PyXSetErrorHandler(
+ handler: Callable[[PyDisplay, PyXErrorEvent], None],
+) -> Callable[[PyDisplay, PyXErrorEvent], None]: ...
+def PyXInternAtom(display: PyDisplay, atom_name: str, only_if_exists: bool) -> int: ...
+def PyXGetWindowProperty(display: PyDisplay, window: int, property_: str) -> Optional[tuple[array[int], int, str]]: ...
+def PyXChangeProperty(
+ display: PyDisplay,
+ window: int,
+ property_name: str,
+ atom_type: int,
+ format_: int,
+ mode: int,
+ property_data: array[int],
+) -> int: ...
+def PyXGetWindowAttributes(display: PyDisplay, window: int) -> Optional[PyXWindowAttributes]: ...
+def PyXQueryTree(display: PyDisplay, window: int) -> Optional[PyXWindowTree]: ...
+def PyXGetGeometry(display: PyDisplay, window: int) -> Optional[PyXWindowGeometry]: ...
+def PyXScreenSaverQueryInfo(display: PyDisplay, window: int) -> Optional[PyXScreenSaverInfo]: ...
+def PyXkbGetState(display: PyDisplay, device_spec: int) -> PyXkbStateRec: ...
+def PyXkbLockGroup(display: PyDisplay, device_spec: int, group: int) -> int: ...
+def PyXFlush(display: PyDisplay) -> int: ...
+def PyXTestQueryExtension(display: PyDisplay) -> bool: ...
+def PyXTestFakeKeyEvent(display: PyDisplay, keycode: int, press: bool, delay: int) -> int: ...
+def PyXTestFakeButtonEvent(display: PyDisplay, button: int, press: bool, delay: int) -> int: ...
+def PyXTestFakeMotionEvent(display: PyDisplay, screen: int, x: int, y: int, delay: int) -> int: ...
+def PyXGetAtomName(display: PyDisplay, atom: int) -> str: ...
+def PyXSendEvent(display: PyDisplay, window: int, propagate: bool, event_mask: int, xevent: PyXEvent) -> int: ...
+def PyRenderSvgToArgb(filepath: str, size: int) -> Optional[bytes]: ...
+def PySetWindowIcon(display: PyDisplay, window: int, filepath: Path) -> bool: ...
+def PyXStringToKeysym(string: str) -> int: ...
+def PyXKeysymToKeycode(display: PyDisplay, keysym: int) -> int: ...
+def PyXPending(display: PyDisplay) -> int: ...
+def PyXNextEvent(display: PyDisplay) -> PyXEvent: ...
+def PyGetDPMSInfo(display: PyDisplay) -> PyDPMSInfo: ...
+def PyDPMSEnable(display: PyDisplay) -> bool: ...
+def PyDPMSDisable(display: PyDisplay) -> bool: ...
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a0f98c5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,124 @@
+[build-system]
+requires = ["setuptools>=61", "wheel", "Cython>=3.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "orcsome3"
+license = {file = "LICENSE"}
+authors = [
+ {name = "Ahsan Pérez", email = "ahsand.perez@gmail.com"}
+]
+description = "Scripting extension for NETWM compliant window managers"
+requires-python = ">=3.8"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: MIT License",
+ "Development Status :: 4 - Beta",
+ "Environment :: X11 Applications",
+ "Topic :: Desktop Environment :: Window Managers",
+ "Intended Audience :: Developers",
+ "Intended Audience :: End Users/Desktop",
+ "Natural Language :: English",
+]
+dependencies = ["dbus-next", "typing_extensions"]
+dynamic = ["version", "readme"]
+
+[project.optional-dependencies]
+dev = ["mypy", "basedpyright", "ruff", "types-setuptools"]
+
+[project.scripts]
+orcsome3 = "orcsome3.run:run"
+
+[project.urls]
+repository = "https://github.com/ahsand97/orcsome3"
+
+[tool.setuptools]
+zip-safe = false
+packages = [
+ "orcsome3",
+ "orcsome3.libs",
+ "orcsome3.libs.build",
+ "orcsome3.libs.cython_libs",
+]
+
+[tool.setuptools.cmdclass]
+build_py = "orcsome3.libs.build:BuildPy"
+
+[tool.setuptools.package-data]
+"orcsome3" = ["py.typed", "icons/hicolor/index.theme", "icons/hicolor/scalable/apps/*.svg"]
+"orcsome3.libs.cython_libs" = ["**/*.pxd", "**/*.pyx"]
+
+[tool.setuptools.dynamic]
+version = {attr = "orcsome3.common.VERSION"}
+readme = {file = ["README.md"], content-type = "text/markdown"}
+
+[tool.basedpyright]
+typeCheckingMode = "all"
+reportAny = false
+reportExplicitAny = false
+reportDeprecated = false
+enableTypeIgnoreComments = false
+extraPaths = ["."]
+ignore = ["orcsome3/tray.py"]
+
+[[tool.basedpyright.executionEnvironments]]
+root = "orcsome3/notify.py"
+reportUnknownMemberType = false
+reportUnknownVariableType = false
+reportUnknownParameterType = false
+reportOptionalMemberAccess = false
+reportUnknownArgumentType = false
+
+# The compiled orcsome3_backend.so isn't built for a lint-only run (make lint doesn't need
+# make native); these are the only two files allowed to import it, so only they can hit
+# "found the .pyi, but not the matching .so" (reportMissingModuleSource).
+[[tool.basedpyright.executionEnvironments]]
+root = "orcsome3/libs/xlib.py"
+reportMissingModuleSource = false
+
+[[tool.basedpyright.executionEnvironments]]
+root = "orcsome3/libs/ev.py"
+reportMissingModuleSource = false
+
+[[tool.basedpyright.executionEnvironments]]
+root = "tools/generate_backend_stub.py"
+reportUnknownMemberType = false
+reportUnknownVariableType = false
+reportUnknownArgumentType = false
+reportUnknownParameterType = false
+reportMissingModuleSource = false
+
+# unittest: setUp/tearDown, internals, decorator-registered callbacks
+[[tool.basedpyright.executionEnvironments]]
+root = "tests"
+reportPrivateUsage = false
+reportImplicitOverride = false
+reportUninitializedInstanceVariable = false
+reportUnusedFunction = false
+
+[tool.mypy]
+mypy_path = "."
+python_version = "3.10"
+
+[[tool.mypy.overrides]]
+module = "orcsome3.tray"
+ignore_errors = true
+
+[tool.ruff]
+line-length = 120
+target-version = "py38"
+
+[tool.ruff.lint]
+select = ["I", "PTH", "ANN"]
+ignore = ["ANN401"]
+
+[tool.ruff.lint.isort]
+known-first-party = ["orcsome3"]
+
+[tool.cibuildwheel]
+# 3.9–3.19 via cp31* (picks up new CPythons when cibuildwheel is bumped). 3.8 is a separate CI job
+# (cibuildwheel 4 dropped it). Skip musllinux and free-threaded (cpXXXt) builds.
+build = "cp3[9]* cp31*"
+skip = "*-musllinux_* *t-*"
+before-build = "pip install Cython>=3.0"
+environment = { ORCSOME3_BUILD_JOBS = "2" }
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 999a811..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,3 +0,0 @@
-[metadata]
-description_file=README.md
-license_files=LICENSE
diff --git a/setup.py b/setup.py
index 8bbcf34..e8c3c1a 100644
--- a/setup.py
+++ b/setup.py
@@ -1,176 +1,8 @@
-import sys
-from typing import List
+from __future__ import annotations
from setuptools import setup
-import orcsome3.version
+from orcsome3.libs.build import build_extensions
-
-def generate_stubs() -> None:
- """
- This function re-generate the stubs for orcsome3, only meant to be used for development
- """
- try:
- from pathlib import Path
-
- def rmdir(directory: Path) -> None:
- """Delete all items from a directory"""
- directory = Path(directory)
- for item in directory.iterdir():
- if item.is_dir():
- rmdir(directory=item)
- else:
- item.unlink()
- directory.rmdir()
-
- def generate_files() -> Path:
- import mypy.stubgen as stubgen
-
- def initialize_modules(directory: Path) -> None:
- # Initializes orcsome-stubs : __init__.py
- directory.mkdir(parents=True, exist_ok=True)
- directory.joinpath("__init__.py").touch(exist_ok=True)
-
- # Initializes orcsome-stubs.orcsome : __init__.py
- inner_directory = directory.joinpath("orcsome")
- inner_directory.mkdir(parents=True, exist_ok=True)
- inner_directory.joinpath("__init__.py").touch(exist_ok=True)
-
- output_dir = Path(__file__).parent.joinpath("orcsome3-stubs")
- if output_dir.is_dir():
- rmdir(directory=output_dir)
- initialize_modules(directory=output_dir)
- args = ["--verbose", "--output", str(output_dir), str(Path(__file__).parent.joinpath("orcsome3"))]
- sys.argv.extend(args)
- # This will run "stubgen --verbose --output ./orcsome3-stubs ./orcsome3"
- stubgen.main()
- # returns the full path of ./orcsome3-stubs
- return output_dir
-
- def copy_files(dir: Path) -> Path:
- """
- This function will re-order the files created by function `generate_files`
- """
- import shutil
-
- destiny: Path = dir.joinpath("orcsome") # ./orcsome3-stubs/orcsome
- # Copy the file ./orcsome3-stubs/orcsome3/version.pyi to ./orcsome3-stubs/version.pyi
- shutil.copy(src=dir.joinpath("orcsome3", "version.pyi"), dst=dir.joinpath("version.pyi"))
- for file_ in dir.joinpath("orcsome3", "orcsome").iterdir():
- # Copy every single file from ./orcsome3-stubs/orcsome3/orcsome to ./orcsome3-stubs/orcsome
- shutil.copy(src=file_, dst=destiny.joinpath(file_.name))
- rmdir(directory=dir.joinpath("orcsome3")) # remove recursively ./orcsome3-stubs/orcsome3
- return destiny
-
- def complete_stubs(dir: Path) -> None:
- """
- This function will edit every .pyi file and replace the type Incomplete
- for the type Any, also, it will put the decorator @cached_property to the
- necessary methods accordingly to source
- """
- files_to_edit: List[Path] = []
- for file_ in dir.iterdir():
- if file_.name.endswith(".pyi"):
- with file_.open(mode="r") as content:
- lines = content.readlines()
- for line in lines:
- if (
- "from _typeshed import Incomplete" in line
- or "from functools import cached_property" in line
- ):
- files_to_edit.append(file_)
- break
-
- source_files = Path(__file__).parent.joinpath("orcsome3", "orcsome")
- for file_ in files_to_edit:
- src: Path = source_files.joinpath(file_.name.replace(".pyi", ".py"))
- src_content: List[str] = []
- new_content: List[str] = []
- typing_import_src: str = ""
-
- with src.open(mode="r") as src_:
- src_content = src_.readlines()
- for line in src_content:
- if "from typing import" in line:
- typing_import_src = "from typing import " + ", ".join(
- [x for x in line.replace("from typing import ", "").split(sep=", ") if x[0].isupper()]
- )
- break
-
- with file_.open(mode="r") as f_:
- new_content = [line for line in f_.readlines() if "from _typeshed import Incomplete" not in line]
- fix_decorator: bool = False
- list_variables_cached: List[str] = []
- for index, line in enumerate(iterable=new_content):
- line_clean: str = line.translate(str.maketrans("", "", " \n\t\r"))
- if "from typing import" in line:
- new_content[index] = f"{typing_import_src}\n"
- if ":Incomplete" in line_clean:
- nombre_variable: str = line_clean.split(sep=":")[0]
- line_fixed: bool = False
- for src_line in src_content:
- if f"self.{nombre_variable}:" in src_line:
- variable_definition: List[str] = src_line.strip().lstrip().split(sep=":")
- if not len(variable_definition) >= 2:
- continue
- if variable_definition[1].strip():
- new_content[index] = line.replace(
- "Incomplete", variable_definition[1].split("=")[0].strip()
- )
- line_fixed = True
- break
- if not line_fixed:
- new_content[index] = line.replace("Incomplete", "Any")
- if "from functools import cached_property" in line:
- fix_decorator = True
- for index, src_line in enumerate(iterable=src_content):
- if "@cached_property" in src_line:
- list_variables_cached.append(src_content[index + 1])
- if fix_decorator and line.replace(" ...", "") in list_variables_cached:
- new_content[index] = f" @cached_property\n{line}"
-
- with file_.open(mode="w") as f_:
- f_.writelines(new_content)
-
- output_dir: Path = generate_files() # ./orcsome3-stubs
- stub_dir: Path = copy_files(dir=output_dir)
- complete_stubs(dir=stub_dir)
- except ModuleNotFoundError as e:
- print(f"EXCEPTION: {e}")
-
-
-if "--generate-stubs" in sys.argv:
- sys.argv.remove("--generate-stubs")
- generate_stubs()
- exit()
-
-setup(
- name="orcsome3",
- version=orcsome3.version.VERSION,
- author="Ahsan Pérez",
- author_email="ahsand.perez@gmail.com",
- description="Scripting extension for NETWM compliant window managers",
- long_description=open(file="README.rst").read(),
- long_description_content_type="text/x-rst",
- zip_safe=False,
- packages=["orcsome3", "orcsome3.orcsome", "orcsome3-stubs", "orcsome3-stubs.orcsome", "orcsome3.bin"],
- package_data={"orcsome3-stubs": ["*.pyi", "**/*.pyi"], "orcsome3-stubs.orcsome": ["*.pyi", "**/*.pyi"]},
- include_package_data=True,
- cffi_modules=["orcsome3/orcsome/ev_build.py:ffibuilder", "orcsome3/orcsome/xlib_build.py:ffibuilder"],
- setup_requires=["cffi>=1.0.0"],
- install_requires=["cffi>=1.0.0"],
- extras_require={"dev": ["mypy", "mypy-extensions", "typing_extensions"]},
- scripts=["orcsome3/bin/orcsome3"],
- url="https://github.com/ahsand97/orcsome3",
- classifiers=[
- "Programming Language :: Python :: 3",
- "License :: OSI Approved :: MIT License",
- "Development Status :: 4 - Beta",
- "Environment :: X11 Applications",
- "Topic :: Desktop Environment :: Window Managers",
- "Intended Audience :: Developers",
- "Intended Audience :: End Users/Desktop",
- "Natural Language :: English",
- ],
- python_requires=">=3.8",
-)
+_build_dir, extensions = build_extensions(skip_build_external_libs=True, static=True)
+_ = setup(ext_modules=extensions)
diff --git a/tests/test_unit.py b/tests/test_unit.py
new file mode 100644
index 0000000..4df59df
--- /dev/null
+++ b/tests/test_unit.py
@@ -0,0 +1,67 @@
+"""Tests that do not need an X server."""
+
+from __future__ import annotations
+
+import unittest
+
+from orcsome3.instance import _self_check, display_key
+from orcsome3.keys import KeyboardModifiers, KeyDefinition, WindowMatchers
+from orcsome3.libs.xlib import BUTTON_MASKS, KEY_MASKS
+from orcsome3.utils import match_string
+from orcsome3.window_manager import _BUTTON_STATE_MASK, _modifiers_mask
+
+
+class TestDisplayKey(unittest.TestCase):
+ def test_screen_suffix_dropped(self) -> None:
+ _self_check()
+ self.assertEqual(display_key(display=":0.0"), ":0")
+ self.assertNotEqual(display_key(display=":0"), display_key(display=":1"))
+
+
+class TestKeyDefinition(unittest.TestCase):
+ def test_parse_control_d(self) -> None:
+ parsed: KeyDefinition = KeyDefinition.new_from_string(keydef="Control + d")
+ self.assertEqual(parsed.key.name, "d")
+ self.assertEqual(parsed.get_modifiers_value(), KeyboardModifiers.Control.value)
+
+ def test_parse_multi_modifier(self) -> None:
+ parsed: KeyDefinition = KeyDefinition.new_from_string(keydef="Control + Shift + a")
+ self.assertEqual(
+ parsed.get_modifiers_value(),
+ KeyboardModifiers.Control.value | KeyboardModifiers.Shift.value,
+ )
+
+ def test_key_requires_one_attr(self) -> None:
+ with self.assertRaises(Exception):
+ _ = KeyDefinition.Key()
+ with self.assertRaises(Exception):
+ _ = KeyDefinition.Key(name="a", keycode=38)
+
+
+class TestModifiersMask(unittest.TestCase):
+ def test_enum_and_list(self) -> None:
+ self.assertEqual(_modifiers_mask(modifiers=KeyboardModifiers.Alt), int(KeyboardModifiers.Alt))
+ self.assertEqual(
+ _modifiers_mask(modifiers=[KeyboardModifiers.Control, KeyboardModifiers.Shift]),
+ int(KeyboardModifiers.Control) | int(KeyboardModifiers.Shift),
+ )
+
+ def test_button_state_mask_strips_buttons_not_keys(self) -> None:
+ mixed: int = KEY_MASKS.ControlMask.value | BUTTON_MASKS.Button2Mask.value
+ self.assertEqual(mixed & ~_BUTTON_STATE_MASK, KEY_MASKS.ControlMask.value)
+
+
+class TestWindowMatchers(unittest.TestCase):
+ def test_empty(self) -> None:
+ self.assertTrue(WindowMatchers().is_empty())
+ self.assertFalse(WindowMatchers(class_="URxvt").is_empty())
+
+
+class TestMatchString(unittest.TestCase):
+ def test_regex_search(self) -> None:
+ self.assertTrue(match_string(pattern="fox", string="the firefox window"))
+ self.assertFalse(match_string(pattern="^chrome$", string="chromium"))
+
+
+if __name__ == "__main__":
+ _ = unittest.main()
diff --git a/tests/test_x11.py b/tests/test_x11.py
new file mode 100644
index 0000000..fd4aceb
--- /dev/null
+++ b/tests/test_x11.py
@@ -0,0 +1,545 @@
+"""X11 tests: Cython event round-trip and WindowManager decorator dispatch.
+
+Skipped when `XOpenDisplay` fails (no `$DISPLAY` / no X server). Does not call `init()`,
+so it does not grab keys or select on the root of a running session.
+
+Grab *delivery* prefers a nested Xephyr/Xvfb server. On a live session it uses a throwaway
+window and an obscure key (F24…F21), and skips button grabs so the pointer is not warped.
+"""
+
+from __future__ import annotations
+
+import os
+import select
+import shutil
+import subprocess
+import unittest
+from typing import Callable, Optional, cast
+
+from typing_extensions import TypeAlias
+
+import orcsome3.libs.xlib as xlib
+from orcsome3.keys import KeyboardModifiers, KeyDefinition
+from orcsome3.window_manager import Window, WindowManager
+
+_Popen: TypeAlias = subprocess.Popen[bytes]
+_nested_server: Optional[_Popen] = None
+
+
+def _try_spawn_displayfd(argv: list[str]) -> Optional[tuple[_Popen, str]]:
+ if shutil.which(cmd=argv[0]) is None:
+ return None
+ read_fd: int
+ write_fd: int
+ read_fd, write_fd = os.pipe()
+ os.set_inheritable(write_fd, True)
+ proc: Optional[_Popen] = None
+ try:
+ proc = subprocess.Popen(
+ args=[argv[0], "-displayfd", str(write_fd), *argv[1:]],
+ pass_fds=(write_fd,),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except OSError:
+ os.close(fd=read_fd)
+ os.close(fd=write_fd)
+ return None
+ os.close(fd=write_fd)
+ ready: list[int]
+ ready, _, _ = select.select([read_fd], [], [], 5.0)
+ if not ready:
+ proc.kill()
+ _ = proc.wait(timeout=2)
+ os.close(fd=read_fd)
+ return None
+ raw: bytes = os.read(read_fd, 64)
+ os.close(fd=read_fd)
+ line: str = raw.decode().strip()
+ if not line.isdigit():
+ proc.kill()
+ _ = proc.wait(timeout=2)
+ return None
+ return proc, f":{line}"
+
+
+def _spawn_nested_x() -> Optional[tuple[_Popen, str]]:
+ candidates: list[list[str]] = []
+ if os.getenv(key="DISPLAY") and shutil.which(cmd="Xephyr"):
+ candidates.append(["Xephyr", "-screen", "200x200", "-ac"])
+ if shutil.which(cmd="Xvfb"):
+ candidates.append(["Xvfb", "-screen", "0", "200x200x24"])
+ for argv in candidates:
+ spawned: Optional[tuple[_Popen, str]] = _try_spawn_displayfd(argv=argv)
+ if spawned is not None:
+ return spawned
+ return None
+
+
+def _stop_nested_x() -> None:
+ global _nested_server
+ if _nested_server is None:
+ return
+ _nested_server.terminate()
+ try:
+ _ = _nested_server.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ _nested_server.kill()
+ _ = _nested_server.wait(timeout=2)
+ _nested_server = None
+
+
+def _obscure_keycode(display: xlib.TYPES.Cython_Display, *, nested: bool) -> Optional[int]:
+ names: tuple[str, ...] = ("a", "F1") if nested else ("F24", "F23", "F22", "F21")
+ for name in names:
+ keysym: xlib.TYPES.Cython_KeySym = xlib.x_string_to_keysym(string=name)
+ keycode: xlib.TYPES.Cython_KeyCode = xlib.x_keysym_to_keycode(display=display, keysym=keysym)
+ if keycode:
+ return int(keycode)
+ return None
+
+
+def _drain_x_events(wm: WindowManager) -> None:
+ while xlib.x_pending(display=wm.display) > 0:
+ event: xlib.XEvent = xlib.x_next_event(display=wm.display)
+ handler: Optional[Callable[[xlib.XEvent], None]] = wm._event_handlers.get(event.type)
+ if handler is not None:
+ handler(event)
+
+
+def _swallow_x_errors(__display__: xlib.TYPES.Cython_Display, __error__: xlib.TYPES.EVENTS.Cython_XErrorEvent) -> None:
+ return
+
+
+def _try_open_display() -> Optional[xlib.TYPES.Cython_Display]:
+ try:
+ return xlib.x_open_display()
+ except Exception:
+ return None
+
+
+class TestEventRoundTrip(unittest.TestCase):
+ display: xlib.TYPES.Cython_Display
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ opened: Optional[xlib.TYPES.Cython_Display] = _try_open_display()
+ if opened is None:
+ raise unittest.SkipTest("no X display")
+ cls.display = opened
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ xlib.x_close_display(display=cls.display)
+
+ def test_configure_round_trip(self) -> None:
+ original: xlib.XConfigureEvent = xlib.XConfigureEvent(
+ display=self.display,
+ event=1,
+ window=2,
+ x=10,
+ y=20,
+ width=300,
+ height=400,
+ border_width=1,
+ above=99,
+ override_redirect=False,
+ )
+ self.assertIsNotNone(original._cython_event)
+ wrapped: xlib.XConfigureEvent = xlib.XConfigureEvent._new_from_cython_event_(
+ configure_event=cast(xlib.TYPES.EVENTS.Cython_XConfigureEvent, original._cython_event),
+ )
+ self.assertEqual(wrapped.window, 2)
+ self.assertEqual(wrapped.event, 1)
+ self.assertEqual(wrapped.x, 10)
+ self.assertEqual(wrapped.y, 20)
+ self.assertEqual(wrapped.width, 300)
+ self.assertEqual(wrapped.height, 400)
+ self.assertEqual(wrapped.above, 99)
+
+ def test_map_unmap_round_trip(self) -> None:
+ mapped: xlib.XMapEvent = xlib.XMapEvent(display=self.display, event=5, window=6, override_redirect=True)
+ self.assertIsNotNone(mapped._cython_event)
+ wrapped_map: xlib.XMapEvent = xlib.XMapEvent._new_from_cython_event_(
+ map_event=cast(xlib.TYPES.EVENTS.Cython_XMapEvent, mapped._cython_event),
+ )
+ self.assertEqual(wrapped_map.window, 6)
+ self.assertTrue(wrapped_map.override_redirect)
+
+ unmapped: xlib.XUnmapEvent = xlib.XUnmapEvent(display=self.display, event=5, window=6, from_configure=True)
+ self.assertIsNotNone(unmapped._cython_event)
+ wrapped_unmap: xlib.XUnmapEvent = xlib.XUnmapEvent._new_from_cython_event_(
+ unmap_event=cast(xlib.TYPES.EVENTS.Cython_XUnmapEvent, unmapped._cython_event),
+ )
+ self.assertTrue(wrapped_unmap.from_configure)
+
+ def test_button_round_trip(self) -> None:
+ press: xlib.XButtonEvent = xlib.XButtonEvent(
+ display=self.display,
+ type=xlib.XButtonEvent.TYPE.ButtonPress,
+ window=7,
+ root=1,
+ subwindow=0,
+ button=xlib.BUTTONS.Button1,
+ x=4,
+ y=5,
+ state=xlib.KEY_MASKS.ControlMask.value,
+ )
+ self.assertIsNotNone(press._cython_event)
+ wrapped: xlib.XButtonEvent = xlib.XButtonEvent._new_from_cython_event_(
+ button_event=cast(xlib.TYPES.EVENTS.Cython_XButtonEvent, press._cython_event),
+ )
+ self.assertEqual(wrapped.button, xlib.BUTTONS.Button1)
+ self.assertEqual(wrapped.x, 4)
+ self.assertEqual(wrapped.state, xlib.KEY_MASKS.ControlMask.value)
+
+
+class TestWindowManagerSignals(unittest.TestCase):
+ wm: WindowManager
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ probe: Optional[xlib.TYPES.Cython_Display] = _try_open_display()
+ if probe is None:
+ raise unittest.SkipTest("no X display")
+ xlib.x_close_display(display=probe)
+
+ def setUp(self) -> None:
+ WindowManager.delete_singleton()
+ self.wm = WindowManager(loop=None)
+
+ def tearDown(self) -> None:
+ try:
+ self.wm.stop(exit=True)
+ finally:
+ WindowManager.delete_singleton()
+
+ def test_configure_dispatches_client_copy_not_root(self) -> None:
+ seen: list[tuple[int, int, int]] = []
+
+ @self.wm.on_configure()
+ def on_cfg(window: Window, event: xlib.XConfigureEvent) -> None:
+ seen.append((int(window), event.width, event.height))
+
+ client: int = 0x1111
+ root_copy: xlib.XConfigureEvent = xlib.XConfigureEvent(
+ display=self.wm.display,
+ event=self.wm.root,
+ window=client,
+ width=100,
+ height=50,
+ )
+ self.wm._handle_configure(event=root_copy)
+ self.assertEqual(seen, [])
+
+ client_copy: xlib.XConfigureEvent = xlib.XConfigureEvent(
+ display=self.wm.display,
+ event=client,
+ window=client,
+ width=640,
+ height=480,
+ )
+ self.wm._handle_configure(event=client_copy)
+ self.assertEqual(seen, [(client, 640, 480)])
+ self.assertEqual(int(self.wm.event_window), client)
+
+ def test_map_and_unmap(self) -> None:
+ mapped: list[int] = []
+ unmapped: list[bool] = []
+
+ @self.wm.on_map()
+ def on_map(window: Window, _event: xlib.XMapEvent) -> None:
+ mapped.append(int(window))
+
+ @self.wm.on_unmap()
+ def on_unmap(_window: Window, event: xlib.XUnmapEvent) -> None:
+ unmapped.append(event.from_configure)
+
+ client: int = 0x2222
+ self.wm._handle_map(
+ event=xlib.XMapEvent(display=self.wm.display, event=client, window=client, override_redirect=False)
+ )
+ self.wm._handle_unmap(
+ event=xlib.XUnmapEvent(display=self.wm.display, event=client, window=client, from_configure=True)
+ )
+ self.assertEqual(mapped, [client])
+ self.assertEqual(unmapped, [True])
+
+ def test_focus_ignores_pointer_detail_mode(self) -> None:
+ focused: list[int] = []
+ unfocused: list[int] = []
+
+ @self.wm.on_focus()
+ def on_focus(window: Window, _event: xlib.XFocusChangeEvent) -> None:
+ focused.append(int(window))
+
+ @self.wm.on_unfocus()
+ def on_unfocus(window: Window, _event: xlib.XFocusChangeEvent) -> None:
+ unfocused.append(int(window))
+
+ client: int = 0x3333
+ pointer: xlib.XFocusChangeEvent = xlib.XFocusChangeEvent(
+ display=self.wm.display,
+ type=xlib.XFocusChangeEvent.TYPE.FocusIn,
+ window=client,
+ detail=xlib.XFocusChangeEvent.NOTIFY_DETAIL.NotifyPointer,
+ mode=xlib.XFocusChangeEvent.NOTIFY_MODE.NotifyGrab,
+ )
+ self.wm._handle_focus(event=pointer)
+ self.assertEqual(focused, [])
+ self.assertIn(client, self.wm.focus_history)
+
+ real: xlib.XFocusChangeEvent = xlib.XFocusChangeEvent(
+ display=self.wm.display,
+ type=xlib.XFocusChangeEvent.TYPE.FocusIn,
+ window=client,
+ detail=xlib.XFocusChangeEvent.NOTIFY_DETAIL.NotifyNonlinear,
+ mode=xlib.XFocusChangeEvent.NOTIFY_MODE.NotifyNormal,
+ )
+ self.wm._handle_focus(event=real)
+ self.assertEqual(focused, [client])
+
+ out: xlib.XFocusChangeEvent = xlib.XFocusChangeEvent(
+ display=self.wm.display,
+ type=xlib.XFocusChangeEvent.TYPE.FocusOut,
+ window=client,
+ detail=xlib.XFocusChangeEvent.NOTIFY_DETAIL.NotifyNonlinear,
+ mode=xlib.XFocusChangeEvent.NOTIFY_MODE.NotifyNormal,
+ )
+ self.wm._handle_focus(event=out)
+ self.assertEqual(unfocused, [client])
+
+ def test_destroy_and_per_window_handler(self) -> None:
+ all_gone: list[int] = []
+ one_gone: list[int] = []
+ client: int = 0x4444
+
+ @self.wm.on_destroy()
+ def on_any(window: Window, _event: xlib.XDestroyWindowEvent) -> None:
+ all_gone.append(int(window))
+
+ @self.wm.on_destroy(window=client)
+ def on_one(window: Window, _event: xlib.XDestroyWindowEvent) -> None:
+ one_gone.append(int(window))
+
+ event: xlib.XDestroyWindowEvent = xlib.XDestroyWindowEvent(display=self.wm.display, event=client, window=client)
+ self.wm._handle_destroy(event=event)
+ self.wm._handle_destroy(event=event)
+ self.assertEqual(all_gone, [client])
+ self.assertEqual(one_gone, [client])
+ self.assertNotIn(client, self.wm._destroy_handlers)
+
+ def test_client_message(self) -> None:
+ got: list[int] = []
+ atom: xlib.TYPES.Cython_Atom = self.wm.atom_cache.get_atom(name="WM_PROTOCOLS")
+
+ @self.wm.on_client_message(message_type="WM_PROTOCOLS")
+ def on_msg(window: Window, _event: xlib.XClientMessageEvent) -> None:
+ got.append(int(window))
+
+ event: xlib.XClientMessageEvent = xlib.XClientMessageEvent(
+ display=self.wm.display,
+ window=0x5555,
+ message_type=atom,
+ format_=xlib.PROPERTY_FORMAT.LONG,
+ data=[1, 0, 0, 0, 0],
+ )
+ self.wm._handle_client_message(event=event)
+ self.assertEqual(got, [0x5555])
+
+ def test_key_press_and_release_slots(self) -> None:
+ order: list[str] = []
+ client: int = 0x6666
+ keycode: int = 38
+
+ def on_press(_window: xlib.TYPES.Cython_Window, _event: xlib.XKeyEvent) -> None:
+ order.append("press")
+
+ def on_release(_window: xlib.TYPES.Cython_Window, _event: xlib.XKeyEvent) -> None:
+ order.append("release")
+
+ self.wm._key_grabs[client] = {(0, keycode): [on_press, on_release]}
+ press: xlib.XKeyEvent = xlib.XKeyEvent(
+ display=self.wm.display,
+ type=xlib.XKeyEvent.TYPE.KeyPress,
+ window=client,
+ root=self.wm.root,
+ subwindow=0,
+ keycode=keycode,
+ state=0,
+ )
+ release: xlib.XKeyEvent = xlib.XKeyEvent(
+ display=self.wm.display,
+ type=xlib.XKeyEvent.TYPE.KeyRelease,
+ window=client,
+ root=self.wm.root,
+ subwindow=0,
+ keycode=keycode,
+ state=0,
+ )
+ self.wm._key_press_event_handler(event=press)
+ self.wm._handle_keyrelease(event=release)
+ self.assertEqual(order, ["press", "release"])
+
+ def test_button_ignores_other_button_mask_in_state(self) -> None:
+ clicks: list[int] = []
+ client: int = 0x7777
+ modifiers: int = KeyboardModifiers.Control.value
+
+ def on_click(_window: xlib.TYPES.Cython_Window, event: xlib.XButtonEvent) -> None:
+ clicks.append(int(event.button))
+
+ self.wm._button_grabs[client] = {(modifiers, int(xlib.BUTTONS.Button1)): on_click}
+ event: xlib.XButtonEvent = xlib.XButtonEvent(
+ display=self.wm.display,
+ type=xlib.XButtonEvent.TYPE.ButtonPress,
+ window=client,
+ root=self.wm.root,
+ subwindow=0,
+ button=xlib.BUTTONS.Button1,
+ state=modifiers | xlib.BUTTON_MASKS.Button2Mask.value,
+ )
+ self.wm._handle_button(event=event)
+ self.assertEqual(clicks, [int(xlib.BUTTONS.Button1)])
+
+ def test_any_key_keycode(self) -> None:
+ key: KeyDefinition.Key = KeyDefinition.Key(name="any_key")
+ self.assertEqual(key.get_keycode(display=self.wm.display), xlib.CONSTANTS.KB.ANY_KEY)
+
+ def test_create_map_destroy_window(self) -> None:
+ window: xlib.TYPES.Cython_Window = xlib.x_create_window(display=self.wm.display, parent=self.wm.root)
+ self.assertTrue(window)
+ xlib.x_map_window(display=self.wm.display, window=window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ xlib.x_destroy_window(display=self.wm.display, window=window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+
+
+class TestGrabDelivery(unittest.TestCase):
+ wm: WindowManager
+ nested: bool = False
+ _saved_display: Optional[str] = None
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ global _nested_server
+ cls._saved_display = os.getenv(key="DISPLAY")
+ spawned: Optional[tuple[_Popen, str]] = _spawn_nested_x()
+ if spawned is not None:
+ _nested_server, nested_display = spawned
+ os.environ["DISPLAY"] = nested_display
+ cls.nested = True
+ probe: Optional[xlib.TYPES.Cython_Display] = _try_open_display()
+ if probe is None:
+ _stop_nested_x()
+ if cls._saved_display is None:
+ _ = os.environ.pop("DISPLAY", default=None)
+ else:
+ os.environ["DISPLAY"] = cls._saved_display
+ raise unittest.SkipTest("no X display")
+ xlib.x_close_display(display=probe)
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ _stop_nested_x()
+ if cls._saved_display is None:
+ _ = os.environ.pop("DISPLAY", default=None)
+ else:
+ os.environ["DISPLAY"] = cls._saved_display
+
+ def setUp(self) -> None:
+ WindowManager.delete_singleton()
+ self.wm = WindowManager(loop=None)
+
+ def tearDown(self) -> None:
+ try:
+ self.wm.stop(exit=True)
+ finally:
+ WindowManager.delete_singleton()
+
+ def test_grab_key_delivers_via_xtest(self) -> None:
+ if not xlib.xtest_query_extension(display=self.wm.display):
+ raise unittest.SkipTest("XTEST extension not available")
+ keycode: Optional[int] = _obscure_keycode(display=self.wm.display, nested=self.nested)
+ if keycode is None:
+ raise unittest.SkipTest("no usable keycode on this keyboard")
+
+ xlib.x_set_error_handler(handler=_swallow_x_errors)
+ seen: list[int] = []
+
+ def on_press(_window: xlib.TYPES.Cython_Window, event: xlib.XKeyEvent) -> None:
+ seen.append(int(event.keycode))
+
+ key_definition: KeyDefinition = KeyDefinition(
+ modifiers=KeyboardModifiers.NoModifiers,
+ key=KeyDefinition.Key(keycode=keycode),
+ )
+ prev_focus, prev_revert = xlib.x_get_input_focus(display=self.wm.display)
+ grab_window: Optional[xlib.TYPES.Cython_Window] = None
+ try:
+ grab_window = xlib.x_create_window(display=self.wm.display, parent=self.wm.root)
+ xlib.x_map_window(display=self.wm.display, window=grab_window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ xlib.x_set_input_focus(display=self.wm.display, window=grab_window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ self.wm._grab_key_binding(window=grab_window, key_definition=key_definition, handler=on_press)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ _drain_x_events(wm=self.wm)
+ xlib.xtest_fake_key_event(display=self.wm.display, keycode=keycode, press=True)
+ xlib.xtest_fake_key_event(display=self.wm.display, keycode=keycode, press=False)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ _drain_x_events(wm=self.wm)
+ finally:
+ xlib.xtest_fake_key_event(display=self.wm.display, keycode=keycode, press=False)
+ self.wm._ungrab_handler(handler=on_press)
+ xlib.x_set_input_focus(display=self.wm.display, window=prev_focus, revert_to=prev_revert)
+ if grab_window:
+ xlib.x_destroy_window(display=self.wm.display, window=grab_window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+
+ self.assertEqual(seen, [keycode])
+
+ def test_grab_button_delivers_via_xtest(self) -> None:
+ if not self.nested:
+ raise unittest.SkipTest("button grab delivery needs Xephyr/Xvfb (will not warp the session pointer)")
+ if not xlib.xtest_query_extension(display=self.wm.display):
+ raise unittest.SkipTest("XTEST extension not available")
+
+ xlib.x_set_error_handler(handler=_swallow_x_errors)
+ seen: list[int] = []
+ button: int = int(xlib.BUTTONS.Button1)
+
+ def on_click(_window: xlib.TYPES.Cython_Window, event: xlib.XButtonEvent) -> None:
+ seen.append(int(event.button))
+
+ grab_window: Optional[xlib.TYPES.Cython_Window] = None
+ try:
+ grab_window = xlib.x_create_window(display=self.wm.display, parent=self.wm.root)
+ xlib.x_map_window(display=self.wm.display, window=grab_window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ xlib.xtest_fake_motion_event(display=self.wm.display, x=8, y=8)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ self.wm._grab_button_binding(
+ window=grab_window,
+ button=button,
+ modifiers_value=KeyboardModifiers.NoModifiers.value,
+ handler=on_click,
+ )
+ xlib.x_sync(display=self.wm.display, discard=False)
+ _drain_x_events(wm=self.wm)
+ xlib.xtest_fake_button_event(display=self.wm.display, button=button, press=True)
+ xlib.xtest_fake_button_event(display=self.wm.display, button=button, press=False)
+ xlib.x_sync(display=self.wm.display, discard=False)
+ _drain_x_events(wm=self.wm)
+ finally:
+ xlib.xtest_fake_button_event(display=self.wm.display, button=button, press=False)
+ self.wm._ungrab_button_handler(handler=on_click)
+ if grab_window:
+ xlib.x_destroy_window(display=self.wm.display, window=grab_window)
+ xlib.x_sync(display=self.wm.display, discard=False)
+
+ self.assertEqual(seen, [button])
+
+
+if __name__ == "__main__":
+ _ = unittest.main()
diff --git a/tools/check_explicit_types.py b/tools/check_explicit_types.py
new file mode 100644
index 0000000..69e2473
--- /dev/null
+++ b/tools/check_explicit_types.py
@@ -0,0 +1,168 @@
+"""Fail when a simple assignment has no explicit annotation (`a = 5` → `a: int = 5`).
+
+Skips Enum members, TypeVar/NewType bindings, unpacking, for/with/except targets,
+and names already annotated in the same scope (including function args and `global`/`nonlocal`
+names annotated in an outer scope). Python has no place to annotate those other bindings.
+"""
+
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import Path
+from typing import Union
+
+from typing_extensions import override
+
+SKIP_ROOT: set[str] = {"venv", ".venv", "build", "dist"}
+SKIP_ANY: set[str] = {".git", "__pycache__", "orcsome3-stubs"}
+SKIP_FILES: set[str] = {"prueba.py"}
+TYPING_CTORS: set[str] = {"TypeVar", "NewType", "ParamSpec", "TypeVarTuple", "Concatenate"}
+ENUM_BASES: set[str] = {"Enum", "IntEnum", "Flag", "IntFlag", "StrEnum"}
+
+
+def _iter_py_files(root: Path) -> list[Path]:
+ out: list[Path] = []
+ for path in sorted(root.rglob(pattern="*.py")):
+ rel: Path = path.relative_to(root)
+ if rel.parts[0] in SKIP_ROOT:
+ continue
+ if any(p in SKIP_ANY for p in rel.parts):
+ continue
+ if path.name in SKIP_FILES:
+ continue
+ out.append(path)
+ return out
+
+
+def _base_names(node: ast.expr) -> list[str]:
+ if isinstance(node, ast.Name):
+ return [node.id]
+ if isinstance(node, ast.Attribute):
+ return [node.attr]
+ return []
+
+
+def _is_enum_class(node: ast.ClassDef) -> bool:
+ return any(name in ENUM_BASES for base in node.bases for name in _base_names(node=base))
+
+
+def _is_typing_ctor(value: ast.expr) -> bool:
+ if not isinstance(value, ast.Call):
+ return False
+ func: ast.expr = value.func
+ if isinstance(func, ast.Name):
+ return func.id in TYPING_CTORS
+ if isinstance(func, ast.Attribute):
+ return func.attr in TYPING_CTORS
+ return False
+
+
+class Visitor(ast.NodeVisitor):
+ def __init__(self, path: Path) -> None:
+ self.path: Path = path
+ self.hits: list[tuple[int, int, str]] = []
+ self.stack: list[set[str]] = [set()]
+ self.enum_depth: int = 0
+
+ def _declare(self, name: str) -> None:
+ self.stack[-1].add(name)
+
+ def _known(self, name: str) -> bool:
+ return name in self.stack[-1]
+
+ def _arg_names(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> set[str]:
+ args: ast.arguments = node.args
+ names: set[str] = {a.arg for a in args.posonlyargs + args.args + args.kwonlyargs}
+ if args.vararg is not None:
+ names.add(args.vararg.arg)
+ if args.kwarg is not None:
+ names.add(args.kwarg.arg)
+ for stmt in node.body:
+ if isinstance(stmt, (ast.Global, ast.Nonlocal)):
+ for name in stmt.names:
+ if any(name in scope for scope in self.stack):
+ names.add(name)
+ return names
+
+ @override
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+ self.stack.append(self._arg_names(node=node))
+ self.generic_visit(node=node)
+ _ = self.stack.pop()
+
+ @override
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
+ self.stack.append(self._arg_names(node=node))
+ self.generic_visit(node=node)
+ _ = self.stack.pop()
+
+ @override
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
+ is_enum: bool = _is_enum_class(node=node)
+ if is_enum:
+ self.enum_depth += 1
+ self.stack.append(set())
+ self.generic_visit(node=node)
+ _ = self.stack.pop()
+ if is_enum:
+ self.enum_depth -= 1
+
+ @override
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
+ if isinstance(node.target, ast.Name):
+ self._declare(name=node.target.id)
+ self.generic_visit(node=node)
+
+ @override
+ def visit_Assign(self, node: ast.Assign) -> None:
+ if self.enum_depth == 0 and not _is_typing_ctor(value=node.value):
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
+ name: str = node.targets[0].id
+ if name != "_" and not self._known(name=name):
+ self.hits.append((node.lineno, node.col_offset, name))
+ self._declare(name=name)
+ self.generic_visit(node=node)
+
+
+def check_tree(path: Path, tree: ast.AST) -> list[tuple[int, int, str]]:
+ visitor: Visitor = Visitor(path=path)
+ visitor.visit(node=tree)
+ return visitor.hits
+
+
+def _self_check() -> None:
+ def hits(src: str) -> list[str]:
+ return [name for _ln, _col, name in check_tree(path=Path("mod.py"), tree=ast.parse(source=src))]
+
+ assert hits(src="a = 5\n") == ["a"]
+ assert hits(src="a: int = 5\n") == []
+ assert hits(src="def f(a: int) -> None:\n a = 2\n") == []
+ assert hits(src="def f() -> None:\n x = 1\n") == ["x"]
+ assert hits(src="from enum import Enum\nclass E(Enum):\n A = 1\n") == []
+ assert hits(src="from typing import TypeVar\n_T = TypeVar('_T')\n") == []
+ assert hits(src="__all__ = ['x']\n") == ["__all__"]
+ print("check_explicit_types self-check ok")
+
+
+def main(argv: list[str]) -> int:
+ _self_check()
+ root: Path = Path(argv[1] if len(argv) > 1 else Path(__file__).resolve().parents[1])
+ n: int = 0
+ for path in _iter_py_files(root=root):
+ tree: ast.AST = ast.parse(source=path.read_text(), filename=str(path))
+ for lineno, col, name in check_tree(path=path, tree=tree):
+ try:
+ rel: Path = path.relative_to(root)
+ except ValueError:
+ rel = path
+ print(f"{rel}:{lineno}:{col + 1}: unannotated assignment: {name}: = ...")
+ n += 1
+ if n:
+ print(f"{n} explicit-type violation(s)", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(argv=sys.argv))
diff --git a/tools/check_named_args.py b/tools/check_named_args.py
new file mode 100644
index 0000000..7a4b6d6
--- /dev/null
+++ b/tools/check_named_args.py
@@ -0,0 +1,533 @@
+"""Fail when a call passes positional args that the callee accepts as keywords.
+
+Skip positional-only params, *args, unknown callees, signatures we cannot resolve,
+and Mapping key args (`get`/`pop`/…) that inspect reports as keywords but stubs mark `/`.
+"""
+
+from __future__ import annotations
+
+import ast
+import builtins
+import importlib
+import inspect
+import sys
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any, Iterator, NamedTuple, Optional, Union
+
+POSONLY: Any = inspect.Parameter.POSITIONAL_ONLY
+PORK: Any = inspect.Parameter.POSITIONAL_OR_KEYWORD
+VARPOS: Any = inspect.Parameter.VAR_POSITIONAL
+
+SKIP_ROOT: set[str] = {"venv", ".venv", "build", "dist"}
+SKIP_ANY: set[str] = {".git", "__pycache__", "orcsome3-stubs"}
+SKIP_FILES: set[str] = {"prueba.py"}
+# inspect often reports Mapping.get/pop key as PORK; typeshed/mypy treat it as positional-only.
+_MAPPING_KEY_METHODS: frozenset[str] = frozenset(
+ {"get", "pop", "setdefault", "__getitem__", "__setitem__", "__delitem__"}
+)
+
+
+class Sig(NamedTuple):
+ params: tuple[tuple[str, Any], ...]
+
+
+class Hit(NamedTuple):
+ path: Path
+ lineno: int
+ col: int
+ param: str
+
+
+def _kind_params(args: ast.arguments, *, drop_self: bool) -> tuple[tuple[str, Any], ...]:
+ params: list[tuple[str, Any]] = []
+ for a in args.posonlyargs:
+ params.append((a.arg, POSONLY))
+ for a in args.args:
+ params.append((a.arg, PORK))
+ if args.vararg is not None:
+ params.append((args.vararg.arg, VARPOS))
+ if drop_self and params and params[0][0] in {"self", "cls"}:
+ params = params[1:]
+ return tuple(params)
+
+
+def _is_staticmethod(node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> bool:
+ return any(isinstance(d, ast.Name) and d.id == "staticmethod" for d in node.decorator_list)
+
+
+def _sig_from_inspect(obj: Any, *, drop_self: bool) -> Optional[Sig]:
+ try:
+ signature: inspect.Signature = inspect.signature(obj=obj)
+ except (TypeError, ValueError):
+ return None
+ params: list[tuple[str, Any]] = []
+ for p in signature.parameters.values():
+ if p.kind in (POSONLY, PORK, VARPOS):
+ params.append((p.name, p.kind))
+ if drop_self and params and params[0][0] in {"self", "cls"}:
+ params = params[1:]
+ return Sig(params=tuple(params))
+
+
+def _is_mapping_like(obj: object) -> bool:
+ if isinstance(obj, Mapping):
+ return True
+ return isinstance(obj, type) and issubclass(obj, Mapping)
+
+
+def _adjust_mapping_method_sig(*, parent: object, method: str, sig: Sig) -> Sig:
+ """Mark Mapping key args positional-only so we do not require `key=` (mypy rejects it)."""
+ if method not in _MAPPING_KEY_METHODS or not sig.params or not _is_mapping_like(obj=parent):
+ return sig
+ name, kind = sig.params[0]
+ if kind is not PORK:
+ return sig
+ return Sig(params=((name, POSONLY), *sig.params[1:]))
+
+
+class Index:
+ def __init__(self) -> None:
+ self.funcs: dict[str, dict[str, Sig]] = {}
+ self.methods: dict[tuple[str, str, str], Sig] = {}
+ self.classes: set[tuple[str, str]] = set()
+
+ def add_func(self, mod: str, name: str, sig: Sig) -> None:
+ self.funcs.setdefault(mod, {})[name] = sig
+
+ def add_method(self, mod: str, cls: str, name: str, sig: Sig) -> None:
+ self.classes.add((mod, cls))
+ self.methods[(mod, cls, name)] = sig
+
+ def load_tree(self, mod: str, tree: ast.AST) -> None:
+ class_stack: list[str] = []
+
+ def visit(node: ast.AST) -> None:
+ if isinstance(node, ast.ClassDef):
+ class_stack.append(node.name)
+ qual: str = ".".join(class_stack)
+ self.classes.add((mod, qual))
+ for child in node.body:
+ visit(node=child)
+ _ = class_stack.pop()
+ return
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ drop_self: bool = bool(class_stack) and not _is_staticmethod(node=node)
+ sig: Sig = Sig(params=_kind_params(args=node.args, drop_self=drop_self))
+ if class_stack:
+ self.add_method(mod=mod, cls=".".join(class_stack), name=node.name, sig=sig)
+ else:
+ self.add_func(mod=mod, name=node.name, sig=sig)
+ for child in node.body:
+ visit(node=child)
+ return
+ for sub in ast.iter_child_nodes(node=node):
+ visit(node=sub)
+
+ visit(node=tree)
+
+
+def _module_name(path: Path, root: Path) -> str:
+ rel: Path = path.relative_to(root)
+ parts: list[str] = list(rel.with_suffix(suffix="").parts)
+ if parts[-1] == "__init__":
+ parts = parts[:-1]
+ return ".".join(parts)
+
+
+def _iter_source_files(root: Path) -> Iterator[Path]:
+ for path in sorted(root.rglob(pattern="*")):
+ if not path.is_file() or path.suffix not in {".py", ".pyi"}:
+ continue
+ rel: Path = path.relative_to(root)
+ if rel.parts[0] in SKIP_ROOT:
+ continue
+ if any(p in SKIP_ANY for p in rel.parts):
+ continue
+ if path.name in SKIP_FILES:
+ continue
+ yield path
+
+
+def _attr_parts(node: ast.AST) -> Optional[list[str]]:
+ parts: list[str] = []
+ cur: ast.AST = node
+ while True:
+ if isinstance(cur, ast.Name):
+ parts.append(cur.id)
+ parts.reverse()
+ return parts
+ if isinstance(cur, ast.Attribute):
+ parts.append(cur.attr)
+ cur = cur.value
+ continue
+ return None
+
+
+def _unwrap_anno(node: ast.AST) -> ast.AST:
+ while True:
+ if (
+ isinstance(node, ast.Subscript)
+ and isinstance(node.value, ast.Name)
+ and node.value.id in {"Optional", "Union"}
+ ):
+ sl: ast.AST = node.slice
+ if isinstance(sl, ast.Tuple):
+ node = sl.elts[0]
+ continue
+ node = sl
+ continue
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ try:
+ node = ast.parse(source=node.value, mode="eval").body
+ except SyntaxError:
+ return node
+ continue
+ return node
+
+
+def _import_map(tree: ast.AST) -> dict[str, tuple[str, str]]:
+ """local name -> (module, remainder) where remainder is '' for a module import."""
+ out: dict[str, tuple[str, str]] = {}
+ for node in ast.walk(node=tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ name: str = alias.asname or alias.name.rsplit(".", 1)[-1]
+ out[name] = (alias.name, "")
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ for alias in node.names:
+ if alias.name == "*":
+ continue
+ name = alias.asname or alias.name
+ out[name] = (node.module, alias.name)
+ return out
+
+
+def _inspect_mod_attr(modname: str, *attrs: str) -> Optional[Sig]:
+ if modname.startswith("orcsome3") or modname.split(".")[0] in {"hashlib", "_hashlib"}:
+ return None
+ try:
+ obj: object = importlib.import_module(name=modname)
+ parent: object = obj
+ for attr in attrs:
+ parent = obj
+ obj = getattr(obj, attr)
+ if inspect.isclass(object=obj):
+ return _sig_from_inspect(obj=obj.__init__, drop_self=True)
+ sig: Optional[Sig] = _sig_from_inspect(
+ obj=obj,
+ drop_self=inspect.ismethoddescriptor(object=obj)
+ or inspect.isfunction(object=obj)
+ and hasattr(obj, "__qualname__")
+ and "." in getattr(obj, "__qualname__", ""),
+ )
+ if sig is None or not attrs:
+ return sig
+ return _adjust_mapping_method_sig(parent=parent, method=attrs[-1], sig=sig)
+ except Exception:
+ return None
+
+
+def _inspect_method(modname: str, cls: str, method: str) -> Optional[Sig]:
+ if modname.startswith("orcsome3") or modname.split(".")[0] in {"hashlib", "_hashlib"}:
+ return None
+ try:
+ obj: object = importlib.import_module(name=modname)
+ for part in cls.split("."):
+ obj = getattr(obj, part)
+ parent: object = obj
+ obj = getattr(obj, method)
+ sig: Optional[Sig] = _sig_from_inspect(obj=obj, drop_self=True)
+ if sig is None:
+ return None
+ return _adjust_mapping_method_sig(parent=parent, method=method, sig=sig)
+ except Exception:
+ return None
+
+
+def _builtin_sig(name: str) -> Optional[Sig]:
+ obj: object = getattr(builtins, name, None)
+ if obj is None:
+ return None
+ if inspect.isclass(object=obj):
+ return _sig_from_inspect(obj=obj.__init__, drop_self=True) or _sig_from_inspect(obj=obj, drop_self=False)
+ return _sig_from_inspect(obj=obj, drop_self=False)
+
+
+class Checker:
+ def __init__(self, root: Path) -> None:
+ self.root: Path = root
+ self.index: Index = Index()
+ self.hits: list[Hit] = []
+ for path in _iter_source_files(root=root):
+ try:
+ tree: ast.AST = ast.parse(source=path.read_text(), filename=str(path))
+ except SyntaxError:
+ continue
+ self.index.load_tree(mod=_module_name(path=path, root=root), tree=tree)
+
+ def run(self) -> list[Hit]:
+ for path in _iter_source_files(root=self.root):
+ if path.suffix != ".py":
+ continue
+ tree: ast.Module = ast.parse(source=path.read_text(), filename=str(path))
+ self.check_file(path=path, tree=tree)
+ return self.hits
+
+ def check_file(self, path: Path, tree: ast.Module) -> None:
+ imports: dict[str, tuple[str, str]] = _import_map(tree=tree)
+ mod: str = _module_name(path=path, root=self.root)
+ class_stack: list[str] = []
+ bindings: list[dict[str, tuple[str, str]]] = [self._module_bindings(tree=tree, imports=imports, mod=mod)]
+
+ def resolve_type_parts(parts: list[str]) -> Optional[tuple[str, str]]:
+ head, tail = parts[0], parts[1:]
+ if head in bindings[-1]:
+ tmod, tcls = bindings[-1][head]
+ extra: str = ".".join(tail)
+ cls: str = ".".join(p for p in (tcls, extra) if p)
+ return (tmod, cls)
+ if head in imports:
+ imod, iname = imports[head]
+ if iname:
+ cls = ".".join(p for p in (iname, *tail) if p)
+ return (imod, cls)
+ return (imod if not tail else imod, ".".join(tail))
+ if head in {"self", "cls"} and class_stack:
+ if not tail:
+ return (mod, ".".join(class_stack))
+ if tail[0] in bindings[-1]:
+ tmod, tcls = bindings[-1][tail[0]]
+ extra = ".".join(tail[1:])
+ return (tmod, ".".join(p for p in (tcls, extra) if p))
+ return (mod, ".".join(class_stack))
+ return None
+
+ def resolve_sig(func: ast.AST) -> Optional[Sig]:
+ if isinstance(func, ast.Name):
+ if func.id in imports:
+ imod, iname = imports[func.id]
+ if iname:
+ sig: Optional[Sig] = self.index.funcs.get(imod, {}).get(iname)
+ if sig is not None:
+ return sig
+ sig = self.index.methods.get((imod, iname, "__init__"))
+ if sig is not None:
+ return sig
+ if (imod, iname) in self.index.classes:
+ return Sig(params=())
+ return _inspect_mod_attr(imod, iname)
+ return _inspect_mod_attr(modname=imod)
+ sig = self.index.funcs.get(mod, {}).get(func.id)
+ if sig is not None:
+ return sig
+ if func.id in bindings[-1]:
+ tmod, tcls = bindings[-1][func.id]
+ return self.index.methods.get((tmod, tcls, "__init__")) or _inspect_method(
+ modname=tmod, cls=tcls, method="__init__"
+ )
+ return _builtin_sig(name=func.id)
+ parts: Optional[list[str]] = _attr_parts(node=func)
+ if not parts:
+ return None
+ if parts[0] in imports:
+ imod, iname = imports[parts[0]]
+ rest: list[str] = ([iname] if iname else []) + parts[1:]
+ if not rest:
+ return _inspect_mod_attr(modname=imod)
+ if len(rest) == 1:
+ sig = self.index.funcs.get(imod, {}).get(rest[0])
+ if sig is not None:
+ return sig
+ sig = self.index.methods.get((imod, rest[0], "__init__"))
+ if sig is not None:
+ return sig
+ return _inspect_mod_attr(imod, rest[0])
+ cls, method = ".".join(rest[:-1]), rest[-1]
+ sig = self.index.methods.get((imod, cls, method))
+ if sig is not None:
+ return sig
+ if method == "__init__" or (imod, cls) in self.index.classes and method not in {"__init__"}:
+ pass
+ return _inspect_method(modname=imod, cls=cls, method=method) or _inspect_mod_attr(imod, *rest)
+ typed: Optional[tuple[str, str]] = resolve_type_parts(parts=parts[:-1]) if len(parts) > 1 else None
+ if typed is not None:
+ tmod, tcls = typed
+ meth: str = parts[-1]
+ if not tcls:
+ sig = self.index.funcs.get(tmod, {}).get(meth)
+ if sig is not None:
+ return sig
+ return _inspect_mod_attr(tmod, meth)
+ sig = self.index.methods.get((tmod, tcls, meth))
+ if sig is not None:
+ return sig
+ return _inspect_method(modname=tmod, cls=tcls, method=meth)
+ if parts[0] in {"self", "cls"} and class_stack and len(parts) == 2:
+ return self.index.methods.get((mod, ".".join(class_stack), parts[1]))
+ return None
+
+ def bind_anno(name: str, anno: ast.AST) -> None:
+ anno = _unwrap_anno(node=anno)
+ parts: Optional[list[str]] = _attr_parts(node=anno)
+ if not parts:
+ return
+ resolved: Optional[tuple[str, str]] = resolve_type_parts(parts=parts) if len(parts) >= 1 else None
+ if resolved is not None:
+ bindings[-1][name] = resolved
+ return
+ if parts[0] in imports:
+ imod, iname = imports[parts[0]]
+ cls: str = ".".join(p for p in ((iname, *parts[1:]) if iname else parts[1:]) if p)
+ bindings[-1][name] = (imod, cls)
+ elif len(parts) == 1:
+ bindings[-1][name] = (mod, parts[0])
+
+ def bind_rhs(name: str, value: ast.AST) -> None:
+ if not isinstance(value, ast.Call):
+ return
+ parts: Optional[list[str]] = _attr_parts(node=value.func)
+ if not parts:
+ return
+ if parts[0] in imports:
+ imod, iname = imports[parts[0]]
+ rest: list[str] = ([iname] if iname else []) + parts[1:]
+ if len(rest) >= 1:
+ if len(rest) >= 2:
+ bindings[-1][name] = (imod, ".".join(rest[:-1]))
+ else:
+ bindings[-1][name] = (imod, rest[0])
+
+ def check_call(node: ast.Call) -> None:
+ if isinstance(node.func, ast.Name) and node.func.id in {"cast", "exec"}:
+ return
+ # 3.8 Path.relative_to(*other) vs 3.12+ relative_to(other, *); keyword other= breaks 3.8.
+ if isinstance(node.func, ast.Attribute) and node.func.attr == "relative_to":
+ return
+ sig: Optional[Sig] = resolve_sig(func=node.func)
+ if sig is None:
+ return
+ if any(isinstance(arg, ast.Starred) for arg in node.args):
+ return
+ n_fixed: int = 0
+ for _name, kind in sig.params:
+ if kind is VARPOS:
+ break
+ n_fixed += 1
+ n_plain: int = 0
+ for arg in node.args:
+ if isinstance(arg, ast.Starred):
+ break
+ n_plain += 1
+ # Keywording a PORK param would make leftover *args values positional-after-keyword.
+ if n_plain > n_fixed:
+ return
+ i: int = 0
+ for arg in node.args:
+ if isinstance(arg, ast.Starred):
+ return
+ if i >= len(sig.params):
+ return
+ pname, kind = sig.params[i]
+ if kind is VARPOS:
+ return
+ if kind is PORK:
+ self.hits.append(Hit(path=path, lineno=arg.lineno, col=arg.col_offset, param=pname))
+ i += 1
+
+ def visit(node: ast.AST) -> None:
+ if isinstance(node, ast.ClassDef):
+ class_stack.append(node.name)
+ bindings.append(dict(bindings[-1]))
+ for child in node.body:
+ visit(node=child)
+ _ = bindings.pop()
+ _ = class_stack.pop()
+ return
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ bindings.append(dict(bindings[-1]))
+ for a in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
+ if a.annotation is not None:
+ bind_anno(name=a.arg, anno=a.annotation)
+ for child in node.body:
+ visit(node=child)
+ _ = bindings.pop()
+ return
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
+ bind_anno(name=node.target.id, anno=node.annotation)
+ if node.value is not None:
+ bind_rhs(name=node.target.id, value=node.value)
+ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
+ bind_rhs(name=node.targets[0].id, value=node.value)
+ if isinstance(node, ast.Call):
+ check_call(node=node)
+ for sub in ast.iter_child_nodes(node=node):
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
+ continue
+ visit(node=sub)
+
+ visit(node=tree)
+
+ def _module_bindings(
+ self, tree: ast.Module, imports: dict[str, tuple[str, str]], mod: str
+ ) -> dict[str, tuple[str, str]]:
+ bindings: dict[str, tuple[str, str]] = {}
+ for node in tree.body:
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
+ anno: ast.AST = _unwrap_anno(node=node.annotation)
+ parts: Optional[list[str]] = _attr_parts(node=anno)
+ if parts and parts[0] in imports:
+ imod, iname = imports[parts[0]]
+ cls: str = ".".join(p for p in ((iname, *parts[1:]) if iname else parts[1:]) if p)
+ bindings[node.target.id] = (imod, cls)
+ elif parts and len(parts) == 1:
+ bindings[node.target.id] = (mod, parts[0])
+ return bindings
+
+
+def _self_check() -> None:
+ def hits_for(src: str) -> list[str]:
+ root: Path = Path("/tmp/named-args-self-check")
+ root.mkdir(parents=True, exist_ok=True)
+ path: Path = root / "mod.py"
+ _ = path.write_text(data=src)
+ checker: Checker = Checker(root=root)
+ checker.root = root
+ checker.index = Index()
+ tree: ast.Module = ast.parse(source=src)
+ checker.index.load_tree(mod="mod", tree=tree)
+ checker.hits = []
+ checker.check_file(path=path, tree=tree)
+ return [h.param for h in checker.hits]
+
+ assert hits_for(src="def f(a, /):\n f(1)\n") == []
+ assert hits_for(src="def f(*args):\n f(1)\n") == []
+ assert hits_for(src="def f(a, *args):\n f(1, 2)\n") == []
+ assert hits_for(src="def f(a, *args):\n f(1)\n") == ["a"]
+ assert hits_for(src="def f(a):\n f(1)\n") == ["a"]
+ assert hits_for(src="def f(a, b):\n f(a=1, b=2)\n") == []
+ assert hits_for(src="def f(a, b):\n f(1, b=2)\n") == ["a"]
+ assert hits_for(src="len(x)\n") == []
+ assert hits_for(src="import os\nos.environ.pop('DISPLAY')\n") == []
+ assert hits_for(src="import os\nos.environ.pop('DISPLAY', None)\n") == ["default"]
+ print("check_named_args self-check ok")
+
+
+def main(argv: list[str]) -> int:
+ _self_check()
+ root: Path = Path(argv[1] if len(argv) > 1 else Path(__file__).resolve().parents[1])
+ hits: list[Hit] = Checker(root=root).run()
+ for h in hits:
+ try:
+ rel: Path = h.path.relative_to(root)
+ except ValueError:
+ rel = h.path
+ print(f"{rel}:{h.lineno}:{h.col + 1}: positional arg should be keyword: {h.param}=")
+ if hits:
+ print(f"{len(hits)} named-arg violation(s)", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(argv=sys.argv))
diff --git a/tools/generate_backend_stub.py b/tools/generate_backend_stub.py
new file mode 100644
index 0000000..ae9549d
--- /dev/null
+++ b/tools/generate_backend_stub.py
@@ -0,0 +1,457 @@
+"""Generate `orcsome3_backend.pyi` from `orcsome3_backend.pyx`.
+
+The compiled extension has no types of its own. This script walks Cython's parse tree of the
+`.pyx`, then writes a `.pyi` that type checkers use when `import orcsome3_backend` runs.
+
+Pipeline (`generate`):
+1. Parse the `.pyx` with Cython (`parse_from_strings`). Nodes are untyped Cython AST objects;
+ helpers poke attributes (`name`, `body.stats`, `return_type_annotation`) by convention.
+2. Import the already-built `.so` for real enum integers (`xlib.KeyPress` is an AttributeNode
+ in the tree, not a number). Requires `make native-fast` first.
+3. Emit enums, `cdef class` wrappers, then module-level `def` functions.
+4. Prefix only the imports the text actually uses, then format with ruff so `--check`
+ matches `make format`.
+
+What the stub contains:
+- Integer `class Foo(IntEnum)` with values from the `.so`. `PROPERTY_FORMAT` stays `Enum`
+ because its members are `(bits, array_typecode)` tuples, not ints.
+- `cdef class` attributes from `cdef public` fields and from `@property` getters that have a
+ Python return annotation (`-> EVENT_TYPES`). PEP-526 lines on cdef classes (`_type: EVENT_TYPES`)
+ are ignored by Cython and are not a field source.
+- `def` signatures copied from Python `->` annotations on the `.pyx`.
+
+`make stubs` writes the file. `make stubs-check` (CI) runs this script with `--check` (must match the
+committed `.pyi`).
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from enum import Enum, EnumMeta
+from pathlib import Path
+from typing import Optional
+
+from Cython.Compiler.TreeFragment import parse_from_strings
+
+ROOT: Path = Path(__file__).resolve().parent.parent
+PYX: Path = ROOT / "orcsome3" / "libs" / "cython_libs" / "orcsome3_backend.pyx"
+STUB: Path = ROOT / "orcsome3_backend.pyi"
+# Factory / dispatcher used from Python wrappers; keep them on the stub even though they are `_` prefixed.
+KEEP_PRIVATE: set[str] = {"_new_from_python_", "_get_specific_event_"}
+SKIP_METHODS: set[str] = {"__dealloc__"}
+# C / X11 typedefs that should appear as Python builtins on the stub. Unknown names are copied as-is
+# (they are expected to be `cdef class` names like `PyDisplay`).
+C_TO_PY: dict[str, str] = {
+ "int": "int",
+ "char": "int",
+ "bint": "bool",
+ "BOOL": "bool",
+ "Window": "int",
+ "Atom": "int",
+ "Time": "int",
+ "XID": "int",
+ "KeySym": "int",
+ "KeyCode": "int",
+ "array": "array[int]",
+ "float": "float",
+ "double": "float",
+ "str": "str",
+}
+
+
+def _stats(node: object) -> list[object]:
+ """Return the statement list of a Cython AST node.
+
+ Classes and the module wrap their children in a `body` with `.stats`. A leaf with no list
+ is returned as a one-item list so callers can always iterate.
+ """
+ body: object = getattr(node, "body", node)
+ stats: object = getattr(body, "stats", None)
+ if not isinstance(stats, list):
+ return [body]
+ items: list[object] = []
+ for item in stats:
+ items.append(item)
+ return items
+
+
+def _kind(node: object) -> str:
+ """Cython AST class name (`PyClassDefNode`, `DefNode`, `CVarDefNode`, `TupleNode`, …)."""
+ return type(node).__name__
+
+
+def _ann_text(ann: object) -> Optional[str]:
+ """Python annotation text from a Cython `AnnotationNode` (`-> EVENT_TYPES`, `x: int`).
+
+ Rewrites `pyarray.array` to `array` so the stub can `from array import array`. Returns
+ `None` when the node has no annotation (cdef-only C types).
+ """
+ if ann is None:
+ return None
+ string: object = getattr(ann, "string", None)
+ value: object = getattr(string, "value", None)
+ if not isinstance(value, str):
+ return None
+ text: str = value.replace("pyarray.array", "array").strip("'\"")
+ return text
+
+
+def _arg_name(arg: object) -> str:
+ """Parameter name, walking Cython's nested declarators (`int *p` wraps the name in a pointer node)."""
+ dec: object = getattr(arg, "declarator", None)
+ name: Optional[str] = None
+ while dec is not None:
+ raw: object = getattr(dec, "name", None)
+ if isinstance(raw, str):
+ name = raw
+ dec = getattr(dec, "base", None)
+ if name is None:
+ raise SystemExit("argument with no name")
+ return name
+
+
+def _c_type_name(typed: object) -> Optional[str]:
+ """C type name on a cdef argument or field (`int`, `Window`, `bint`), or `None` if absent."""
+ base: object = getattr(typed, "base_type", None)
+ if base is None:
+ return None
+ name: object = getattr(base, "name", None)
+ return name if isinstance(name, str) else None
+
+
+def _map_c_type(c_name: Optional[str], *, where: str) -> str:
+ """Map a C / X11 type to a Python stub type via `C_TO_PY`; unknown names are kept (cdef classes)."""
+ if c_name is None:
+ raise SystemExit(f"missing C type at {where}")
+ py_name: Optional[str] = C_TO_PY.get(c_name)
+ if py_name is None:
+ return c_name
+ return py_name
+
+
+def _arg_py_type(arg: object, *, where: str) -> str:
+ """Stub type for one parameter: Python `x: T` annotation if present, else the mapped C type."""
+ annotated: Optional[str] = _ann_text(ann=getattr(arg, "annotation", None))
+ if annotated is not None:
+ return annotated
+ return _map_c_type(c_name=_c_type_name(typed=arg), where=where)
+
+
+def _deco_names(node: object) -> list[str]:
+ """Decorator names on a `DefNode` (`property`, `setter`, `classmethod`, `staticmethod`)."""
+ names: list[str] = []
+ for deco in getattr(node, "decorators", None) or []:
+ inner: object = getattr(deco, "decorator", deco)
+ name: object = getattr(inner, "name", None) or getattr(inner, "attribute", None)
+ if isinstance(name, str):
+ names.append(name)
+ return names
+
+
+def _is_enum_class(node: object) -> bool:
+ """True if this `PyClassDefNode` subclasses `Enum` or `IntEnum` (`pyenum.IntEnum` in the .pyx)."""
+ bases: object = getattr(node, "bases", None)
+ for base in getattr(bases, "args", None) or []:
+ attr: object = getattr(base, "attribute", None) or getattr(base, "name", None)
+ if attr in {"Enum", "IntEnum"}:
+ return True
+ return False
+
+
+def _enum_python_base(node: object) -> str:
+ """Stub base for an enum class: `IntEnum` when the .pyx uses `pyenum.IntEnum`, else `Enum`."""
+ bases: object = getattr(node, "bases", None)
+ for base in getattr(bases, "args", None) or []:
+ attr: object = getattr(base, "attribute", None) or getattr(base, "name", None)
+ if attr == "IntEnum":
+ return "IntEnum"
+ if attr == "Enum":
+ return "Enum"
+ return "Enum"
+
+
+def _base_class_name(node: object) -> Optional[str]:
+ """First base of a `cdef class` (`PyXButtonEvent(PyXEvent)` → `PyXEvent`), or `None`."""
+ bases: object = getattr(node, "bases", None)
+ args: object = getattr(bases, "args", None)
+ if not isinstance(args, list) or not args:
+ return None
+ first: object = args[0]
+ name: object = getattr(first, "name", None)
+ return name if isinstance(name, str) else None
+
+
+def _declarator_names(var: object) -> list[str]:
+ """Field names on a `CVarDefNode`. One cdef line can declare several (`cdef public int x, y`)."""
+ names: list[str] = []
+ for dec in getattr(var, "declarators", None) or []:
+ name: object = getattr(dec, "name", None)
+ if isinstance(name, str):
+ names.append(name)
+ return names
+
+
+def _format_def(name: str, args: list[str], ret: str, *, decorators: list[str], indent: str) -> str:
+ """Render `def name(...) -> ret: ...`, wrapping arguments if the line would exceed 120 chars."""
+ lines: list[str] = [f"{indent}@{deco}" for deco in decorators]
+ joined: str = ", ".join(args)
+ one: str = f"{indent}def {name}({joined}) -> {ret}: ..."
+ if len(one) <= 120:
+ lines.append(one)
+ return "\n".join(lines)
+ lines.append(f"{indent}def {name}(")
+ for arg in args:
+ lines.append(f"{indent} {arg},")
+ lines.append(f"{indent}) -> {ret}: ...")
+ return "\n".join(lines)
+
+
+def _emit_function(node: object, *, indent: str, skip_self: bool) -> str:
+ """Stub one `def` from a Cython `DefNode`.
+
+ Requires a Python `->` return annotation on the .pyx (Cython `cpdef int foo()` is not used
+ for the Python-facing API). `skip_self` leaves `self`/`cls` untyped, which is normal on stubs.
+ """
+ name: object = getattr(node, "name", None)
+ if not isinstance(name, str):
+ raise SystemExit("function with no name")
+ ret: Optional[str] = _ann_text(ann=getattr(node, "return_type_annotation", None))
+ if ret is None:
+ raise SystemExit(f"missing return annotation on {name}")
+ args: list[str] = []
+ for arg in getattr(node, "args", None) or []:
+ arg_name: str = _arg_name(arg=arg)
+ if skip_self and arg_name in {"self", "cls"}:
+ args.append(arg_name)
+ continue
+ args.append(f"{arg_name}: {_arg_py_type(arg=arg, where=f'{name}.{arg_name}')}")
+ decos: list[str] = [d for d in _deco_names(node=node) if d in {"staticmethod", "classmethod"}]
+ return _format_def(name=name, args=args, ret=ret, decorators=decos, indent=indent)
+
+
+def _runtime_enum_values() -> dict[str, dict[str, object]]:
+ """`{enum_class: {member: value}}` from the compiled `orcsome3_backend` module.
+
+ The parse tree stores most members as `xlib.KeyPress` / `libev.EV_READ` (AttributeNodes), not
+ integers. The `.so` has already folded those C constants. Exits if the extension is missing.
+ """
+ if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+ try:
+ import orcsome3_backend as backend
+ except ImportError:
+ raise SystemExit("need a built orcsome3_backend to emit enum values (make native-fast)")
+ out: dict[str, dict[str, object]] = {}
+ for name in dir(backend):
+ obj: object = getattr(backend, name)
+ if not isinstance(obj, EnumMeta) or obj is Enum:
+ continue
+ values: dict[str, object] = {}
+ for name_ in obj.__members__:
+ member: Enum = getattr(obj, name_)
+ values[name_] = member.value
+ out[name] = values
+ return out
+
+
+def _enum_member_rhs(*, rhs: object, runtime: object) -> str:
+ """Source text for one enum member value on the stub (`2`, `(8, "b")`, …).
+
+ Prefers `runtime` from the `.so`. If that member is not imported yet (pyx added, extension not
+ rebuilt), falls back to IntNode / TupleNode in the tree, else `0`.
+ """
+ if runtime is not None:
+ return repr(runtime)
+ # ponytail: .so is missing this member (rebuild then remake stubs); IntNode/tuple from the pyx, else 0
+ kind: str = _kind(node=rhs)
+ if kind == "TupleNode":
+ parts: list[str] = []
+ for item in getattr(rhs, "args", None) or []:
+ item_kind: str = _kind(node=item)
+ value: object = getattr(item, "value", None)
+ if item_kind == "IntNode":
+ parts.append(str(value))
+ elif item_kind == "UnicodeNode":
+ parts.append(repr(value))
+ else:
+ parts.append("0")
+ return f"({', '.join(parts)})"
+ if kind == "IntNode":
+ int_value: object = getattr(rhs, "value", None)
+ if int_value is not None:
+ return str(int_value)
+ return "0"
+
+
+def _emit_enum(node: object, *, runtime: dict[str, dict[str, object]]) -> str:
+ """Stub one Python enum: `class NAME(IntEnum|Enum):` members, then any `@classmethod` helpers."""
+ name: object = getattr(node, "name", None)
+ if not isinstance(name, str):
+ raise SystemExit("enum with no name")
+ lines: list[str] = [f"class {name}({_enum_python_base(node=node)}):"]
+ methods: list[str] = []
+ members_runtime: dict[str, object] = runtime.get(name, {})
+ for stat in _stats(node=node):
+ kind: str = _kind(node=stat)
+ if kind == "SingleAssignmentNode":
+ member: object = getattr(getattr(stat, "lhs", None), "name", None)
+ if isinstance(member, str):
+ lines.append(
+ f" {member} = {_enum_member_rhs(rhs=getattr(stat, 'rhs', None), runtime=members_runtime.get(member))}"
+ )
+ elif kind == "DefNode":
+ methods.append(_emit_function(node=stat, indent=" ", skip_self=True))
+ if len(lines) == 1 and not methods:
+ lines.append(" ...")
+ if methods:
+ lines.append("")
+ lines.extend(methods)
+ return "\n".join(lines)
+
+
+def _emit_cclass(node: object) -> str:
+ """Stub one `cdef class` as a Python class.
+
+ Fields, in order:
+ - `cdef public` C attributes (mapped through `C_TO_PY`).
+ - `@property` getters with a Python `->` type, emitted as attributes (`type: EVENT_TYPES`).
+ Setters are skipped (same name as the getter).
+ - Public methods, plus `KEEP_PRIVATE` factories. `__dealloc__` is omitted.
+
+ PEP-526 annotations on cdef classes are not used: Cython warns and drops them.
+ """
+ name: object = getattr(node, "class_name", None)
+ if not isinstance(name, str):
+ raise SystemExit("cdef class with no name")
+ base: Optional[str] = _base_class_name(node=node)
+ header: str = f"class {name}({base}):" if base is not None else f"class {name}:"
+ body: list[str] = []
+ seen: set[str] = set()
+ for stat in _stats(node=node):
+ kind: str = _kind(node=stat)
+ if kind == "CVarDefNode":
+ if getattr(stat, "visibility", None) != "public":
+ continue
+ py_type: str = _map_c_type(c_name=_c_type_name(typed=stat), where=f"{name} field")
+ for field in _declarator_names(var=stat):
+ if field in seen:
+ continue
+ seen.add(field)
+ body.append(f" {field}: {py_type}")
+ elif kind == "DefNode":
+ meth: object = getattr(stat, "name", None)
+ if not isinstance(meth, str) or meth in SKIP_METHODS:
+ continue
+ decos: list[str] = _deco_names(node=stat)
+ if "setter" in decos:
+ continue
+ if "property" in decos:
+ ret: Optional[str] = _ann_text(ann=getattr(stat, "return_type_annotation", None))
+ if ret is None:
+ raise SystemExit(f"missing return annotation on {name}.{meth}")
+ if meth not in seen:
+ seen.add(meth)
+ body.append(f" {meth}: {ret}")
+ continue
+ if meth.startswith("_") and meth not in KEEP_PRIVATE:
+ continue
+ body.append(_emit_function(node=stat, indent=" ", skip_self=True))
+ if not body:
+ return f"{header} ..."
+ return "\n".join([header, *body])
+
+
+def generate() -> str:
+ """Build the full `.pyi` text: enums, cdef classes, module functions, then used imports only."""
+ source: str = PYX.read_text(encoding="utf-8")
+ tree: object = parse_from_strings(name="orcsome3_backend", code=source)
+ enums: list[str] = []
+ classes: list[str] = []
+ functions: list[str] = []
+ runtime: dict[str, dict[str, object]] = _runtime_enum_values()
+ for stat in _stats(node=tree):
+ kind: str = _kind(node=stat)
+ if kind == "PyClassDefNode" and _is_enum_class(node=stat):
+ enums.append(_emit_enum(node=stat, runtime=runtime))
+ elif kind == "CClassDefNode":
+ classes.append(_emit_cclass(node=stat))
+ elif kind == "DefNode":
+ functions.append(_emit_function(node=stat, indent="", skip_self=False))
+ text: str = "\n\n".join(enums + classes + functions)
+ needs_any: bool = "Any" in text
+ needs_callable: bool = "Callable" in text
+ needs_optional: bool = "Optional[" in text
+ needs_union: bool = "Union[" in text
+ needs_array: bool = "array[" in text
+ needs_path: bool = "Path" in text
+ typing_names: list[str] = []
+ if needs_any:
+ typing_names.append("Any")
+ if needs_optional:
+ typing_names.append("Optional")
+ if needs_union:
+ typing_names.append("Union")
+ imports: list[str] = [
+ '"""Type stub for the compiled orcsome3_backend extension (generated from orcsome3_backend.pyx)."""',
+ "",
+ "from __future__ import annotations",
+ "",
+ ]
+ if needs_array:
+ imports.append("from array import array")
+ if needs_callable:
+ imports.append("from collections.abc import Callable")
+ imports.append("from enum import Enum, IntEnum")
+ if needs_path:
+ imports.append("from pathlib import Path")
+ if typing_names:
+ imports.append(f"from typing import {', '.join(typing_names)}")
+ return _ruff_format(source="\n".join(imports) + "\n\n" + text + "\n")
+
+
+def _ruff_format(source: str) -> str:
+ """Format the stub the same way `make format` does, so `--check` is not a false diff."""
+ proc: subprocess.CompletedProcess[str] = subprocess.run(
+ args=[sys.executable, "-m", "ruff", "format", "--stdin-filename", str(STUB)],
+ input=source,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return proc.stdout
+
+
+def main() -> int:
+ """Write `orcsome3_backend.pyi`, or with `--check` exit 1 if the committed file is stale.
+
+ Also refuses to write a stub that is missing `EVENT_TYPES` as `IntEnum` (regression guard:
+ event `.type` must not fall back to `Any`).
+ """
+ parser: argparse.ArgumentParser = argparse.ArgumentParser(
+ description="Generate orcsome3_backend.pyi from orcsome3_backend.pyx"
+ )
+ _ = parser.add_argument(
+ "--check",
+ action="store_true",
+ help="exit 1 if the committed .pyi does not match a fresh generate() (used by make stubs-check)",
+ )
+ args: argparse.Namespace = parser.parse_args()
+ stub: str = generate()
+ if "class EVENT_TYPES(IntEnum):" not in stub or " type: EVENT_TYPES" not in stub:
+ print("generator did not emit EVENT_TYPES as an IntEnum", file=sys.stderr)
+ return 1
+ if args.check:
+ current: str = STUB.read_text(encoding="utf-8") if STUB.is_file() else ""
+ if current != stub:
+ print(f"{STUB.relative_to(ROOT)} is stale. Run: make stubs", file=sys.stderr)
+ return 1
+ return 0
+ _ = STUB.write_text(data=stub, encoding="utf-8")
+ print(f"wrote {STUB.relative_to(ROOT)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())