Metadata-Version: 2.4
Name: wbb
Version: 0.1.4.1
Summary: Render a live website into an off-screen pixel buffer and expose it as a programmable primitive
Author-email: Delicious <singularitwenty@proton.me>
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: License :: OSI Approved :: MIT License
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: numpy>=1.24
Requires-Dist: websockets>=12.0
Requires-Dist: Pillow>=10.0
Requires-Dist: aiohttp>=3.9
Provides-Extra: display
Requires-Dist: PySDL2>=0.9.16; extra == "display"
Provides-Extra: kde
Requires-Dist: pydbus>=0.6; extra == "kde"
Requires-Dist: PyGObject>=3.50; extra == "kde"
Provides-Extra: x11
Requires-Dist: python-xlib>=0.33; extra == "x11"
Provides-Extra: all
Requires-Dist: PySDL2>=0.9.16; extra == "all"
Requires-Dist: pydbus>=0.6; extra == "all"
Requires-Dist: PyGObject>=3.50; extra == "all"
Requires-Dist: python-xlib>=0.33; extra == "all"
Requires-Dist: PyTurboJPEG>=1.7; extra == "all"
Provides-Extra: fast-jpeg
Requires-Dist: PyTurboJPEG>=1.7; extra == "fast-jpeg"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: ruff; extra == "dev"

# wbb — WebView Buffer Bridge

Render a live website into an off-screen pixel buffer and expose it as a programmable primitive.

`wbb` drives headless Chrome over the Chrome DevTools Protocol (CDP), decodes its screencast stream into RGBA frames, and writes those frames into a POSIX shared-memory double-buffer. Any process, in the same script or a separate one, can attach to that buffer: read frames with zero-copy views, display them in a window, record them to disk or ffmpeg, or run a filter pipeline over them. Mouse, keyboard, and DOM-query input can be forwarded back into the page, so the buffer is a full interactive surface, not just a screenshot source. The point is to get pixels out of a real, JS-executing browser into your own process at low latency, without polling screenshot RPCs and without tying the renderer to the same process as the consumer.

## Installation

```bash
pip install wbb
```

System requirements:

- **Chrome or Chromium** on `PATH` (checked names: `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`, `chrome`; common macOS/Windows install paths are also checked). Override with the `CHROME_PATH` environment variable.
- **ffmpeg**, only if you pipe frames to it yourself (see `examples/05_record_to_ffmpeg.py`). Not a library dependency.

### Optional extras

Each extra maps to a specific capability. The display window and the placement backends are optional and platform-specific, so pick the one that matches your desktop.

```bash
pip install 'wbb[display]'    # PySDL2 + pysdl2-dll — the SDL2 DisplayClient window
pip install 'wbb[kde]'        # pydbus + PyGObject — KWin placement backend (KDE, X11 OR Wayland)
pip install 'wbb[x11]'        # python-xlib — EWMH placement backend (non-KDE X11 desktops)
pip install 'wbb[fast-jpeg]'  # PyTurboJPEG — much faster screencast JPEG decode
pip install 'wbb[all]'        # display + both placement backends + fast-jpeg
pip install 'wbb[dev]'        # pytest, mypy, ruff
```

Quote the brackets (`'wbb[kde]'`) — some shells (zsh) treat them as globs otherwise.

**Which placement extra do I need?** Positioning and always-on-top require a backend that matches your live session:

| Session | Extra | Backend |
|---|---|---|
| KDE Plasma (X11 or Wayland) | `wbb[kde]` | KWin D-Bus scripting |
| GNOME/X11, XFCE, i3, other X11 | `wbb[x11]` | X11 / EWMH |
| GNOME Wayland, other non-KDE Wayland | *(none available)* | no-op — see below |
| Not sure | `wbb[all]` | best available |

If no matching backend is installed (or available), `DisplayClient` still runs — it just falls back to a plain, compositor-placed window and logs **one** warning at startup telling you exactly which package to install, with a copy-pasteable command targeting your current interpreter (so it works inside a venv).

