The simulator

On this page

Running the simulator

A window, an event pump and a keyboard map around the embedded_graphics backend. There is no simulator-specific drawing path — the pixels in the window are the pixels a panel would get, from the same backend, the same components and the same font metrics. The README covers what the crate is; this covers running it.

SDL2

embedded-graphics-simulator links SDL2, so it has to be installed before anything here builds.

Platform
macOSbrew install sdl2
Linux, WSLsudo apt install libsdl2-dev

Nothing else is needed. The simulator is a plain cargo run.

The gallery is a repository of its own, because it is the application and this is the library it draws through. Clone it beside this one:

cd .. && git clone https://github.com/XPUI-Framework/xpui-gallery

Every block below opens by entering it, so each one stands on its own — this page is read by jumping to a section, not from the top.

cd ../xpui-gallery
cargo run -p xpui-gallery

That is the fastest way to see a framework change: the gallery has a screen each for controls, lists, dialogs, scrolling, text, typefaces and one that puts everything on a single page, so a widget that broke shows up in one of them.

Opening a screen of your own instead:

use xpui_simulator::{Board, Panel, Simulator};

fn main() {
    // Your panel. The vendor crates carry ready-made ones; this crate knows
    // no device and opens whatever it is handed.
    let mine = Board::custom("my reader", 480, 800, false);
    Simulator::new(Panel::of(mine)).title("my reader").run(MyScreen::new());
}

There is no default panel: this crate knows no devices, so a caller names one. Board::custom describes any panel; xpui-boards-pimoroni, xpui-boards-xteink and xpui-boards-seeed carry ready-made ones — a project depends on the vendor it targets and not the other two.

Panel::of(board) gives that device its own size and a scale that keeps the window within reach of a laptop display. A 480 × 800 reader is big enough to show at 1:1; a 296 × 128 strip at 1:1 is a postage stamp, so it is tripled.

The board keys walk one board by default: the one the panel was opened on. Simulator::boards(&[..]) is how an application offers more, in its own order. xpui-gallery's gallery/src/main.rs passes all seven.

Panel::of(..).scaled(n) overrides that. Scale is a window concern: doubling every panel pixel changes nothing about what the screen is laid out against.

Boards

Panel::of(board) takes a Board — a panel size, what its keys mean, whether it has a touchscreen, and how much larger than the baseline its chrome should be. Not the chrome itself: whoever wires the backend derives that from the panel's size and that scale. It is the same value a firmware reads, which is what makes "develop in a window, then flash it" true rather than aspirational, and it picks a sensible scale so a 296 × 128 strip is not a postage stamp on a modern display.

cd ../xpui-gallery
cargo run -p xpui-gallery -- --board x4            # the default
cargo run -p xpui-gallery -- --board x3
cargo run -p xpui-gallery -- --board x4pro         # the touch reader
cargo run -p xpui-gallery -- --board sticky
cargo run -p xpui-gallery -- --board badger2040
cargo run -p xpui-gallery -- --board tufty2040
cargo run -p xpui-gallery -- --board inkyframe

--board picks the one it opens on. B walks the rest of them without restarting — see Changing it while it runs — which is the quicker way to see a screen on all of them.

This is worth doing early rather than at the end. A Badger 2040's content band is 90 pixels; a screen that looks spacious at 480 × 800 can have nowhere to put its third row, and the panel is where you find that out. With the default chrome that band is 28 pixels and a list draws no rows at all, which is why Board carries a token preset rather than only a size.

The window lies about how big everything is

A reader's panel is 217 to 257 ppi. A laptop display is around 110, so the window shows it at roughly twice life size: a label that looks generous here is a shade over 2mm on the glass, and a row that looks like a comfortable target is 4mm across.

Nothing in the window can tell you that, so the boards carry the panel's diagonal and Board::tenths_of_a_mm answers in millimetres. A board a finger drives also carries a UI scale — its chrome and its type come out larger than the same panel's would with keys, which is why an X4 and a touch reader of the same size do not look alike in the window either.

Keys and mouse

Named by meaning rather than position, because that is the framework's own contract: a screen asks for Confirm and the host decides what that is.

Key
Up / DownButton::Up / Button::Down — move focus
Left / RightButton::Left / Button::Right — nudge whatever holds focus
Enter, keypad Enter, SpaceButton::Confirm
BackspaceButton::Back
Page Up / Page DownButton::PageBack / Button::PageForward
Hthe home gesture
Q, Escape, or closing the windowquit

Escape cannot be Back, however much it ought to be. embedded-graphics-simulator turns it into SimulatorEvent::Quit before the event reaches us, so there is no key press left to interpret. Backspace is Back; Escape closes the window.

