1 unstable release
Uses new Rust 2024
| new 0.2.0 | Jun 7, 2026 |
|---|
#438 in #ui
Used in 2 crates
280KB
6K
SLoC
rlvgl-playit
A mini-playwright test driver for rlvgl.
rlvgl-playit lets you drive an rlvgl UI from an external host — injecting
input events, querying widget state by tag, and inspecting framebuffer pixels —
over any byte-oriented transport (serial UART, stdin/stdout, TCP, or
in-process).
Features
- Input injection — send any
Eventvariant (tap, pointer, key, tick, multi-touch) through the widget tree, by coordinate or by widget tag. - Gesture routing — optionally pipe raw events through a gesture
recognition pipeline (
EventPipelinetrait) soPointerDown/Upsequences produce the samePressRelease/DoubleTapevents as real hardware. - Event recording — capture a timed input session with per-tick deltas and dump it for replay. Record from live hardware, replay in CI.
- Widget tagging — address widgets by name instead of pixel coordinates.
Tags are
Option<&'static str>onWidgetNode, zero-cost when unused. - Widget queries — ask for bounds, existence, or child count of a tagged widget.
- Framebuffer inspection — dump ARGB8888 pixel windows, synchronised to display present count.
no_stdby default — runs on bare-metal targets (STM32, etc.) with no allocator required.- Transport-agnostic — implement the
PlayitTransporttrait (two methods) to plug in any byte stream. - Text wire protocol — human-readable, backward-compatible with the
original serial commands (
T,D,?), and extensible for new input types.
Quick start
Add the dependency:
[dependencies]
rlvgl-playit = "0.2.0"
Embedded (serial transport)
use rlvgl_playit::{PlayitExecutor, PlayitTransport, StatusData, NullPipeline};
struct SerialTransport;
impl PlayitTransport for SerialTransport {
fn read_byte(&mut self) -> Option<u8> {
serial::pop_rx()
}
fn write_bytes(&mut self, bytes: &[u8]) {
serial::write_bytes(bytes);
}
}
let mut executor = PlayitExecutor::new(SerialTransport);
let mut pipeline = NullPipeline; // or a real GesturePipeline
// In your main loop:
let status = StatusData { tick_count, present_count };
executor.poll(&mut root, &status, Some(&fb_reader), &mut pipeline, |ext| {
// handle app-specific extension commands
});
Gesture routing
use rlvgl_playit::EventPipeline;
use rlvgl::platform::gesture::TapRecognizer;
struct GesturePipeline { tap: TapRecognizer }
impl EventPipeline for GesturePipeline {
fn process(&mut self, event: Event) -> (Option<Event>, Option<Event>) {
(self.tap.process(&event), None)
}
fn tick(&mut self) -> (Option<Event>, Option<Event>) {
(self.tap.tick(), None)
}
}
Now PD100,200 followed by PU100,200 produces a debounced PressRelease
that buttons actually respond to.
Widget tagging
use rlvgl_core::WidgetNode;
let node = WidgetNode::new(my_widget)
.with_tag("settings_btn");
Then from the test host:
QE:settings_btn -> EXISTS:1
QB:settings_btn -> BOUNDS:10,20,80,40
T@settings_btn:40,30 -> OK
Event recording and replay
-> RS <- REC:recording
(user interacts with the screen)
-> RE <- REC:START,4
<- @0 PD100,200
<- @3 PM105,205
<- @6 PU110,210
<- @12 PD300,150
<- REC:END
The @<delta> prefix is the tick count since the previous event. A host
replay script converts these to wall-clock delays and sends the commands back.
Wire protocol
Commands are single lines terminated by \n or \r\n.
| Command | Wire format | Description |
|---|---|---|
| Tap | T<x>,<y> |
Inject PressRelease |
| Press down | TD<x>,<y> |
Inject PressDown |
| Double tap | TT<x>,<y> |
Inject DoubleTap |
| Tick | TK |
Inject Tick |
| Pointer down | PD<x>,<y> |
Inject PointerDown |
| Pointer up | PU<x>,<y> |
Inject PointerUp |
| Pointer move | PM<x>,<y> |
Inject PointerMove |
| Multi-touch | MT<n>:<id>,<s>,<x>,<y>;... |
Inject Touch (s=D/U/C) |
| Key down | KD:<key> |
Inject KeyDown (Enter, Escape, a, F5, ...) |
| Key up | KU:<key> |
Inject KeyUp |
| Tagged tap | T@<tag>:<x>,<y> |
Inject PressRelease to tagged widget |
| Dump pixels | D<x>,<y>,<w>,<h>[,<frames>] |
Dump ARGB hex from framebuffer |
| Query bounds | QB:<tag> |
Get widget bounds |
| Query exists | QE:<tag> |
Check if widget exists |
| Query children | QC:<tag> |
Count children of widget |
| Record start | RS |
Start recording input events |
| Record stop | RE |
Stop recording and dump entries |
| Record dump | RD |
Dump entries without stopping |
| Status | ? |
Get tick/present telemetry |
| Extension | X<payload> |
App-defined (e.g. toggle animations) |
Feature flags
| Flag | Effect |
|---|---|
| (default) | no_std, no allocator — core types, protocol, traits |
alloc |
Enables heap-backed helpers |
std |
Enables alloc plus standard-library integrations |
See OPTIONS.md for the full feature reference.
Test runners
The repository ships ready-to-run playit test suites for every target:
| Target | Make target | Description |
|---|---|---|
| Disco simulator (host) | make test-disco-sim |
Builds rlvgl-disco-sim and runs Rust + Node.js suites over a TCP socket |
| Disco-demo unit tests | make test-disco-demo |
Pure no_std controller tests (cargo test -p rlvgl-app-disco-demo) |
| UEFI simulation | make test-uefi-disco |
Boots rlvgl-uefi-disco headless under QEMU and runs the Node.js suite |
| STM32H747I-DISCO hardware | make test-stm32h747i-disco |
Bridges USART1 (ST-Link VCP) to TCP and runs the Node.js suite |
| All of the above | make test-playit-all |
Runs every suite in sequence |
The rlvgl-playit Node.js client is in playit/node/. See
playit/node/test/ for the test files driven by the runners above.
License
MIT