> **Wayland reality check.** Wayland has no protocol for a client to set its own absolute position or stay-on-top. On KDE Wayland the *only* mechanism is the KWin scripting backend (`wbb[kde]`). On non-KDE Wayland (e.g. GNOME) there is currently no positioning backend at all — run under an X11/XWayland session if you need precise placement there.

> **Note on the SDL2 system library.** `pip install 'wbb[display]'` pulls in `pysdl2-dll`, which bundles the SDL2 binaries, so you usually don't need a system `libsdl2`. If you prefer your distro's SDL2, install PySDL2 without the bundled DLL and provide `libsdl2` yourself.

## Quickstart

```python
import asyncio
from wbb import BrowserBridge, FrameBuffer

async def main():
    buf = FrameBuffer("my_buf", 1280, 720)
    async with BrowserBridge(buf) as br:
        await br.navigate("https://example.com")
        frame = await buf.next_frame()
        frame.save("screenshot.png")
    buf.close()
    buf.unlink()

asyncio.run(main())
```

## Features

- CDP screencast capture, no screenshot polling
- Shared-memory double-buffer with zero-copy frame reads
- Cross-process display: render in one process, view in another
- Composable filter pipelines (crop, scale, color, blur, custom), with adjacent color filters fused into a single lookup table
- SDL2 window with always-on-top, absolute position, and click-through, where the desktop supports it
- DOM-aware interaction: `wait_for_selector`, `click_element` (selector or text fallback)
- Browser process pooling for reuse across multiple sites
- Frame recording to PNG sequences or an ffmpeg pipe
- Per-bridge extra HTTP header management (add/remove by group)
- Event hooks: `frame`, `navigate`, `load`, `console`, `error`

## Documentation

### `wbb.buffer`

#### `class FrameBuffer(name, width, height, *, attach=False)`

Shared, mutable RGBA pixel buffer backed by two POSIX shared-memory segments (`<name>_a`, `<name>_b`) plus a small metadata segment that tracks which one is current. The writer alternates buffers and flips a pointer after each write, so a reader never sees a torn frame.

- **Parameters**
  - `name: str` — shared-memory identifier. Two `FrameBuffer` instances (same process or different processes) with the same name attach to the same data.
  - `width, height: int` — frame dimensions. The writer's `write()` calls must match exactly.
  - `attach: bool` — `False` (default) creates and owns the segments; `True` attaches to segments created elsewhere. Only the owner should call `unlink()`.

```python
buf = FrameBuffer("ex01", 1280, 720)          # creates segments
viewer_buf = FrameBuffer("ex01", 1280, 720, attach=True)  # attaches, e.g. in another process
```

##### `FrameBuffer.write(rgba: np.ndarray) -> int`

Writer-side. Copies an H×W×4 `uint8` RGBA array into the inactive buffer, then flips the active pointer. Returns the new monotonically increasing `frame_id`. Raises `ValueError` if `rgba.shape != (height, width, 4)`. Called internally by `BrowserBridge`. You generally don't call this yourself unless compositing frames manually (see `examples/07_display_multiple_sites_with_pool.py`).

##### `FrameBuffer.read() -> Frame`

Returns the latest frame as a **zero-copy view** into shared memory. The view goes stale the instant the writer flips again. It doesn't update in place; it just silently starts showing old pixels once a newer frame exists elsewhere. Call `frame.copy()` if you need to keep it past the next write.

```python
frame = buf.read()
frame.save("latest.png")
del frame  # release the view
```

##### `async FrameBuffer.next_frame(timeout: float = 5.0) -> Frame`

Blocks, without busy-polling (backed by a `threading.Condition`), until a frame newer than the last one observed arrives, or `timeout` seconds pass, then returns `read()`. On timeout it does **not** raise or return `None`. It just returns whatever is currently committed, so repeated calls under a slow or idle screencast are harmless.

The blocking wait runs on a small dedicated thread pool owned by the buffer, *not* asyncio's shared default executor. This matters under load: the default executor is also where `BrowserBridge` runs JPEG decode, so parking readers there could starve the decoder when many consumers wait at once. The dedicated pool keeps the two from competing.