Holding a key sends one press. The window manager's auto-repeat is dropped, because hardware has none: a key held on a device sends one press and stays down, and the framework runs its own repeat off that. Letting the repeats through re-arms that timer on each one, so a held key would step at the desktop's rate rather than the framework's.

Reading something into a press

Keys reach the framework as the hardware sent them. A board can have fewer keys than it has meanings — a badge with three along its bottom edge has no room for a Back key — and folding two together is the firmware's decision, not the simulator's. Simulator::keys is where a caller gets between:

/// Swaps the page keys over, for somebody holding the device the other way up.
struct Swapped;

impl Keys for Swapped {
    fn translate(&mut self, press: Press) -> Option<Button> {
        Some(match press.button {
            Button::PageBack => Button::PageForward,
            Button::PageForward => Button::PageBack,
            other => other,
        })
    }
}

Two directions, and they are not the same thing:

  • translate renames a press. Returning None swallows it. The release of that key follows whatever it became, so a press turned into Back is released as Back — a caller never has to track that.
  • due invents one, from a timer, with no key behind it. It is delivered as a press and its release in the same frame, because there is no finger to lift later and a button left held auto-repeats.

The gallery installs a reader for both, though no board here needs it: every one has a Back key of its own, so presses pass straight through. On a board with three keys and no spare, the reader swallows the first press of the stand-in key and re-issues it when the double-press window shuts — which costs every select a third of a second. See gallery::chord for what to weigh before choosing that arrangement.

Mouse
Left click on the panela tap
Left drag on the panelheld positions, then a swipe or a gesture on release
Left press, held stilla long press after half a second
Left click on a physical buttonpresses it, exactly as its key does
Scroll wheela swipe: wheel up reports SwipeDir::Down, wheel down SwipeDir::Up

Changing it while it runs

Checking a screen on every panel used to be one cargo run per board, each losing whatever you had navigated to — and that state is usually the thing you wanted to look at. These keys change the simulator instead of restarting it, and the screen stack survives all of them.

Key
Bthe next board
Shift+Bthe previous board
+zoom in, as far as the window allows
-zoom out, down to life size
Eshow or hide the device body
Swrite the panel to target/screenshots/ and print the path

Switching board installs a different backend, because a different panel size needs one. The screens carry on running on it: the app owns the stack, and the screen on top is re-measured against the new panel on the next frame. That is the whole point — the same screen, on another panel, without navigating back to it each time.

One backend is kept per board and reused, so cycling for an hour costs under two megabytes in total rather than a panel's worth of pixels per press.

The window never resizes

There is no resize API: MultiWindow fixes its SDL window and its streaming texture in the constructor. So the window is opened once, large enough for the largest board these keys can reach, and every smaller one is letterboxed into the middle of it. Hiding the body does not shrink the window — it grows the letterbox.

That is also what limits zoom. A scale whose device would not fit the window is refused, and a 480 × 800 reader inside its body is already 1165 pixels tall, so those boards stay at life size. Zoom is for the small panels: a Badger 2040 opens tripled, a Tufty 2040 doubled, and both go further with the body hidden.

Zoom changes nothing about the layout. Scale is a window concern — the panel is the same number of pixels at 1× as at 3× — and a screen that re-lays-out when you zoom means the scale has leaked into the board.

Screenshots

S writes the panel: not the window, and not the device drawn around it. A picture of a screen with a simulated body in it is not a picture of what the device would show.

The file is a 1-bit BMP named <slug>-<n>.bmp, and its path is printed — a screenshot you cannot find is not a screenshot. n is the first number not already taken, so pressing the key twice gives two files and a later run does not overwrite an earlier one's. XPUI_SCREENSHOT_DIR moves them elsewhere.

The mouse as a finger

A click and drag goes through CrossPoint's touch model, ported constant for constant from the firmware's InputManager, so a gesture that works in this window works on the device and one the device would refuse is refused here.

A tapup to 59 px of travel, reported at the point the finger went down
A swipe60 px on either axis, within 700 ms, resolved to its dominant axis
A long press500 ms still, cancelled by 28 px of movement
Backa right swipe starting in the left 25%
Homean up swipe starting in the bottom 14%
Menua down swipe starting in the top 14%

Two of those numbers look like typos and are not. A tap survives 59 px while a long press dies at 28, because they answer different questions: one asks whether the finger was ever still, the other whether it went far enough to have meant somewhere else. They were once the same number, and the 29..59 px gap that left — where an ordinary finger roll was neither a tap nor a swipe — is the reason they are not. A tap is reported where the finger landed for a related reason: the contact point drifts 10-20 px as a finger rolls off during a lift, and routing the release point makes small targets feel unreliable.

