Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- [audio.md](audio.md) — `audiodev` interfaces
- [bledev.md](bledev.md) — Bluetooth Low Energy: one async API on boards, laptops and browsers
- [app-and-board-config.md](app-and-board-config.md) — the application loop
- [lvgl.md](lvgl.md) — LVGL on PyDevices: `display_driver`, the three sister projects, sync and async timers

## Drivers

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ what you can compile:
|---|---|---|
| **Raw graphics / canvas** | Direct pixel, line, and shape drawing | [`displaydev`](displaydev.md) with [`pygraphics`](https://github.com/PyDevices/pygraphics) |
| **Pure-Python GUI** | Portable buttons, lists, themes, and screen management with nothing to compile | [`pdwidgets`](https://github.com/PyDevices/pdwidgets) |
| **C-native GUI** | Complex vector widgets and a C-accelerated animation engine | [`lvgl`](https://github.com/PyDevices/lvgl-bindings) — see [using LVGL with PyDevices](https://github.com/PyDevices/lvgl-bindings/blob/main/docs/using-lvgl-with-pydevices.md) |
| **C-native GUI** | Complex vector widgets and a C-accelerated animation engine | [`lvgl`](https://github.com/PyDevices/lvgl-bindings) — see [using LVGL with PyDevices](lvgl.md) |

All three sit on the same board contract, so the choice does not change how your
hardware is configured.
Expand Down
178 changes: 178 additions & 0 deletions docs/lvgl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Using LVGL with PyDevices

How to run [LVGL](https://lvgl.io/) on the PyDevices display, input, and timing
packages, and what [`display_driver.py`](../lib/display_driver.py) does for you.

## The three sister projects

The PyDevices LVGL family bundles this integration for each interpreter:

| Repository | Interpreter |
|---|---|
| [lvgl-micropython](https://github.com/PyDevices/lvgl-micropython) | MicroPython firmware |
| [lvgl-circuitpython](https://github.com/PyDevices/lvgl-circuitpython) | CircuitPython firmware |
| [lvgl-python](https://github.com/PyDevices/lvgl-python) | CPython (`pydevices-lvgl` wheels on TestPyPI) |

All three consume the bindings generated by
[lvgl-bindings](https://github.com/PyDevices/lvgl-bindings), and all three share `displaydev`,
neutral board capabilities, and `multimer` — so the same LVGL Python code is
portable across them. You can even develop it interactively in
[Jupyter](jupyter.md).

The rest of this page covers wiring LVGL up by hand, which is what you need with
upstream [lv_micropython](https://github.com/lvgl/lv_micropython) rather than one
of the sister projects.

## 1. Install the minimum PyDevices packages

```python
import mip
INDEX = "https://PyDevices.github.io/mip"
mip.install("pydevices", index=INDEX) # displaydev, appdev, display_driver and the rest of lib/
mip.install("github:PyDevices/pydevices/board_configs/<your_board>")
```

Replace `<your_board>` with a path from
[board configs](board-configs.md),
or follow
[install workflows](install-workflows.md).

## 2. Build or obtain LVGL firmware

Follow upstream [lv_micropython](https://github.com/lvgl/lv_micropython) for your
board. PyDevices supplies the flush and input glue via `board_config.py`; LVGL
supplies the UI toolkit.

For production ESP32 projects, consider
[kdschlosser's lvgl_micropython](https://github.com/kdschlosser/lvgl_micropython)
C drivers wired through `BusDisplay`.

## 3. Wire `board_config` to LVGL

Your `board_config.py` should expose:

- `display_drv` — a `displaydev` driver with `blit_rect`, dimensions, and rotation
- neutral input callables where present: `host_read`, `touch_read`,
`keypad_read`, `encoder_read`, `encoder_button_read`

Board configs describe hardware; they do not create an application-level `appdev.App`.
Connect LVGL's display flush callback to copy LVGL's draw buffer through
`display_drv.blit_rect` — or use the packaged `display_driver`, which does it
for you.

## `display_driver`

[`display_driver.py`](../lib/display_driver.py) is the LVGL coordinator. It
lives in this repository's `lib/` and ships with `pydevices` (the mip package,
the wheel, and the frozen module in a micropython-pydevices build), beside the
`board_config` idiom, `appdev`, `events`, `keys` and `multimer` it needs. The
sister projects carry only `lvgl` and `fs_driver`
([loading fonts at runtime](https://github.com/PyDevices/lvgl-bindings/blob/main/docs/fonts.md)).

With `display_driver`, LVGL input is wired automatically through its own
`app` (an `appdev.App`) and virtual touch / encoder / keypad devices.

> **Do not instantiate or poll `appdev` in an LVGL app.** `lv.task_handler()`,
> driven by `display_driver.event_loop` plus `multimer`, already drains input.
> Window-close (`QUIT`) is handled by the bridge's `HostInput` path.

Build the UI, then hand control over with `app.run()`.

### Wheel and swipe input

A mouse wheel or two-finger trackpad swipe drives the virtual encoder
device, so it adjusts whichever control the LVGL group has focused —
provided that control entered edit mode, which only happens once a click
lands on it (an LVGL core behavior: changing focus always drops edit mode,
so a handler that sets it back on must run on the `FOCUSED` event, not
`PRESSED`, which fires too early).

By default the encoder reads a single axis and never touches focus, matching
prior versions. Call `display_driver.set_wheel_mapping()` to change that:

```python
import display_driver

# Horizontal sliders read best adjusted along their own axis, with the
# perpendicular swipe stepping between them:
display_driver.set_wheel_mapping(adjust_axis="h", adjust_sign=-1, navigate=True)
```

- `adjust_axis`: `"v"` (default) or `"h"` — which axis adjusts the focused
control's value. Match it to the control's orientation: horizontal
sliders read best with `"h"`; vertical sliders and knobs, `"v"`.
- `adjust_sign`: `1` (default) or `-1` to flip the adjust direction.
- `navigate`: `False` (default) or `True` to let the other axis move group
focus between controls (`lv.group_t.focus_next`/`focus_prev` on the
default group) — wheel-only browse-and-tweak, with no keyboard or extra
indev required.
- `navigate_sign`: `1` (default) or `-1` to flip which way focus travels.
Separate from `adjust_sign` because the two axes come from different
sources with their own conventions — SDL and Win32 disagree about the
sign of vertical scroll, so "swipe down goes to the next control" is a
claim about that axis on that platform, not about the mapping as a whole.

A wheel event's legacy integer delta and its float "precise" delta
disagree about which one is real depending on the platform's `usdl2`
build — confirmed empirically to differ between a desktop MicroPython
build and a CPython one on the same machine. `display_driver` resolves
this per event already; if you ever read wheel deltas directly instead of
through `display_driver`, don't assume either field is the trustworthy one
without checking on your own target.

## Sync versus async timers

`display_driver` includes the LVGL `event_loop`, which requires `multimer`.
Inspect **`app.timer_async`** — derived from `board_config.timer_async`, or
from the display driver's `requires_async_timer` — to see which backend was
selected:

| `app.timer_async` | Applies to |
|---|---|
| `False` (the desktop default) | MCUs, MicroPython Unix, CPython Linux — uses `multimer.auto.Timer` |
| `True` | PyScript, Jupyter, or desktop with `PYDEVICES_TIMER_ASYNC=1` — uses `multimer.AsyncTimer` |

`display_driver` passes it straight through as
`event_loop(asynchronous=app.timer_async)`. When it is true, `display_driver`
drives both ticks and `display.show()` from its asynchronous LVGL refresh loop.

The desktop `board_config` reads **`PYDEVICES_TIMER_ASYNC`** for the PG/SDL
branch (the default comes from `AutoDisplay` and is normally `False`); PyScript
and Jupyter always use `timer_async=True`. To force async on desktop, export
`PYDEVICES_TIMER_ASYNC=1` in the shell that launches the process, or set it
before `board_config` loads:

```python
from boarddev import env_set
env_set("PYDEVICES_TIMER_ASYNC", "1")
import display_driver
```

`env_set` works on every interpreter; `os.environ` exists only on CPython.

The frame rate is the display's: `PYDEVICES_REFRESH_MS` changes it for LVGL and
non-LVGL apps alike, and `PYDEVICES_FPS_PRINT` prints it
([displaydev](displaydev.md)).

## Verifying an integration

[`lv_test_timer.py`](https://github.com/PyDevices/pydevices-examples/blob/main/lib/examples/lv_test_timer.py)
is a single smoke test that follows `app.timer_async`. Its UI reports the
autodetected interpreter, OS, display driver class, screen, timer backend, and
LVGL version, plus a seconds counter, spinning arc, frame rate, and tap button.
It never sets `PYDEVICES_TIMER_ASYNC` itself — set it in the parent shell if you
want a specific desktop mode.

`python examples/lv_test_timer.py kit` runs a timed LVGL timer and input check,
prints a `KIT_RESULT=` JSON line, then quits. To drive that across every desktop
interpreter in both modes, use the harnesses documented in
[pydevices-examples/tools/README.md](https://github.com/PyDevices/pydevices-examples/blob/main/tools/README.md).

## See also

- [Architecture](architecture.md)
- [displaydev](displaydev.md)
- [multimer](multimer.md)
- [appdev](appdev.md)
- [lvgl-bindings](https://github.com/PyDevices/lvgl-bindings) — the generator,
and [loading fonts at runtime](https://github.com/PyDevices/lvgl-bindings/blob/main/docs/fonts.md)
2 changes: 1 addition & 1 deletion docs/newcomers.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Read [the board contract](board-peripherals.md) before adding a board and [app a

- Use displaydev and pygraphics for direct drawing.
- Use [pdwidgets](https://github.com/PyDevices/pdwidgets) for a pure-Python widget toolkit.
- Use [LVGL](https://github.com/PyDevices/lvgl-bindings) when the firmware or the pydevices-lvgl wheel provides the lvgl module.
- Use [LVGL](lvgl.md) when the firmware or the pydevices-lvgl wheel provides the lvgl module.

The hardware configuration remains the same whichever layer you choose. Ready-to-run programs live in [pydevices-examples](https://github.com/PyDevices/pydevices-examples), not this product repository.

Expand Down