A grippy little TUI for embedded displays.
knurl is a pixel-native, no_std, allocation-free TUI library for small
panels - OLED and TFT modules driven by microcontrollers like the RP2040. It
wears a Bubble Tea / Charm-inspired
look (rounded chrome, a > cursor, an accented selection, dim everything else)
and is driven by a rotary encoder with one button. The guiding idea: your
screen is a terminal - a compact catalog of stack-only widgets, laid out in
pixels, that feels like a TUI on a 128×64 OLED.
| OLED - 128×64 mono | TFT - 320×240 colour (Charm theme) |
![]() |
![]() |
![]() |
![]() |
Widgets draw onto an abstract RenderTarget and react to Msgs. They never know
what's behind the target - a real panel or a simulator window. That single seam
is what makes this true:
┌──────────────────────── the same Component code ───────────────────────┐
firmware: panel driver (SSD1306, ST7789, …) → Graphics/ColorGraphicsTarget → screens
desktop: SimulatorDisplay<BinaryColor|Rgb565> → Graphics/ColorGraphicsTarget → screens
└────────────────────────────────────────────────────────────────────────┘
SimulatorDisplay is already an embedded-graphics DrawTarget, and the
GraphicsTarget (mono) / ColorGraphicsTarget (colour) adapters are generic over
any such target - so the simulator adds no new render path. Screen code that
runs on hardware runs on your desktop, pixel-for-pixel.
The library is genuinely no_std and bare-metal portable - the machine proof:
rustup target add thumbv6m-none-eabi
cargo build -p knurl-core --target thumbv6m-none-eabi
cargo build -p knurl-graphics --target thumbv6m-none-eabi
cargo build -p knurl-screens --target thumbv6m-none-eabi # the demo's own screensThat last line is the interesting one: knurl-screens is the demo application
itself - every screen you see below - and it links for bare metal, so a screen
file copies into a firmware project unchanged.
The target hardware is a rotary encoder + push button, so UIs are driven by exactly three inputs and nothing else:
- ↑ / ↓ - rotate the encoder (move the cursor / change a value)
- Space - push the encoder button (select / edit / activate)
There is no Back, Left/Right, or text key - the device has none. "Back" is a selectable menu item, never baked into a widget's data; the root menu's "Exit" item asks the host to quit. The simulator reserves no quit key (Esc does nothing); close the window (or pick "Exit") to leave.
knurl is pixel-native: there is no character grid. Coordinates and extents are
pixels (Area is u16), and widgets lay text out by asking the target for three
font metrics - line_height(), char_width(), text_width(s) - then positioning
glyphs by pixel. (Char-LCDs like the HD44780 are out of scope; knurl is
pixel-only.)
-
Semantic styling, per-target rendering. A widget says what a piece of text is (
Style::{Normal, Accent, Muted, Danger, Focus, Inverted}), never how to colour it. Each target decides: the monoGraphicsTargetmaps styles to a 1-bitTheme(inversion / emphasis), the colourColorGraphicsTargetmaps them through a CharmColorTheme(calm lilac selection, accented text, smooth bars - no hardcoded RGB). Semantic primitives likedraw_check/draw_radio/draw_bar/draw_spinnerlet each target pixel-draw a real indicator. -
Drawing of your own. Beside text and fills there are four free-hand primitives -
set_pixel,draw_line,draw_rect,draw_bitmap(a 1-bit sprite) - and they take aStyletoo, so a hand-drawn dial or sparkline stays portable across mono, colour and themes, and is assertable in a test. Each has a default implementation, so one Bresenham and one bitmap format serve every target; the pixel targets override them with the native embedded-graphics ones.Canvas::new(|target, area| ...)wraps a drawing in an ordinary component (dirty gate, self-clear, zero-area guard) so it needs no type of its own. For what portable primitives cannot say - arcs, images, your own font -knurl-graphicshands over the rawDrawTarget, clipped to the widget's area. -
DataProvider models. Data-heavy widgets borrow a trait, not a fixed slice, so an app can back them with its own store (a fixed array, a ring buffer, generated rows) with no copying:
ListModel,TreeModel,TableModel,BarChartModel, andLinesModel(thePager, with awrite_linevariant for streaming data that is never stored whole). A plain&[&str](etc.) still works via blanket impls. A model that changes under the widget can say so -revision()returns a number that changes when the content does, and the widget compares it against the one on screen, so a growing log repaints itself with nobody remembering to ask. The default is a constant ("I keep no revision"), so aconstarray pays nothing. -
Navigation -
Router/Nav. A fixed-depth, heap-free screen-history stack (Router<Id, DEPTH>):push/pop/replace,current(),at_root(). The app matches onrouter.current()to render a screen; a focusable "Back" item pops; "Back at the root" is the cue to exit. -
Dirty + partial redraw, all the way to the bus. Each
Componentcarries aCell-backed dirty flag set inupdate()only when state actually changes. The render loop gates on it: a frame with nothing dirty is skipped entirely, and a widget'sview()self-clears and repaints only its own area - there is no global per-frameclear(). The target accumulates what was touched, sotake_dirty_rect()tells the application which pixels to send - see Partial redraw on the bus. -
Desktop simulator (
knurl-sim). Mono and colour backends overembedded-graphics-simulator, sharing one event/render loop, plus the demos.
A repaint that stays in RAM saves nothing. On a 320×240 panel a full frame is ~150 KB over SPI, and an application that cannot say which pixels moved has to push all of it - once per encoder click, to move one row.
So the target counts. Every draw call unions its box into a dirty rectangle, and the frame ends by asking for it:
app.view(&mut target, AREA);
let Some(r) = target.take_dirty_rect() else {
return; // nothing was drawn: the panel already shows the right picture
};None means a frame that costs zero bytes. Otherwise r is one rectangle,
clamped to the panel, in the same pixel coordinates every Area is in.
Sending it is the driver's business, not the library's: a framebuffer is
contiguous and a region is not, so its rows are copied out by stride into a
scratch buffer. With lcd_async on an ST7789:
let mut scratch = [0u8; MAX_REGION_BYTES];
let row_bytes = r.w as usize * 2; // Rgb565
for row in 0..r.h as usize {
let src = ((r.y as usize + row) * WIDTH + r.x as usize) * 2;
let dst = row * row_bytes;
scratch[dst..dst + row_bytes].copy_from_slice(&frame_buffer[src..src + row_bytes]);
}
display.show_raw_data(r.x, r.y, r.w, r.h, &scratch[..r.h as usize * row_bytes]).await?;What it comes to, measured on the demo application at 320×240 (2 bytes/pixel, full frame = 153 600 B):
| what the user did | region | bytes | of a full frame |
|---|---|---|---|
| an idle frame, nothing dirty | – | 0 B | 0 % |
| one step of a value being edited | 312×10 | 6 240 B | 4.1 % |
| one spinner tick | 312×10 | 6 240 B | 4.1 % |
| a tick moving three indicators five rows apart | 312×50 | 31 200 B | 20.3 % |
| moving inside a form behind tabs | 312×20 | 12 480 B | 8.1 % |
| moving the cursor in a full-screen list | 312×206 | 128 544 B | 83.7 % |
| opening another screen | 320×239 | 152 960 B | 99.6 % |
The granularity is the widget, because a widget's view() clears its own
area: a form repaints the row that changed, and a list that fills the screen
repaints the list. Navigation is a full frame by definition, and that is fine -
it is the click that happens once, not the one that happens every detent. And
the region is one rectangle, not a list, so three small widgets far apart
cost the box that contains them - a reason to keep what animates, together.
Two habits are what keep the region honest:
- never build a widget inside
draw- a fresh widget is dirty, so a rebuilt one repaints (and re-dirties its row) forever. Keep it in the struct and change it with its setter. Measured on the demo's own Indicators screen, which used to get this wrong: the same spinner tick is 4.1 % with the widgets in fields and was 83.7 % when the screen rebuilt its rows every frame; - reach for
clipped(area), notdisplay_mut(), when dropping to raw embedded-graphics: what goes through a raw target is invisible to the region, soclippedconservatively dirties its area and the unclippeddisplay_mutdirties the whole panel.
The catalog covers what most panels need, and then it does not cover the one
thing yours does. The full contract - what you bring, what you get free, and
what a skipped clause costs - is one rustdoc page:
knurl::custom_widget
(cargo doc --open, then custom_widget). The short version:
- a picture - a dial, a sparkline, a logo - is a
Canvas: give it a closure and it brings the dirty gate, the self-clear and the zero-area guard; - something the user drives is a
Componentof your own. Four obligations: aCell<bool>set inupdateonly when the state really changed, a guard for an area too small to draw into, anOutcomefor every event (Ignoredat the edges is what lets the cursor leave your widget), andStyleinstead of colour. It becomes a focus zone with nothing declared -FocusZonehas a blanket impl for everyComponent; - pixels the portable primitives cannot express - arcs, images, your own
font - go through
knurl-graphics'clipped(area)escape hatch, which costs you the theme, monochrome, and testability.
A worked example lives in the demo, written against nothing but the knurl
facade, exactly as firmware would be: thermostat.rs
is a setpoint dial with tests for every clause in
tests/thermostat.rs.
Text & chrome: Label, Title, Separator, Spacer, StatusBar,
Help, Dialog, Tabs.
Data: List, Tree, Table, BarChart, Pager (with follow/tail).
Inputs: Checkbox, Toggle, Counter, Slider, Picker, Radio,
TextInput, and Form (a focus/edit-mode controller over FormFields).
Indicators: Spinner, ProgressBar, LineGauge, Scrollbar,
Paginator.
Layout: VStack / HStack (with Constraint), Padded, Bordered.
Custom drawing: Canvas (a closure, drawn through the portable primitives).
The demo is an application, not a gallery: knurl-screens is
a no_std crate with one screen per file, each implementing
Screen and owning its widgets. It covers the whole
catalog, but arranged as compositions - one form, two forms sharing a layout,
three forms behind tabs, a list and a form on one screen - because a
library of components that do not work together is not a library.
Two hosts run it, and differ only in what a host provides (panel, theme, chrome, frame loop):
oled- monochrome, tuned for a tiny SSD1306 (128×64 default;128x128via arg). Compact chrome for ~5 rows.tft- colour, 320×240 ST7789-class, the default CharmColorTheme, with a persistent status-bar hint and a live log behind the Pager screen: a ring-bufferLinesModelthat gains a line every few ticks (standing in for UART), in follow/tail mode. The screen rendering it is the same file a device builds against aconstarray.
Encoder model throughout; every page either fits or scrolls - nothing is truncated. Both run on the dirty-gated partial-redraw loop.
![]() |
![]() |
![]() |
![]() |
The desktop backend needs SDL2:
# macOS (Homebrew)
export LIBRARY_PATH="/opt/homebrew/lib:$LIBRARY_PATH"
export PKG_CONFIG_PATH="/opt/homebrew/opt/sdl2/lib/pkgconfig:$PKG_CONFIG_PATH"
# Debian/Ubuntu: sudo apt install libsdl2-dev
cargo run -p knurl-sim --example oled # mono OLED, 128×64 (default)
cargo run -p knurl-sim --example oled -- 128x128 # mono OLED, taller variant
cargo run -p knurl-sim --example tft # colour TFT, 320×240Controls everywhere: ↑/↓ rotate, Space selects; "Back"/"Exit" are menu items; close the window to quit.
The README images are produced headlessly (no SDL window - it renders to an off-screen display and writes PNGs), so it works in CI:
cargo run -p knurl-sim --features desktop --example screenshots # → docs/*.png| Crate | no_std |
Depends on | Role |
|---|---|---|---|
knurl-core |
✅ (zero-dep) | - | Traits (RenderTarget, Component), Msg, Style, Area, Router, models, and every widget. |
knurl-graphics |
✅ | embedded-graphics |
GraphicsTarget / ColorGraphicsTarget adapters + Theme / ColorTheme. |
knurl |
✅ | core (+ optional graphics) | Facade re-exporting the public API. |
knurl-screens |
✅ | knurl only |
The demo application: one Screen per file, shared by both demos, built for thumbv6m in CI. |
knurl-sim |
❌ std | facade + embedded-graphics-simulator |
Desktop simulator (mono + colour) and the demo hosts. |
knurl-sim is a workspace member but excluded from default-members, so a
plain cargo test from the root never needs SDL2.
cargo test # core + graphics widgets, host-side (no SDL2)
cargo test -p knurl-sim # ...plus the screenshot matrix (needs SDL2)Three of those deserve naming, because each exists to catch something the others cannot:
-
The screenshot matrix (
knurl-sim/tests/matrix.rs) renders every widget in every state - focused and not, checked and not, editing and not, mono and colour, every border style, empty data, and areas one to four pixels wide - into an area the size of the widget, and compares it against a golden image pixel by pixel (never by PNG bytes, so a new encoder cannot make it lie). The nine pictures indocs/are a shop window; this is the test. When a cell differs you get<name>.actual.pngand<name>.diff.pngbeside the golden, differing pixels in magenta. To regenerate after an intended change - and read the diff before committing it:KNURL_MATRIX=bless cargo test -p knurl-sim --test matrix -
The seeded sweep (
knurl-core/src/smoke.rs) walks every widget and every composition through random events over random areas, including the degenerate ones a layout produces when it runs out of room. Every failure prints a seed that reproduces it. It must run in debug: half of what it looks for is arithmetic overflow, which release builds wrap away. -
The walk (
knurl-screens/tests/walk.rs) drives the demo application itself - every screen opens, every screen can be left, the compositions behave, and a frame costs what it should on the bus.
MIT OR Apache-2.0.