```python
frame = await buf.next_frame(2)  # wait up to 2s for a new frame
```

##### `async for frame in buf:` (`FrameBuffer.__aiter__`)

Infinite async iterator, equivalent to calling `next_frame()` in a loop with the default timeout. Used by consumers like `examples/06_combined_scenario.py`'s `record_task`/`monitor_task`.

##### `FrameBuffer.close() -> None`

Releases this process's own memory mappings, wakes any parked `next_frame()` waiter, and shuts down the buffer's wait pool. **Drop every `Frame` (and any array derived from one, e.g. via `.crop()`) before calling this.** CPython won't unmap memory while a buffer-protocol export is still live on it, the same rule `mmap` follows generally. If a view is still outstanding, `close()` logs a debug message and returns instead of raising. The segment releases once you drop the last reference and GC runs.

##### `FrameBuffer.unlink() -> None`

Destroys the underlying named shared-memory segments. Only the creator (`attach=False`) should call this. Safe to call even if `close()` couldn't fully release a mapping: `unlink()` only removes the name so no new process can attach. This process's own mapping is freed by the OS at exit regardless.

##### Context manager

`with FrameBuffer(...) as buf:` calls `close()` on exit, and `unlink()` too if the buffer wasn't created with `attach=True`.

### `wbb.frame`

#### `class Frame` (frozen dataclass)

A snapshot of one rendered frame.

- **Fields**
  - `data: np.ndarray` — H×W×4 `uint8` RGBA. Read-only view into shared memory unless produced by `.copy()`.
  - `width, height: int`
  - `frame_id: int` — unique within one `FrameBuffer` session. Two `Frame`s with the same id are the same underlying frame.
  - `timestamp: float` — `time.monotonic()` at write time.

##### `Frame.copy() -> np.ndarray`

Detaches the data from shared memory entirely. A writable array, safe to keep past `FrameBuffer.close()`.

##### `Frame.crop(x, y, w, h) -> Frame`

Returns a new `Frame` whose `data` is a zero-copy sub-view. `frame_id`/`timestamp` are inherited from the source. Still pins shared memory, same lifecycle rule as the parent.

##### `Frame.save(path, *, format=None) -> None`

Writes the frame to disk via Pillow. Format is inferred from the extension unless given explicitly (e.g. `"PNG"`, `"JPEG"`).

```python
frame = await buf.next_frame()
frame.crop(0, 0, 640, 360).save("corner.png")
```

### `wbb.browser`

#### `class BrowserBridge(buffer, *, width=1280, height=720, screencast_quality=80, screencast_max_fps=30, enable_input=None, headless_args=None, extra_headers=None)`

Owns one headless Chrome process and its CDP WebSocket connection. Frames arrive via `Page.startScreencast` push, no polling. Each JPEG payload is decoded to RGBA off the event loop and written into `buffer`. With PyTurboJPEG installed, decode goes straight to RGBA in a single libjpeg-turbo pass (no intermediate channel-shuffle copy); without it, Pillow is the fallback.

- **Parameters**
  - `buffer: FrameBuffer` — destination for decoded frames.
  - `width, height: int` — viewport size, also passed to `Emulation.setDeviceMetricsOverride`.
  - `screencast_quality: int` — JPEG quality 1-100 for the CDP stream.
  - `screencast_max_fps: int` — upper bound Chrome is asked to push at.
  - `enable_input: Optional[bool]` — `None` or anything but `False` enables `click`/`key`/`type`/`scroll`/`move`/`click_element`; `False` makes all of them raise `RuntimeError`.
  - `headless_args: list[str] | None` — extra Chrome CLI flags, appended after the built-ins.
  - `extra_headers: dict[str, str] | None` — seeds an initial header group (see `header_add` below).

```python
async with BrowserBridge(buf, width=1280, height=720, enable_input=True) as br:
    await br.navigate("https://example.com")
```

##### `await BrowserBridge.start()` / `await BrowserBridge.stop()`