A click that missed the panel is never a touch, and neither is a click on a board whose touch is false — a Badger 2040 has no touchscreen, so clicking its panel does nothing at all. A device cannot receive a touch at a coordinate it has no way of producing, and a simulator that invented one would let a screen ship depending on it. Use the keys, or a board that has a touchscreen.

The device around the panel

A board that has described its body — see Bezel — is drawn inside it. The panel is inset into a shell drawn from the device's published millimetre dimensions, with its real buttons where a thumb would find them.

cd ../xpui-gallery
cargo run -p xpui-gallery -- --board badger2040   # five buttons, all on the front
cargo run -p xpui-gallery -- --board x3           # Up and Down on the side

Clicking one presses it and holding one shows it held, which is worth doing early for the same reason picking the right board is: it is how you notice that the Badger's five buttons are the only input it has, and that a screen built around a fifth control has nowhere to put it.

The layout is kept in tenths of a millimetre rather than pixels, so the scale the window opens at changes how big the device is drawn and nothing about where anything sits on it. That scale is chosen from the whole window rather than from the panel, because a small panel can sit in a comparatively large body.

The row reading Back OK Up Dn inside the canvas is not one of these buttons. It is firmware UI, which the real device draws on the e-ink too.

E hides the body, leaving the bare panel letterboxed in the middle of the window — which is what a board that has never described one shows.

Nothing a screen draws can leave the panel rectangle. The backend wraps every draw in DrawTargetExt::clipped, so a widget that measured itself wrong is cut off at the panel's edge rather than painted over the bezel — which is what a device would do, and what makes the body around the panel safe to draw at all.

--frames N

cd ../xpui-gallery
cargo run -p xpui-gallery -- --frames 60

Stops after that many frames instead of waiting for the window to close.

This is what makes the simulator testable. The loop otherwise ends only when the app's screen stack empties, when Q is pressed, or when the window is closed — none of which happens on its own. A CI run, or any check that the loop even starts, would hang until something killed it.

--frames is the gallery's own flag, parsed by hand in xpui-gallery's gallery/src/main.rs and passed to Simulator::frames. An application embedding the simulator wires up its own way of setting it, or none.

Headless

cd xpui-gallery
SDL_VIDEODRIVER=dummy cargo run -p xpui-gallery -- --frames 30

dummy gives SDL a windowless target, so this works over ssh and on a CI runner with no display. Combined with --frames it is a complete smoke test of the loop, which is exactly what xpui-gallery's gallery/tests/simulator.rs does — it runs the binary headlessly for 30 frames and again for 1, and fails if either panics or overruns a deadline.

That test exists because the simulator once died on startup every single time: Window::events() panics if it is called before the first update(), and the loop called it on its first iteration. The workspace built and every unit test passed. Building is not running.

No window at all

A window is the wrong tool for asserting on a screen. xpui-screenshot renders to memory instead, with no SDL and no simulator involved:

use xpui_eg::{Backend, Palette};
use xpui_screenshot::{Framebuffer, assert_screenshot};

let backend = Backend::leak(
    Framebuffer::new(480, 800),
    Palette::new(BinaryColor::On, BinaryColor::Off),
);
unsafe { xpui::host::install(backend) };

App::new(MyScreen::new()).render();

backend.with_display(|frame| {
    assert_screenshot("my_screen", frame);                  // against a committed PNG
    assert!(frame.ink_in(0, 0, 480, 56) > 0, "it has a header");
});

Input goes in the same way the simulator feeds it — backend.begin_frame(ms), backend.press(Button::Confirm), app.tick() — so a test can press a button and render what came back. xpui-gallery's gallery/tests/typeface.rs is the shortest worked example of the assertion above. xpui-gallery's gallery/tests/screenshots.rs is the fuller one: it pairs goldens with ink_in checks like the one beside the assertion above, and runs the pair across the gallery's seven — nine screens on seven panels, each against a PNG committed as <screen>_<board slug>.png, pixel for pixel. That one calls check_screenshot rather than assert_screenshot, the same comparison handing its report back instead of panicking with it, so a run names every board that moved rather than the first. A mismatch writes target/diff/<name>.png either way — expected, actual and the differences, side by side.

frame.write_bmp("name") and frame.thumbnail(60) are still there for looking at a frame that has no golden. Nothing compares either of them, so do not pair one with a screenshot assertion: two artifacts where only one is authoritative is how the other one ends up trusted.

When it does not run

SDL2 not found at link timeInstall it (above) and build again.
No window on a remote sessionSet DISPLAY, or run headless with SDL_VIDEODRIVER=dummy.
The window redraws while nothing changesExpected. E-ink refreshes slowly, so the app only repaints when something changed — but SDL still has to be pumped every frame or the OS decides the app has hung. The loop pushes the unchanged frame and sleeps rather than spinning a core.

Edit this page on GitHubIt lives in xpui-simulator; a correction goes there.