denise-fbdev 0.1.0

Legacy Linux fbdev backend for Denise, for kernels with no usable DRM device.
Documentation

Denise

A direct-rendering UI toolkit for embedded Linux and systems without a desktop environment.

CI Licence MSRV Milestone Core Targets

Kiosks, digital signage, industrial HMIs, Raspberry Pi panels, in-vehicle displays.

No X11. No Wayland. No browser engine. No managed runtime. One static binary that opens the display, draws, and reads input.

Show me the code

use denise::{Rect, Role, Size, theme};
use denise_ui::widgets::{Button, Label, TextInput};
use denise_ui::Ui;

#[derive(Clone, Copy, PartialEq, Eq)]
enum Message {
    Greet,
}

let mut ui: Ui<Message> = Ui::new(Size::new(460, 260), theme::DARK);
let root = ui.root();

ui.add(root, Label::new("What is your name?"), Rect::new(20, 20, 388, 20));
let name = ui.add(root, TextInput::<Message>::new(), Rect::new(20, 44, 388, 34)).unwrap();
ui.add(
    root,
    Button::new("Greet", Message::Greet).with_role(Role::Primary),
    Rect::new(20, 90, 110, 34),
);

Widgets do not run callbacks. A button holds a value of your type and emits it when pressed, so every state change happens in one match you wrote rather than in a closure somewhere else:

for message in ui.drain_messages().collect::<Vec<_>>() {
    match message {
        Message::Greet => { /* read the field, update a label */ }
    }
}

There are no dirty flags, no invalidate() calls and no repaint bookkeeping anywhere in that. Type into the field and the toolkit repaints the field, not the window. That is the one thing this library is really about, and the way you use it is by not doing anything.

The whole runnable version is examples/hello — eighty lines, half of them comments.

cargo run -p hello                                          # a window
cargo run -p hello --no-default-features --features kiosk   # the display itself

A real one

examples/table-editor is the same idea grown up: a scrolling grid, an edit form, validation, a confirmation modal, CSV persistence and a real font.

cargo run -p table-editor                                          # a window
cargo run -p table-editor --no-default-features --features kiosk   # the display itself
cargo run -p table-editor -- --font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf

The same application, both times. app.rs — the tree, the widgets, all 470 lines of it — never learns which; only main.rs differs, and only by about fifty lines per backend.

The choice is the application's, and it is made at compile time. The toolkit does not choose and offers no way to, because it cannot: aarch64-unknown-linux-gnu is the same target on a kiosk Pi and on a Pi running the desktop image, so a probe in a library would be wrong half the time — and wrong means a binary that opens nothing on a machine somebody has already shipped. A cargo feature settles it, which also means the kiosk build never compiles winit at all.

Three things it is showing, none of which is obvious from the outside:

  • There is no grid widget. There are four widgets — label, button, panel, text field. A row is a full-width Button with the cell Labels placed on top of it; labels are not interactive, so a click falls through them to the button underneath and arrives as Select(index). That is how most of the widgets you will miss get assembled.
  • Nine row nodes exist, however many records there are. Scrolling changes what they display. Rebuilding the tree per frame would be easier to write and would throw away focus and the caret every time anybody typed.
  • The rules live away from the drawing. table.rs knows no widget exists — what a valid row is, what to select after a delete, whether re-selecting a row counts as an edit — and every rule in it is unit tested without a display.

--font loads any TrueType or OpenType file. Without one it falls back to the built-in 8×8 bitmap font and says so, which is the tiered font story denise-text documents, demonstrated rather than asserted.

What it costs when nothing happens

The number that matters for a panel that runs for a year. On a Raspberry Pi 3 A+ at 1920×1080, the panel demo left untouched for ten seconds — with a text field focused, so its caret is blinking — draws 20 frames, wakes 20 times, and spends 80 ms of CPU in total, most of that on the two full repaints every double-buffered swapchain owes at startup.

