Metadata-Version: 2.5
Name: lumenui
Version: 0.0.7
Summary: Python SDK for the Lumen UI framework, over its C ABI via ctypes
Project-URL: Homepage, https://lumenfx.dev
Project-URL: Documentation, https://docs.lumenfx.dev
Project-URL: Repository, https://github.com/lumen-fx/lumen
Author: Lumen contributors
License-Expression: MPL-2.0
Keywords: ctypes,ffi,gui,lumen,ui
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: Topic :: Software Development :: User Interfaces
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# Lumen Python SDK

[![ci](https://github.com/lumen-fx/lumen/actions/workflows/ci.yml/badge.svg)](https://github.com/lumen-fx/lumen/actions/workflows/ci.yml)
![python](https://img.shields.io/badge/python-3.9%2B-blue)
[![docs](https://img.shields.io/badge/docs-reference%2Fffi-blue)](https://docs.lumenfx.dev/reference/ffi/)
[![license](https://img.shields.io/badge/license-MPL--2.0-blue)](https://github.com/lumen-fx/lumen/blob/main/LICENSE)

Typed Python bindings for the [Lumen](https://lumenfx.dev) UI framework, over
the C ABI (`include/lumen.h`). State is declarative and reactive: you write a
`Model` whose fields are typed signals, mutate them with plain Python, and the
runtime keeps the UI in sync.

Stdlib only: `ctypes`, no `cffi`, no compiled extension, no build step of its
own. The only thing to build is the Rust side, once.

## Quickstart

```python
import lumen

class Counter(lumen.Model):        # dataclass-style: each field is a signal
    count: int = 0
    label: str = "0 clicks"

app = lumen.App("counter_app", title="Counter")
state = Counter(app)

@app.on_click("bump")              # decorator; handler takes no args
def bump():
    state.count += 1               # typed, autocompleted, syncs to the runtime
    state.label = f"{state.count} clicks"

app.run()                          # blocks until the window closes
```

Handlers may take the clicked `element_id` string or no argument at all;
`def bump():` and `def bump(element_id):` both work, mirroring the C++ SDK's
`void()` and `void(std::string)` overloads. You can also register without the
decorator: `app.on_click("bump", bump)`.

`state.count` and `state.label` *are* signals: reading a field reads the
signal, writing it pushes the new value to the runtime, and markup bound
with `bind-text="label"` reflects it next tick. Field names are the
binding names; override with `Field(name="other")`.

## The surface, most to least abstract

| You want... | Reach for |
|---|---|
| Declarative reactive state | `lumen.Model` + `lumen.Field` |
| A single typed signal | `lumen.Signal[T]` |
| A derived value | `lumen.computed(name, fn, *deps)` |
| To react to a change | `signal.watch(fn)` |
| To query / mutate the live element tree | `lumen.dom` |
| A raw `lumen_signal_*` C call | `lumen.raw` (appendix) |

### Dynamic DOM (`lumen.dom`)

`lumen.dom` reaches the live element tree the way a browser script does:
query and traverse nodes, read and write attributes / classes / text /
inline style, build and rearrange subtrees, read post-layout geometry, and
bind event handlers. A `dom.Node` is a cheap handle; reads return `None` /
empty for a stale handle and mutations chain.

```python
from lumen import dom

row = dom.spawn("row").add_class("item").set_text("hello")
list_node = dom.get_by_id("list")
if list_node:
    list_node.append(row)

row.on("click", lambda e: print("clicked", e.target().handle, e.type()))
```

### `Model` / `Field`

```python
class TodoList(lumen.Model):
    items: list = lumen.Field(default_factory=list)   # array signal (<for>)
    summary: str = "0 items"

state = TodoList(app)
state.items = state.items + [{"id": "t1", "text": "Buy milk"}]  # re-renders <for>
state.summary = f"{len(state.items)} items"
```

- Annotated fields become typed signals (`int`->int64, `float`->float64,
  `bool`, `str`, `lumen.Color`, `list`->array).
- Defaults are applied on construction; use `Field(default_factory=...)`
  for mutable defaults (lists).
- Signals are global by name, so one `Model` instance per app is the
  intended shape (the field names are the binding names markup reads).
- `state.signal("count")` returns the underlying `Signal` handle - for
  `.watch()` or to pass into `computed()`.

### `Signal[T]` - standalone typed handles

```python
count = lumen.Signal("count", 0)          # kind inferred from the initial value
count += 1                                 # typed operators where they make sense
count.value = 10                           # .value / .get() / .set()
tint = lumen.Signal("tint", lumen.Color(255, 128, 0))

@count.watch                               # decorator or count.watch(fn)
def _(new_value):
    print("count is now", new_value)
```

`Signal(name, initial=None, *, type=...)` - pass `type=` when there is no
initial value, or to widen an `int` literal to `float`.

### `watch` and `computed`

`signal.watch(fn)` is an ABI subscription (`lumen_signal_watch`), not a
polling loop: `fn(new_value)` fires on the Lumen tick thread each time the
value commits. A freshly-registered watch also fires once with the current
value on the first tick it is observed. Watches only fire while an app is
running (`run` or `run_headless`), because that is when ticks happen.

`computed(name, fn, *deps)` derives a signal from `fn()`, recomputing
whenever any dependency commits. Direct or decorator form:

```python
count = lumen.Signal("count", 0)

# direct
lumen.computed("label", lambda: f"{count.value} clicks", count)

# decorator (deps first, then decorate the recompute fn)
@lumen.computed("label", count)
def label():
    return f"{count.value} clicks"
```

### Colors

`lumen.Color` is an RGBA tuple with named channels and CSS-style hex:

```python
lumen.Color(255, 128, 0)            # r, g, b (, a=255)
lumen.Color("#ff8000")             # hex string (also #f80 / #ff8000ff)
lumen.Color.from_hex("#ff8000ff")  # explicit
c = lumen.Color("#ff8000"); c.r, c.g, c.b, c.a
c.to_hex()                          # "#ff8000ff"
```

### Typing

The package ships a PEP 561 `py.typed` marker, so once installed the
inline annotations are visible to `mypy` / Pyright and drive IDE
completion for `Model` fields, `Signal[T]`, and the `App` methods.

## Requirements

- Python 3.9+ (stdlib `ctypes` only).
- The Lumen C library, `liblumen`, built as a shared library.
- `App.run()` opens an OS window and a GPU surface. For CI or a no-display
  environment, `App.run_headless(ticks)` drives the full app (scripts,
  bindings, `<for>` and `<if>` reconciliation, and `watch` firing) for a
  fixed number of ticks with no window.

## Build the C library

From the Lumen workspace root:

```sh
cargo build -p lumen            # -> target/debug/liblumen.{so,dylib,dll}
cargo build -p lumen --release  # -> target/release/...
```

## Run the examples

```sh
LUMEN_LIBRARY_PATH=target/debug python sdk/python/examples/counter.py
LUMEN_LIBRARY_PATH=target/debug python sdk/python/examples/todo.py
```

Both scripts add their parent directory to `sys.path`, so they run
straight from a checkout with no `pip install`. `counter.py` is the
quickstart above; `todo.py` exercises a `Model` with a `list` field
driving a `<for>` block (array signals).

## Installing the package

```sh
pip install -e sdk/python
```

Registers the `lumen` package; the distribution is named `lumenui`, which is
also the name it carries on PyPI. The distribution is pure Python: it holds no
compiled extension and bundles no `liblumen`, so one install works on every
platform and the runtime it binds is whichever one the machine already has.
That means something has to provide `liblumen`: either the Lumen toolchain
install (`curl -fsSL https://lumenfx.dev/install.sh | sh`, which puts the
library in `~/.lumen/bin` next to `lumenc`) or a checkout you built with
`cargo build -p lumen`.

## Locating the library

`load_library()` (called on first use) searches, in order:

1. `LUMEN_LIBRARY_PATH` - a direct path to the library file, or a
   directory containing it.
2. `LUMEN_LIB_DIR` - the same override `lumenc` honours, so one setting
   points both at the same runtime.
3. `target/{debug,release}` relative to the current working directory.
4. `target/{debug,release}` relative to the workspace root (found by
   walking up for the workspace `Cargo.toml`).
5. An installed toolchain: the directory holding the `lumenc` on `PATH`,
   then `$LUMEN_PREFIX/bin`, default `~/.lumen/bin`.
6. The system loader's own search paths (`LD_LIBRARY_PATH`, `/usr/lib`, ...).

A checkout you are working in therefore wins over an installed toolchain, and
either one is found without setting anything.

The loaded library's `lumen_abi_version()` is checked against the version
this SDK targets, ABI 0.14. A major mismatch or an older minor raises
`LumenAbiVersionError`.

## Threading

`@app.on_click` handlers and `watch` callbacks fire on **Lumen's tick
thread**, not necessarily the thread that called `run`. `ctypes` acquires
the GIL for you, so touching Python state is safe; a slow or blocking
handler stalls the whole event loop (rendering, input, timers). Keep
handlers fast; hand real work to a Python thread you spawn yourself.

State you mutate from your own threads is still your responsibility to
synchronise. `Signal` set/get themselves are thread-safe (each maps to one
thread-safe C call).

## Errors

Every `lumen_*` call maps a non-OK `LumenStatus` onto a dedicated
exception under `lumen.LumenError` (`LumenBadPathError`, `LumenCssError`,
`LumenScriptError`, ...), so you can `except LumenCssError` instead of
string-matching. `LumenLibraryNotFoundError` and `LumenAbiVersionError`
cover the two pre-call failures.

## Appendix - the raw layer (`lumen.raw`)

`lumen.raw.Signal` is the thin surface every abstraction above is built
on: each method is exactly one `lumen_signal_*` C call. Reach for it only
when you need the raw ABI, for example the array setter.

```python
from lumen import raw

raw.Signal.set_int64("count", 5)          # typed scalar setters/getters
raw.Signal.get_int64("count")             # -> 5
raw.Signal.set_str("label", "hi")         # string scalar
raw.Signal.set_array("rows", [{"id": "a", "name": "x"}])   # <for> array
raw.Signal.get_str("label")               # read back what the FFI last pushed
```

Read-back caveat: `get_str`, `array_len`, and `array_field` return what the
*embedder* last pushed through the FFI, not live in-app state. A write from
the app's own script, or a two-way input binding, is not visible through
them. The number, bool, and color getters do see in-app writes. The typed
layer above hides all of this.