Manual lifecycle pair behind the `async with` context manager. `start()` launches Chrome, attaches a flattened CDP session to its `about:blank` page target, enables `Page`/`Runtime`/`Network`, pushes any seeded headers, sets the viewport, and starts the screencast. `stop()` stops the screencast, drains any in-flight frame decode/write tasks, cancels the receive loop, closes the socket, and terminates (then kills, if unresponsive after 5s) the Chrome process.

##### `await BrowserBridge.navigate(url: str) -> None`

Sends `Page.navigate`. Does **not** wait for load itself. Use `wait_for_selector`, the `"load"` event hook, or your own `asyncio.sleep`/polling, depending on what "loaded" means for your page.

##### `await BrowserBridge.reload(*, ignore_cache: bool = False) -> None`

##### `await BrowserBridge.eval(expression: str, *, await_promise: bool = False) -> Any`

Evaluates `expression` via `Runtime.evaluate` with `returnByValue=True` and returns the resulting JSON-compatible value.

##### `await BrowserBridge.wait_for_selector(selector: str, *, timeout: float = 10.0) -> bool`

Polls via a `MutationObserver` (not a busy loop) until `document.querySelector(selector)` matches or `timeout` elapses. Returns whether it matched.

```python
found = await br.wait_for_selector('i[data-jsl10n="search-input-button"]')
```

##### `await BrowserBridge.set_viewport(width: int, height: int) -> None`

Updates `width`/`height` and re-issues `Emulation.setDeviceMetricsOverride`. Does **not** resize the screencast's `maxWidth`/`maxHeight`. Restart the bridge if you need that to change too.

##### Extra HTTP headers: `header_add`, `header_remove`, `header_remove_all`, `headers_current`

CDP's `Network.setExtraHTTPHeaders` replaces the *entire* header set on every call. There's no incremental add/remove at the protocol level. `BrowserBridge` works around this by tracking headers as named groups and re-merging plus re-sending the full set on every mutation.

- `await header_add(headers: dict[str, str]) -> str` — registers a group, pushes the merged set, returns an opaque group id.
- `await header_remove(group_id: str) -> None` — removes one group by id (others unaffected), re-pushes. No-op on an unknown id.
- `await header_remove_all() -> None` — clears every group, pushes an empty header set.
- `headers_current() -> dict[str, str]` — local merged state, no CDP round-trip.

```python
gid = await br.header_add({"X-Trace-Id": "abc123"})
...
await br.header_remove(gid)
```

##### Input: `click`, `move`, `scroll`, `key`, `type`

All raise `RuntimeError` if the bridge was constructed with `enable_input=False`.

- `await click(x: float, y: float, *, button: str = "left") -> None` — dispatches a `mousePressed`+`mouseReleased` pair at pixel coordinates.
- `await move(x: float, y: float) -> None`
- `await scroll(x: float, y: float, delta_x: float = 0, delta_y: float = 100) -> None`
- `await key(key: str, *, kind: str = "keyDown", text: Optional[str] = None) -> None` — `key` is a DOM key value (`"Enter"`, `"a"`, etc.). `text` defaults to `key` itself for single printable characters and to `""` otherwise. **Known rough edge:** a manual `keyDown`/`keyUp` pair for `"Enter"` doesn't reliably submit forms in practice. Sending `"\r"` through `type()`, or calling `eval()` to submit the form directly, both work as documented workarounds (see `examples/03_multi_step_automation.py`).
- `await type(text: str) -> None` — inserts `text` via `Input.insertText`, as if pasted or typed.

##### `await BrowserBridge.click_element(selector=None, *, text=None, nth=0, scroll_into_view=False, timeout=5.0, poll_interval=0.15, button="left") -> bool`

Clicks an element resolved by selector and/or text instead of a hardcoded pixel position.

