Skip to content

Simulator

scrollkit.simulator is a desktop emulation of the CircuitPython display stack. It is what lets you develop, test, and demo ScrollKit apps with no hardware.

What it emulates

  • displayioBitmap, Palette, TileGrid, Group, Display, FourWire, OnDiskBitmap.
  • adafruit_display_textLabel.
  • adafruit_bitmap_font — BDF font loading (the same .bdf fonts the hardware uses).
  • terminalio — the built-in terminal font.
  • DevicesMatrixPortalS3.

A pygame window renders the virtual LED matrix pixel-for-pixel, so what you see on screen matches what the panel shows.

Two display classes

UnifiedDisplay is the display an app uses — it auto-selects the real displayio backend on CircuitPython and the simulator on desktop, so your app code never branches on platform. SimulatorDisplay is a thin subclass of UnifiedDisplay (~80 lines) that adds only desktop interactive-window ergonomics: it auto-opens the pygame window on the first show(), plus scale/pitch constructor knobs. screenshot() / start_recording() / save_gif() / save_video() and the hardware_timing/throttle/strict flags all live on UnifiedDisplay itself and are safe no-ops (return None) on real hardware — you get them whether you ship against UnifiedDisplay or use SimulatorDisplay directly. Reach for SimulatorDisplay specifically when you want the auto-opened interactive window (tests, demos, the dev harness).

from scrollkit.display.simulator import SimulatorDisplay

display = SimulatorDisplay(width=64, height=32)
await display.create_window("My ScrollKit App")

Why it matters

Because the simulator emulates the CircuitPython APIs, your application code is identical on desktop and device. UnifiedDisplay picks the simulator on desktop and the real displayio backend on CircuitPython — you never write platform branches.

Keep them in sync

If the simulator ever diverges from real hardware behaviour, fix the simulator, not the shared display code — the device is the source of truth.

The verification workflow

The simulator can model the real board's speed and RAM (opt in with hardware_timing, or feel it in real time with throttle), so the classic trap — looks great at desktop speed, crawls on the ~100×-slower device — surfaces before you flash. The scrollkit.dev toolkit (desktop-only) turns that into a tight build-and-prove loop:

flowchart TB
    write["Write app<br/>(subclass ScrollKitApp, fill the queue)"] --> discover["Discover the API<br/>capabilities() · performance_guide()"]
    discover --> run["run_headless(app, strict=True)"]
    run --> model["PerformanceManager accumulates modeled µs/frame<br/>from the calibrated matrixportal_s3_baseline.json"]
    model --> gate{"strict gate:<br/>frame &gt; ~50 ms budget<br/>or peak RAM &gt; usable?"}
    gate -->|over budget| fail["raises FeasibilityError<br/>result.ok == False"]
    gate -->|within budget| validate["validate(app)<br/>static + dynamic checks"]
    fail --> write
    validate -->|issues found| write
    validate -->|clean| green["make test-unit<br/>+ make lint-errors"]
    green --> flash["Flash to device"]

The feasibility numbers are measured, not guessed: the shipped MatrixPortal S3 baseline was captured from a real board. See Performance for the cost model and Adding New Hardware for recalibration.

Screenshots

SimulatorDisplay.screenshot(path) saves the current frame to an image file — handy for documentation, bug reports, and visual tests:

await display.show()
display.screenshot("frame.png")   # returns the path, or None on hardware

It captures whatever is currently on the simulated matrix. On real hardware (no pygame) it returns None, so the same call is safe to leave in cross-platform code.

Requires pygame on desktop: pip install pygame.

Recording animated GIFs

screenshot()'s sibling captures many frames and encodes an animated GIF — ideal for a README, a docs preview, or a bug report. Turn recording on, run the animation, then save:

display.start_recording()
for _ in range(80):
    await content.render(display)
    await display.show()          # each shown frame is captured
display.save_gif("demo.gif")      # encodes + returns the path (None on hardware)

save_gif(path, *, fps=20, target_width=360, max_colors=48, frame_step=1, ...) downscales to target_width, shares one adaptive palette across frames, and only stores each frame's changed region, so files stay small. Raise frame_step (keep only every Nth frame) for an even smaller file; raise the display's pitch (e.g. SimulatorDisplay(pitch=4)) for crisper recordings.

To capture a whole ScrollKitApp headlessly there's a one-call helper:

from scrollkit.dev import record_gif

record_gif(MyApp(), "demo.gif", seconds=4)   # target_width=, max_colors=, ... forwarded

This is exactly how the Demo Gallery previews are generated (demos/render_gifs.py). Like screenshot(), recording is desktop-only and a no-op on hardware.

Recording MP4 video

For full-colour animation an MP4 is far smaller and smoother than a GIF (no 256-colour palette, real inter-frame compression), so it's the right format for a site hero or a long clip. The recording flow is identical — just save with save_video instead of save_gif:

display.start_recording()
for _ in range(120):
    await content.render(display)
    await display.show()
display.save_video("hero.mp4")    # H.264 MP4; returns the path, or None on hardware

save_video(path, *, fps=24, target_width=None, crf=20, preset="medium", border=0, border_color=(10, 10, 13)) pipes the recorded frames straight to ffmpeg. target_width optionally downscales (None keeps native size); crf trades size for quality (≈18 best … 24 smaller; 20 is a good default); border adds a dark bezel of that many pixels on every side, like a real sign's frame.

The whole-app one-call helper mirrors record_gif:

from scrollkit.dev import record_video

record_video(MyApp(), "hero.mp4", seconds=6, border=22)   # crf=, target_width=, ... forwarded

This is how the landing-page hero is generated (demos/render_hero.py, run via make hero). MP4 recording needs the ffmpeg binary on your PATH (brew install ffmpeg); without it save_video returns None. Like every recording call, it's desktop-only and a no-op on hardware.

Fonts: BDF vs PCF

ScrollKit ships and uses BDF fonts (under scrollkit/simulator/fonts/), and the simulator + hardware both load them with the same bitmap_font.load_font(path) API. BDF is plain-text and easy to work with, which is why it's the default.

On a memory-constrained device, PCF is the more efficient choice for larger fonts:

BDF PCF
Format text binary
Load cost parses the whole font into RAM glyphs read from flash on demand
Best for small fonts, the simulator, development large fonts on the MatrixPortal S3

On real CircuitPython hardware, both formats load through the identical adafruit_bitmap_font.bitmap_font.load_font(path) API, so switching is a one-line change there:

font = bitmap_font.load_font("/fonts/MyFont.pcf")   # instead of .bdf

The desktop simulator does not implement PCFscrollkit.simulator.adafruit_bitmap_font only parses BDF text; pointing it at a .pcf file loads silently with zero glyphs (blank text, no error). Keep testing with the .bdf version in the simulator, and swap to the converted .pcf only for the on-device build.

Convert a BDF to PCF on a desktop with bdftopcf (part of the X11 font utils):

bdftopcf MyFont.bdf -o MyFont.pcf

Recommendation: keep BDF as the default (it works everywhere and the simulator is not RAM-constrained); convert to PCF only the specific large fonts you load on hardware, where the RAM saving matters. BDF parity is preserved either way — the same fonts remain available as .bdf.