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())