- **Resolution order:** if `selector` matches anything, the `nth` match is used as-is (no ancestor redirection: a selector match is exactly what you asked for). Otherwise `text` is matched case-insensitively against each element's own direct text-node content, and each match is redirected to its nearest clickable ancestor (`a`, `button`, `input`, `select`, `textarea`, `[role="button"]`, `[onclick]`, `summary`, `label`, or itself), then de-duplicated.
- Polls (`timeout`/`poll_interval`) until something resolves; returns `False` on timeout rather than raising.
- Clicks the resolved element's `getBoundingClientRect()` center via `click()`.
- `scroll_into_view=True` re-resolves and calls `scrollIntoView({block: 'center', inline: 'center'})` first. If the element vanished between find and scroll, returns `False` instead of clicking stale coordinates.
- Raises `ValueError` if neither `selector` nor `text` is given; raises `RuntimeError` if input is disabled.

```python
await br.click_element("button.pure-button.pure-button-primary-progressive")
await br.click_element(text="Deutsch")  # text fallback, no selector
```

##### `BrowserBridge.on(event: str, cb) -> None`

Registers a callback for `"frame"`, `"navigate"`, `"load"`, `"console"`, or `"error"`. `cb` may be sync or a coroutine function; both are awaited correctly.

```python
br.on("load", lambda: print("loaded"))
br.on("navigate", lambda url: print(f"[nav] {url}"))
```

##### `async BrowserBridge.frames() -> AsyncIterator[Frame]`

Thin pass-through async iterator over the underlying `FrameBuffer`.

##### `BrowserBridge.buffer -> FrameBuffer`

The buffer this bridge writes into.

### `wbb.pool`

#### `class BrowserPool(max_idle: int = 3)` (exported as `BrowserPool`, defined as `ChromePool`)

Reuses warm Chrome processes across `BrowserBridge` instances instead of paying full process-launch cost per site.

What pooling saves is the dominant cost — launching Chrome and completing the CDP attach/enable handshake. What it does **not** reclaim is renderer memory: Chrome does not shrink its heap back down after a heavy page, so a pool of `max_idle` warm processes is also a pool of `max_idle` peak-RSS processes. Use a smaller `max_idle` (or `0` to disable reuse) if resting memory matters more than launch latency. `acquire` is reuse-or-spawn and does not cap concurrent live bridges — `max_idle` bounds the resting footprint, not the peak.

##### `await BrowserPool.acquire(buffer: FrameBuffer, **kwargs) -> BrowserBridge`

Pops an idle bridge if one exists: rebinds it to `buffer`, applies the requested viewport (if `width`/`height` are in `kwargs`), navigates it to `about:blank`, and restarts its screencast against the new consumer. Otherwise constructs and starts a fresh `BrowserBridge(buffer, **kwargs)`.

##### `await BrowserPool.release(br: BrowserBridge) -> None`