It blocks in poll on the input descriptors and the caret deadline rather than spinning. With nothing focused there is no deadline either, and it blocks indefinitely. Move the pointer and it repaints two cursor-sized rectangles, not a megapixel.

Where it runs

Backend Status
Bare Linux, DRM/KMS denise-drm ✅ Pi 3 A+ at 1920×1080, async page flips, hardware cursor plane, console restored on exit
Bare Linux, fbdev denise-fbdev ✅ fallback when there is no /dev/dri
Desktop: macOS, Windows, Linux denise-winit ✅ development and preview
Embedded in a macOS app denise-macos NSView over a CoreGraphics bitmap context
Embedded in a Windows app denise-win32 ✅ child HWND over a DIB section
Embedded via COM/ActiveX denise-activex ✅ registered, sited, scriptable, with a type library PowerShell reads
Embedded in anything else denise-ffi ✅ stable C ABI, hand-written header

The same table-editor binary, unchanged and unconfigured, on two of them. Each picked up the platform's own font without being told to, and neither knows which one it got:

The macOS shot is mid-edit: a sixth record has been added and the form filled in but not applied, so the status line is reporting the record as it currently stands rather than as it is being typed.

The third machine has no window system, so there is nothing to screenshot. These are photographs of a Raspberry Pi 3 A+ driving a 1920×1080 display over DRM/KMS — no X, no Wayland, no compositor, no desktop. Same binaries, rebuilt with --no-default-features --features kiosk, which is the only thing that changes.

The moiré is the camera against the panel, not the renderer.

On the left is the same tree after F2. Every colour comes from a semantic role rather than a literal value, so a theme swap is one call and the contrast between text and its background is derived rather than hoped for.

hello is in the bitmap font because it never asks for another — that is what keeps it eighty lines. table-editor searches the font directories and found /usr/share/fonts/dejavu/DejaVuSans.ttf by itself. Same toolkit, two tiers, one machine.

On a Raspberry Pi, read docs/raspberry-pi.md first — a stock Pi has no /dev/dri at all until the vc4 KMS overlay is enabled, and that one line decides whether you get real page flips or a tearing firmware framebuffer.

The crates

Crate
denise Core types, traits, damage tracking, theming no_std + alloc
denise-render Software rasteriser and the built-in font no_std + alloc
denise-text Glyph sources, atlas, line layout no_std + alloc
denise-ui Scene graph, scene stack, widgets, cursor sprite no_std + alloc
denise-drm Linux DRM/KMS — the primary target Linux
denise-fbdev Linux fbdev fallback Linux
denise-evdev Input, keyboard layouts, dead keys, console muting Linux
denise-winit Desktop development and preview any
denise-macos Embeddable NSView macOS
denise-win32 Child-HWND control Windows
denise-activex COM/ActiveX shim, scriptable Windows
denise-ffi Stable C ABI, cdylib any

Text rendering comes in three tiers, chosen by feature so you pay for what you draw: the built-in 8×8 bitmap font (0 KB, always there), TrueType via fontdue (+145 KB, truetype), and full shaping via cosmic-text (+3.1 MB, shaping).

Examples

hello Start here. Eighty lines: a message enum, a tree, an event loop. Builds for a window or a bare display.
table-editor A record editor with a grid, a form, validation, a modal and real fonts. Builds for a window or for a bare display.
hello-rect The damage proof: a bouncing rectangle that repaints two rectangles, not a window.
panel The widget tree, a modal and a cursor sprite, on bare Linux with no X.
kiosk The instrumented loop: input latency and frame-time percentiles.

Several examples take --snapshot out.ppm, which draws one frame and exits. No display needed — useful over SSH, for reviewing a layout, and for diffing a theme change before and after. panel also writes its live scanout buffer on F12, which is how you screenshot a machine that has no desktop to screenshot: see docs/raspberry-pi.md.

cargo run -p hello -- --snapshot hello.ppm
cargo run -p denise-ui --example showcase -- dark showcase.ppm

Status