Returns `br` to the idle pool if there's room under `max_idle`: it clears the previous owner's event hooks, pauses the screencast (so a parked bridge isn't decoding blank frames into a detached buffer), and navigates to `about:blank`. If the pool is full, calls `br.stop()` instead.

```python
pool = BrowserPool(max_idle=3)
br = await pool.acquire(buffer=buf, width=640, height=360)
await br.navigate(url)
...
await pool.release(br)
```

### `wbb.filters`

Every filter has the signature `(np.ndarray) -> np.ndarray` over an H×W×4 `uint8` RGBA array. Built-ins are pure: they return new arrays or views and never mutate input, so user-defined filters of the same shape plug in without modification.

| Function | Signature | Behavior |
|---|---|---|
| `crop` | `crop(x, y, width, height) -> Filter` | Zero-copy sub-region slice. |
| `scale` | `scale(width, height) -> Filter` | Pillow `LANCZOS` resize. |
| `flip` | `flip(*, horizontal=False, vertical=False) -> Filter` | Zero-copy slice-based flip. |
| `grayscale` | `grayscale() -> Filter` | ITU-R BT.601 luminance; alpha preserved. |
| `colorize` | `colorize(r=1.0, g=1.0, b=1.0, a=1.0) -> Filter` | Per-channel multiply, clipped to [0, 255]. |
| `brightness` | `brightness(delta: int) -> Filter` | Additive RGB shift; alpha untouched. |
| `contrast` | `contrast(factor: float) -> Filter` | Scales RGB around midpoint 128. |
| `blur` | `blur(radius: int = 2) -> Filter` | Separable box blur via `scipy.ndimage.uniform_filter`; falls back to a 5-tap neighbor average if scipy isn't installed. |
| `compose` | `compose(first, second) -> Filter` | `second(first(x))`. |
| `chain` | `chain(*filters) -> Filter` | Left-to-right composition of any number of filters. |
| `identity` | `identity() -> Filter` | Pass-through. |

**Color filter fusion.** `colorize`, `brightness`, and `contrast` are per-channel point operations, implemented as 256-entry lookup tables rather than per-frame float math. When you `chain` (or `compose`) adjacent color filters, their tables are fused at build time into a *single* lookup, so a run of color filters costs one table gather per frame regardless of how many you stacked. A non-color filter (`crop`, `scale`, `blur`, `grayscale`, `flip`, or a custom one) breaks the run; fusion resumes after it. This is transparent — the chained result is bit-identical to applying the filters one at a time.

```python
# These three fuse into one lookup table:
pipeline = filters.chain(
    filters.colorize(r=0.95, g=0.95, b=1.1),
    filters.contrast(1.1),
    filters.brightness(8),
)

# Here the crop breaks the run; colorize and contrast still fuse with each other:
pipeline = filters.chain(
    filters.colorize(r=1.1),
    filters.contrast(1.2),
    filters.crop(0, 0, 640, 360),
)
```

### `wbb.display` (SDL2 `DisplayClient`)

Requires the `display` extra (PySDL2 + SDL2). Renders a `FrameBuffer` into a window, running everything (SDL event polling and frame pull) as a single coroutine on the caller's own asyncio loop. No second thread, no second main loop. Filters run off-loop in a thread pool, and frames are uploaded to the GPU via `SDL_LockTexture` (writing straight into the texture's staging memory).

#### `class DisplayClient(buffer, *, title="wbb", wm_class="wbb-display", filters=None, on_mouse_event=None, on_key_event=None, on_scroll_event=None, window_size=None, position=None, monitor=None, always_on_top=False, borderless=False, click_through=False, max_fps=60.0)`

- **Parameters**
  - `buffer` — anything with `.width`, `.height`, and an async `next_frame(timeout)` (i.e. a `FrameBuffer`).
  - `wm_class: str` — give each concurrent `DisplayClient` a distinct value. The KWin and X11 placement backends match windows by this. It's set as both the X11 `WM_CLASS` and the Wayland app-id, so matching works on either session type.
  - `filters: list[Filter] | None` — run in a thread-pool executor, not inline on the event loop, so a slow chain (Pillow resize, scipy blur) never stalls CDP receive or input dispatch.
  - `position: tuple[int, int] | None` — see the coordinate model below. `None` (default) means **no position requested** — the compositor places the window naturally. When given, it must be a 2-tuple of `int` (raises `ValueError` otherwise).
  - `monitor: int | None` — selects how `position` is interpreted. `None` (default): `position` is a **global virtual-desktop** pixel. An `int`: `position` is **local to that monitor**, an index into `list_displays()`. See the coordinate model below.
  - `always_on_top, borderless` — best-effort. `always_on_top` silently no-ops if no placement backend supports it on the current session. `borderless` is unconditional (`SDL_WINDOW_BORDERLESS` at window creation) and always works.
  - `click_through: bool` — real on X11 sessions (via the Shape extension's input region); a clean, logged no-op under KWin's Wayland scripting backend, which has no input-transparency mechanism at all. Check `is_click_through_active()` to know which happened.
  - `max_fps: float` — caps how often frames are pushed (gates entry into the filter chain, not just the final present). `0` disables the cap.

```python
display = DisplayClient(buf, title="wbb: filtered view", filters=pipeline, window_size=(640, 360))
await display.run_async()
```

#### Coordinate model (read this before setting `position`)

There is one coordinate space every placement backend actually consumes: SDL's **global virtual-desktop** space, where each monitor has an origin that may be offset from `(0, 0)`. A monitor to the left of your primary has a negative `x`; a taller monitor sitting below a shorter one mounted higher has a positive `y`. `list_displays()` reports each monitor's real origin and size.

`DisplayClient` lets you address that space two ways:

- **Global mode (`monitor=None`, the default):** `position` is an absolute desktop pixel, placeable anywhere across all monitors. This is the most predictable choice on offset multi-monitor layouts — what you pass is exactly what the compositor receives. Compute your target from `list_displays()`:

  ```python
  from wbb.display import DisplayClient, list_displays

  d = list_displays()[0]                      # your chosen monitor
  gx = d.x + 20                               # 20px in from its left edge
  gy = d.y + d.height - win_h - 20            # 20px up from its bottom edge
  display = DisplayClient(buf, position=(gx, gy), monitor=None, window_size=(win_w, win_h))
  ```

- **Monitor-local mode (`monitor=<int>`):** `position` is local to that monitor's origin, which `DisplayClient` adds for you. `monitor=0` does **not** guarantee your leftmost or primary monitor — index order is whatever the OS reports. Check each `list_displays()` entry's `.x`/`.y`/`.width`/`.height`.

  ```python
  # bottom-left corner of monitor index 1, in that monitor's own pixels:
  display = DisplayClient(buf, position=(0, mon1_h - win_h), monitor=1)
  ```

##### `await DisplayClient.run_async() -> None` / `DisplayClient.run() -> None`

`run_async()` opens the window and runs until `stop()` is called, the user closes the window, or `next_frame()` errors out. `run()` is a sync wrapper (`asyncio.run(self.run_async())`) for non-async call sites.

**Startup placement retry.** A freshly created toplevel isn't guaranteed to have finished its first map/configure round-trip with the compositor the instant `SDL_CreateWindow` returns, so an early placement request can be superseded by the compositor's own initial placement. To cover this, `DisplayClient` re-applies `always_on_top`/`position` for the first few render-loop iterations rather than relying on a fixed sleep. This costs a handful of extra D-Bus/Xlib round-trips, only during startup.

##### `DisplayClient.stop() -> None`

Sets a flag the running `run_async()` loop checks every iteration.

##### `DisplayClient.set_position(position, position_y=None, *, monitor=...) -> None`

Moves the window after startup. Accepts either a single `(x, y)` tuple or two separate ints:

```python
display.set_position((100, 200))
display.set_position(100, 200)
```

The `monitor` argument selects the coordinate mode:

- Omit it — keep whatever mode is currently in effect (global by default).
- `monitor=None` — explicitly use global virtual-desktop coordinates.
- `monitor=<int>` — `position` is local to that monitor.

No-op if called before `run_async()` has created the window. Re-arms the placement retry window to also cover monitor hot-plug or compositor-reset edge cases.

##### `DisplayClient.get_position() -> WindowPosition`

Returns the last-*requested* position in whatever convention was last used (global by default, or monitor-local if an int monitor was set), not necessarily the resolved global coordinate actually sent to the backend. `WindowPosition` is a frozen dataclass with `x`, `y`.

##### `DisplayClient.set_always_on_top(above: bool) -> None`

##### `DisplayClient.set_click_through(enabled: bool) -> bool`

Returns whether it actually took effect, same contract as the constructor flag.

##### `DisplayClient.is_positionable() -> bool` / `DisplayClient.is_click_through_active() -> bool`

Query the currently active placement backend's real capabilities, post-startup.

##### `wbb.display.list_displays() -> list[DisplayBounds]`

Enumerates monitors via `SDL_GetDisplayBounds`. Safe to call before any window exists. `DisplayBounds` is a frozen dataclass: `index, x, y, width, height`. `x`/`y` are in SDL's single global virtual-desktop coordinate space (a monitor to the left of your primary can have a negative `x`), which is also what every placement backend expects.

#### Placement backends

`wbb.display.placement` (not typically used directly) selects a backend in priority order and keeps the first that activates:

1. **KWin D-Bus scripting** — KDE Plasma, X11 *or* Wayland. Sets geometry and `keepAbove` through KWin's own scripting interface over the session bus. Requires `pydbus` + `PyGObject` (`wbb[kde]`). On a Wayland session this is the only backend that can position windows. Has no input-transparency mechanism, so `click_through` is a logged no-op here.
2. **X11 / EWMH** — non-KDE X11 desktops. Positions via the `_NET_MOVERESIZE_WINDOW` client message (which window managers honor for managed windows, unlike a raw `XConfigureWindow`), sets `_NET_WM_STATE_ABOVE`, and does click-through via the Shape extension. Requires `python-xlib` (`wbb[x11]`). Naturally inert under Wayland (no X11 display to open).
3. **No-op fallback** — anything else (non-KDE Wayland, macOS, Windows, or a KDE box with the deps missing). Logs **one** warning at startup that names the exact package and `pip` command for your detected session and current interpreter, then makes `always_on_top`/`position`/`click_through` silently do nothing.

Each backend is required to fail cleanly rather than raise; an unexpected exception is caught and treated as a declined activation.

## Examples

The `examples/` directory in the source repository is the practical reference for composing the primitives above:

- **`01_display_with_filter.py`**: `BrowserBridge` + `DisplayClient` with a filter pipeline (built-in `colorize`/`crop` chained with a user-defined vignette).
- **`02_monitor_and_react.py`**: polls frames, computes a pixel-diff fraction over a region, and triggers a `click()` plus a saved screenshot on change. Graceful Ctrl-C shutdown via signal handlers.
- **`03_1_wait_for_selector.py`**: `wait_for_selector()` and `click_element()` in place of fixed sleeps.
- **`03_multi_step_automation.py`**: a full navigate, type, click, eval, screenshot pipeline, including selector and text-fallback clicking on the same page.
- **`04_cross_process.py`**: one process renders into a named `FrameBuffer` (`attach=False`), a second process attaches (`attach=True`) and displays it. Run as `renderer`/`viewer` from two terminals.
- **`05_record_to_ffmpeg.py`**: pipes raw RGBA frames into an `ffmpeg` subprocess at a fixed FPS with real-time pacing.
- **`06_combined_scenario.py`**: one `FrameBuffer` shared concurrently by a live `DisplayClient`, a PNG-snapshot recorder, and a change monitor.
- **`07_display_multiple_sites_with_pool.py`**: `BrowserPool` driving several sites at once, composited into a single grid and shown through one `DisplayClient`.
- **`08_clip_display_to_element.py`**: crops the display to a single page element's bounding box.

## Troubleshooting placement

If `always_on_top`/`set_position()` seem to do nothing, the startup log says why and what to install — read that first. Common cases:

- **"no backend available … Wayland session":** you're on non-KDE Wayland, where client-side positioning isn't possible. On KDE Wayland, install `wbb[kde]`. Elsewhere, use an X11/XWayland session.
- **Installed the dep but still no effect, inside a venv:** make sure it landed in *this* environment. `pip install pydbus` can go to your user site (`~/.local`) where the venv won't see it. The startup warning prints a command using your current interpreter — use that exact line. Verify with `python -c "import pydbus, gi"` (KWin) or `python -c "import Xlib"` (X11) from the same Python that runs your app.
- **Window won't move past a monitor edge on KDE:** make sure you're passing a global coordinate that actually falls inside a monitor (`monitor=None` plus a target computed from `list_displays()`), not a value that lands off all displays.

## License

MIT. See `LICENSE`.

## Contributing

Issues and pull requests welcome. For non-trivial changes, open an issue first to discuss the approach.

## Maintainer notes (publishing)

```bash
pip install "setuptools>=68" wheel "numpy>=1.24" "websockets>=12.0" "Pillow>=10.0" "aiohttp>=3.9" "PyGObject>=3.50" "PyTurboJPEG>=1.7"
rm -rf dist/ build/ *.egg-info
# bump version in pyproject.toml and wbb/__init__
python -m build
twine check dist/*
twine upload dist/*
git tag v0.1.4
git push origin v0.1.4
```