M5. Denise drives a real display with no desktop environment, has a user interface to put on it, text that types æøå including dead keys, and a way to embed all of it in somebody else's application.

M0 Surface abstraction, damage tracking, preview backend
M1 Software rasteriser, theming
M2 DRM/KMS, fbdev, evdev, a real Pi
M3 Scene graph, widgets, cursor sprite
M4 Text engine, glyph atlas, keyboard layouts
M5 C ABI, macOS NSView, Windows control, ActiveX shim

Everything through M5 has run on real hardware, not only in CI. The full history — what each milestone cost, what was measured, and what was tried and abandoned — is in docs/design.md.

Known gaps, deliberately not hidden

  • No layout engine. Nodes are positioned with explicit rectangles relative to their parent, which is what a fixed-resolution panel wants. A constraint solver can be added over this without changing anything below it.
  • Four widgets. Label, button, panel, text field. Everything else is assembled from them, as table-editor shows.
  • No text selection, clipboard or word motion in TextInput. The measurement it needs exists; the editing model does not.
  • Only two keyboard layouts, US and Norwegian, and the Norwegian AltGr assignments are a reconstruction that wants checking against real hardware.
  • Touch is unverified on hardware. The multitouch slot path is unit tested and a single touch routes to widgets as a pointer would, but no physical touchscreen has driven it.
  • The ActiveX control has never been hosted in a real form editor. It registers, sites, activates, scripts, sinks events, and draws the design-time view a form editor asks for — the last of those checked pixel by pixel on the Windows runner and by eye in examples/host — but no VB6 form or MFC dialog editor has actually held it. See docs/windows.md.
  • denise-win32 DPI changes are unverified, and it has never been hosted inside a real dialog.

Documentation

docs/design.md How it is built and why — architecture, rasteriser, text, keyboards, theming, and the milestone history
docs/raspberry-pi.md Getting a Pi to hand over a display at all, and what to check when it will not
docs/windows.md The Win32 control and the ActiveX shim, including the toolchain traps
docs/releasing.md How a version goes to crates.io, why all twelve share one number, and what each guard is for

Constraints

  • unsafe_code = "forbid" in denise. unsafe is allowed in backend crates only, every block carrying a // SAFETY: comment.
  • Zero allocation in the render hot path. DamageTracker is fixed-capacity; coalescing degrades to a bounding box rather than allocating.
  • The core builds no_std + allocdenise, denise-render, denise-text and denise-ui all of them. --no-default-features is checked in CI.
  • CI builds the C ABI's example with a C compiler and runs it, and compiles the header as C++ as well — extern "C" is only load-bearing if somebody does. A Windows runner builds denise-win32 and denise-activex and runs their tests, because it is the only machine that can.
  • CI cross-compiles the core to aarch64-unknown-linux-gnu and armv7-unknown-linux-gnueabihf, and asserts the core's dependency tree contains no platform crates. An embedded build that quietly starts compiling winit is a regression.
  • MSRV 1.95, bumped deliberately rather than tracking stable.

Development

cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo run -p hello
cargo run -p denise-ui --example showcase -- dark showcase.ppm   # no display needed
cargo run -p denise-text --example specimen -- specimen.ppm      # ditto, for fonts

The embedding backends, each on its own platform:

cargo build -p denise-ffi --release && make -C denise-ffi/examples run
cargo run -p denise-macos --example embed                        # a real window
cargo run -p denise-macos --example embed -- snapshot out.ppm    # no window server
cargo run -p denise-win32 --example embed
cargo run -p denise-activex --example host                       # needs regsvr32 first

The macOS snapshot renders through AppKit's own cacheDisplayInRect:, so drawRect:, isFlipped and the blit all really run — which makes the whole draw path reviewable over SSH.

The text tiers are off by default, so --all-features is the only build that sees them together and the plain build is the only one that sees neither. CI runs both, because a #[cfg] that compiles in one combination and not the other is exactly the rot that goes unnoticed.

Licence

MIT — see LICENSE.