denise-fbdev 0.0.1

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.

Denise is a from-scratch Rust successor to CoreCanvas, a .NET library that proved the architecture: scene stack, z-index layering, dirty-rectangle tracking, composite cursor sprite, 60 FPS at under 5% CPU on a Pi 4. Denise keeps the design and drops the runtime.

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 — since M5 — a way to embed all of it in somebody else's application. Underneath, DRM/KMS scanout with async page flips, an fbdev fallback, evdev input, damage tracking and theming — verified on a Raspberry Pi 3 A+ driving a 1920×1080 output with no X.

Every pixel above was rendered by cargo run -p denise-ui --example showcase, which writes a PPM and needs no display at all.

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.

On a Linux machine with a spare VT or a virtual GPU:

cargo run -p panel            # the widget tree, a modal dialog, no X
cargo run -p kiosk            # M2's instrumented loop: latency percentiles
cargo run -p denise-drm --example probe   # read-only: what it would drive

The number that matters is what it costs when nothing happens. 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 it 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.

Without a display, the desktop preview backend runs the same scene code unchanged:

cargo run -p hello-rect

The name

Denise — the MOS 8362, one of the three custom chips in the Amiga's original chip set alongside Agnus and Paula — was the display chip. Its job, every single frame:

  1. pull bitplanes out of chip RAM and turn them into pixels,
  2. combine two playfields according to priority,
  3. overlay up to eight hardware sprites,
  4. resolve it all through the colour registers,
  5. hand the result to the video output.

That is this library's job description, done in software: composite z-ordered layers, overlay a cursor sprite, resolve to pixels, hand the buffer to the display. The cursor sprite is not a metaphor — it is step 4 of the rendering pipeline below, and it is why there is one in the logo.

There is a second reason the name fits, and it is the one that actually decided it. Denise did all of the above with no operating system in the way. No compositor, no window server, no driver stack, no GPU. Chip RAM to display, on a fixed cycle budget, on hardware slower than the microcontroller in a modern keyboard. That is precisely the position this toolkit wants to occupy on a Raspberry Pi, and precisely the thing X11 and Wayland exist to stop you doing.

Super Denise (8373) extended it in ECS; AGA replaced it with Lisa.

What the backends are not called

Agnus, Paula and Lisa are not backend crate names, and will not become them. They are excellent names for a blog post and useless in a repository: denise-drm tells you what it does, denise-paula requires you to already know. The Amiga reference stops at the project name.

What it is not

  • A general desktop application framework. If you want one, use egui, Iced or Slint; they are good and this is not competing with them.
  • HTML, CSS or a WebView.
  • A visual designer or a markup language. Perhaps much later.
  • Fully accessible, or capable of complex-script text shaping, in 0.x. The architecture leaves room for both. Neither is delivered.

Architecture

A Cargo workspace: a platform-agnostic core, and thin backends behind two traits.

Crate Purpose Status
denise Geometry, colour, pixel buffer contract, input, damage tracking, theming ✅ M0, M1.1
denise-render Software rasteriser, coverage blitting, the built-in bitmap font ✅ M1, M3, M4
denise-text Glyph sources, a bounded glyph atlas, line layout ✅ M4
denise-ui Scene graph, scene stack, widgets, cursor sprite ✅ M3
denise-winit Desktop development and preview backend ✅ M0
denise-drm Linux DRM/KMS backend — the primary target ✅ M2
denise-fbdev Linux fbdev fallback ✅ M2
denise-evdev Linux input, keyboard layouts, dead keys, console muting ✅ M2, M4, M5
denise-ffi Stable C ABI, cdylib, hand-written header ✅ M5
denise-macos Embeddable NSView over a CoreGraphics bitmap context ✅ M5
denise-win32 Windows child-HWND control over a DIB section ⚠️ M5, unrun
denise-activex COM/ActiveX shim for legacy Windows hosts ◐ M5, registration only

Everything through M4 has run on hardware. denise-win32 compiles and its tests pass on a Windows runner; nothing has hosted it in a real application yet. denise-activex has its registration table and not its COM object — see Milestones for why that is where it stopped.

denise-ui is a crate of its own rather than part of the core because widgets need both the platform contract and the rasteriser, and the rasteriser already depends on the contract — putting them together would be a dependency cycle. It also means a signage application that draws its own scene links no arena, no tree and no widget code at all.

The two traits

Everything a backend has to provide:

pub trait Surface {
    fn size(&self) -> Size;
    fn scale_factor(&self) -> f32;
    fn format(&self) -> PixelFormat;
    fn acquire(&mut self) -> Result<Frame<'_>, SurfaceError>;
    fn present(&mut self, damage: &[Rect]) -> Result<(), SurfaceError>;
}

pub trait InputSource {
    fn poll(&mut self, out: &mut Vec<InputEvent>);
}

Frame carries the pixel slice, its stride, its format, and its age. Those last two are the whole reason acquire exists rather than a bare buffer_mut() -> &mut [u32]:

  • Stride is not width. DRM framebuffers are pitch-aligned — 64 bytes on vc4, more on other ARM drivers — and fbdev has its own line_length. Code that assumes rows are contiguous works perfectly on a desktop and shears diagonally on the panel you actually shipped.
  • Buffers are stale. With double buffering the buffer you are handed holds the frame before last. Repainting only this frame's damage leaves the older content visible in alternating frames. BufferAge is modelled on EGL_EXT_buffer_age, and DamageTracker::resolve widens this frame's damage to cover everything that buffer missed.

Both failure modes are covered by denise-render/tests/damage_pipeline.rs, which runs an anti-aliased scene through 1-, 2-, 3- and 6-buffered swapchains with a padded stride and asserts every presented frame is pixel-identical to a full repaint.

Rendering pipeline

Ported from CoreCanvas, and what Ui::paint does, in this order, inside each damage rectangle:

  1. Clear the back buffer — clean UI, no cursor.
  2. Render the base scene.
  3. Render modal scenes over a dimmed backdrop.
  4. Composite the cursor sprite onto the clean buffer.
  5. Present damaged rectangles only.

Every step happens inside the damage clip, which is what makes step 3 affordable: a full-screen alpha fill costs 63% of a 60 Hz frame on a Pi 3, so a modal that repaints its own blinking caret must not drag one along with it.

On DRM, step 4 should use the hardware cursor plane rather than compositing into the buffer; vc4 has one, and it makes pointer movement cost no redraw at all. The software composite stays as the fallback.

What damage actually buys

Worth being precise about, because it differs per backend:

Backend Effect of present(damage)
Win32 BitBlt, X11, Wayland Real. Only the listed regions are uploaded.
DRM/KMS page flip Little to none. A flip swaps whole buffers; FB_DAMAGE_CLIPS is atomic-only and widely ignored.

On DRM the win is entirely upstream: not rasterising the untouched pixels in the first place. That is where the CPU goes, so that is where the tracking pays.

The component model

Widgets live in a generational arena; the tree stores ids, not references. Event handling returns messages, not callbacks. No Rc<RefCell<_>> anywhere in the path.

let mut ui: Ui<Msg> = Ui::new(surface.size(), theme::DARK);
let card = ui.add(ui.root(), Panel::default(), Rect::new(40, 40, 400, 260))?;
ui.add(card, Button::new("Lagre", Msg::Save), Rect::new(20, 180, 160, 46))?;

ui.handle(&events);
ui.render(&mut surface)?;              // draws nothing when nothing changed
for message in ui.drain_messages() { /* the application decides */ }

A stale NodeId resolves to None rather than to whoever was allocated next, which is the entire reason for the generation in the key.

Damage is the toolkit's job

This is the part that changed as a result of M2. A bug on the Pi produced a stale colour on a card because the application's idea of "what changed" left out the one field that decided the pixels. The fix is not care; it is that the application no longer has that job:

  • Ui::widget_mut invalidates on access, before you have changed anything. Taking &mut to a widget is the declaration that it will look different.
  • Hover, press, focus and enabled are tracked by the tree, so a widget cannot forget to invalidate a state it does not own.
  • Moving, resizing, showing, hiding, adding or removing a node damages both the rectangles it vacated and the ones it now occupies.

denise-ui/tests/scene.rs asserts the property that subsumes all of it: after any poke at the tree, an incremental repaint through a double-buffered swapchain with a padded stride is pixel-identical to a full repaint. A missed invalidation fails there rather than three weeks later on somebody's panel.

Scenes, not dialog widgets

A modal is a scene pushed on the stack, not a widget inside the page. Input goes to the topmost scene only, so nothing underneath is hittable, focusable or reachable by Tab — a property of the stack rather than something each dialog has to enforce. The backdrop is painted per damage region, never over the whole surface.

Hit testing and paint order

Siblings are kept sorted by z as they are added, so flattening the tree is a plain depth-first walk, cached and rebuilt only on a structural or z-order change. Non-interactive widgets are invisible to hit testing, which is why a Label inside a Button does not swallow the click.

Numbers, at five hundred nodes

cargo bench -p denise-benches --bench ui, on an Apple M-series at 1920×1080 with a 500-node tree — twenty panels of controls, which is a busy HMI:

Benchmark Time
Hit test, topmost node 4.0 ns
Hit test, deepest node 297 ns
Hit test, miss (walks everything) 265 ns
Rebuild the paint order after a z change 754 ns
Full repaint 497 µs
One button hovered, then unhovered 5.3 µs per frame
One button pressed, then released 4.1 µs per frame

A frame that changed one button costs about a hundredth of a percent of a full repaint of the same tree. That ratio is the whole design, measured against the size the architecture was specified for.

The rasteriser

denise-render draws rectangles, rounded rectangles, lines and source-over alpha straight into a Frame. It needs neither std nor alloc, contains no unsafe, and uses no floating point at all — anti-aliasing coverage included.

That last one is a deliberate trade. Integer coverage means no libm on no_std targets, no FPU traffic where that costs, and output that is bit-identical between x86 and ARM — which is what makes a pixel-exact reference test meaningful on a developer's laptop and on the Pi. Rounded corners are evaluated analytically per scanline with an integer square root, at four sub-rows per scanline.

The clip is the only damage-awareness the drawing code has. Widget code paints as though it owned the whole window; restricting the clip to a damage region turns that into an incremental repaint, so there is never a second draw path to keep in step with the first.

Numbers

Apple M-series, --release, so read the ratios, not the absolute times — a Pi 4 is an order of magnitude slower on memory-bound work.

Benchmark Time
1080p scene, full repaint 211 µs
1080p scene, typical damage (0.4% of the surface) 5.7 µs
1080p blit, whole buffer 101 µs
1080p blit, same damage 0.88 µs
800×480 scene, full repaint 54 µs
Rounded rect fill 1600×900, r=8 / r=32 77 µs / 78 µs

A damaged frame costs 37× less than a full one, and the rounded-rect cost is flat in the radius — anti-aliasing is paid per perimeter pixel, not per area. Both are the properties the design was aiming at.

Two results worth keeping in view:

  • A padded stride costs 3× on a full clear (59 µs → 185 µs at 1080p). DRM hands out pitch-aligned buffers, so the padded number is the one that will matter on hardware. Whether that gap is stride handling or simply the larger buffer falling out of cache is not yet established.
  • fill_rect currently measures slower than fill_rounded_rect on the same rectangle, which cannot be right — the rounded path does strictly more work. Unexplained, and flagged rather than papered over.

The built-in font

Five by seven in an eight-row cell, monospace, integer-scaled. Glyphs are ASCII art in the source, packed into bits by a const fn at compile time, because a table of hex bytes cannot be reviewed and a picture of a Ø can. It is the first of the three text tiers, and the only one that needs no feature flag, no file and no allocator.

cargo run -p denise-render --example fontdump -- "Kjøre på Æ"

Anti-aliased glyphs from the other tiers arrive as 8-bit coverage masks. The blitter walks each row in runs: solid runs — the inside of a glyph — go through the span blend, empty runs are skipped, and the per-pixel path is paid only on the rim. That matters because the M1 benches put the per-pixel path at 31 Mpx/s against 457 for spans on a Pi 3, and predicted glyphs would be where the gap got paid.

cargo bench --workspace

CI compiles the benches but does not gate on their timings: wall-clock variance on a shared runner is far wider than any threshold worth setting. The regression gate belongs on a self-hosted Pi, or on instruction counts.

Text

Three tiers, chosen by what a panel actually has to draw. The cost column is the increase in a stripped, statically linked aarch64-unknown-linux-musl binary, measured rather than estimated.

Tier Feature Cost Buys
Built-in bitmap none 0 Latin plus ÆØÅ æøå ÄÖÜ äöü Éé ß °, whole-number scales
TrueType truetype +145 KB Real faces, anti-aliased, proportionally spaced, any size
Shaped shaping +3.1 MB Ligatures, bidirectional text, font fallback, complex scripts

For scale, the whole of Denise, DRM, evdev and the widgets is 848 KB, so the shaping tier is four times the rest of the toolkit put together.

The numbers that decide between them are stark in both directions. On a Norwegian pangram, truetype and shaping produce lines two pixels different in total width — three megabytes for two pixels. On Arabic they are not comparable at all:

  • the built-in font draws boxes — obviously missing, obviously a defect;
  • truetype draws the right glyphs, unjoined and in logical order, which is fluent nonsense: it looks like text, it is wrong, and nobody who cannot read the script will notice;
  • only shaping joins them and runs them right to left.

That middle result is the one to be careful about. examples/specimen takes a sample string as its third argument precisely so it can be checked before a device ships:

cargo run -p denise-text --features truetype,shaping \
  --example specimen -- specimen.ppm MyFont.ttf "sample text"

No font ships with Denise, and none will. Type designers' licences differ, and embedding somebody's typeface in a toolkit is a decision for whoever ships the device. There is also no font discovery: nothing is read from a system font directory, because a device that boots from flash with a read-only root very often has none, and a UI whose text depends on that is a UI that fails in the field. cosmic-text's own new_with_fonts turned out to load the host's fonts anyway — 812 of them on the machine this was written on — which is why the database is built by hand.

The glyph cache

One buffer of coverage bytes, shelf-packed, with a size fixed at construction: 64 KB by default. A panel with a twenty-year service life wants "the glyph cache is exactly 64 KB", not "however many glyphs the user has typed since Tuesday".

When it fills it resets wholesale rather than freeing rectangles, and counts the reset — so a cache that is genuinely too small shows up as a rising number rather than as a mystery. Measurement goes through it as well as drawing, which is what stops a label being re-outlined on every layout pass.

Benchmark Time
Cache hit 2.8 ns
Cache miss, 16 px 100 ns
Cache miss, 24 px 213 ns
Measure a 16-character label 252 ns
Draw a 16-character label, 16 px 1.39 µs
Draw the same label, 48 px 6.91 µs

Keyboards

KeyCode names a position; what it types is a property of the user's layout. denise-evdev ships US and Norwegian as static tables, with dead-key composition, AltGr as a third level, and a Caps Lock that reaches æøå without turning 1 into !.

The layout is read from the system: DENISE_KEYMAP, then XKB_DEFAULT_LAYOUT, then the console keyboard configuration files distributions actually write. On the Pi this was developed against, /etc/conf.d/loadkmap says Norwegian and the panel picks it up with nothing set by hand.

$ /tmp/panel
keymap  no (from /etc/conf.d/loadkmap)

The composition table is generated from Unicode's own canonical composition data rather than typed out — a hand-written table of a hundred accented letters is a list of a hundred chances to be subtly wrong about one of them.

Why the tables are ours, when the choice is the system's

Reading which layout a system wants is easy. Reading the layout itself is the part that would remove these tables, and both ways of doing it cost more than they save:

What it gives What it costs
KDGKBENT on a VT The kernel's real keymap, dead-key table included /dev/tty0 is root:root mode 600 everywhere checked
libxkbcommon Every layout in xkeyboard-config A C library and a runtime data directory

Denise otherwise runs unprivileged, needing only the video and input groups, and a static binary needs no data directory. Giving up either to read a keymap is a poor trade. So the choice comes from the system and the data comes from here.

The cost is real and stated: a system configured for a layout Denise has no table for falls back to US, visibly, through the reported source — rather than by typing the wrong thing. Adding a table is about thirty lines, because a layout lists only what differs from the Latin alphabet. Needing root is forever.

Control characters are never text. Enter, Tab and Backspace produce Key events and nothing else, so a field can insert everything it receives without filtering and a key binding cannot be shadowed by a stray control character.

Muting the console

Reading evdev does not stop anyone else reading it. On a console-booted kiosk the login shell behind the UI receives every keystroke as well, so typing into a Denise text field also types at the shell — and a form field that happens to contain reboot followed by Enter does what it says. Holding DRM master stops the console drawing; it does nothing about the keyboard.

Console::mute_keyboard sets KDSKBMODE to K_OFF. evdev sits below the console layer, so Denise still sees everything and the shell sees nothing. It is paired with KDSETMODE/KD_GRAPHICS, which stops console blanking on an idle panel and stops the kernel repainting text after an oops.

Two things make this safe to ship rather than a footgun:

  • The guard restores on drop, including while a panic unwinds, and it puts back the mode it read rather than a guess at the default.
  • A pty is refused. /dev/tty over SSH is not a console, and KDGKBTYPE is the ioctl that says so. Without that check, open would hand back the first thing that opened and the developer's own terminal would be the one muted.

K_OFF also swallows Ctrl+Alt+F2, so a muted console cannot be escaped from at the keyboard, and nothing restores it after SIGKILL. The escape hatch, over SSH, is kbd_mode -u -C /dev/tty1.

Embedding

M5 is the other direction. Everything before it is Denise owning the display; this is Denise owning one rectangle inside an application that already exists — an MFC dialog, a Cocoa window, a C or C# or Python host.

The shape is the same in all of them, and it is the one thing worth getting right: the host owns the window, the event loop and the pixel buffer; Denise owns the widget tree and draws into whatever it is handed. There is no run function in any of these backends, and no Surface in the C ABI. A library that owned either would be unembeddable in exactly the places this exists for.

Backing store Present Verified
denise-ffi the caller's, described by DeniseFrame the caller's problem C and C++ example built and run in CI
denise-macos CGBitmapContext, CoreGraphics owns the pixels setNeedsDisplayInRect: then CGContextDrawImage rendered through AppKit's own cacheDisplayInRect:
denise-win32 32-bit top-down DIB section InvalidateRect then BitBlt compiles, unit tests pass on a Windows runner

Three things fell out of doing it three times:

  • Damage means different things. On DRM a page flip swaps whole buffers, so damage saves rasterisation and no bandwidth. On Win32 and on AppKit it saves both — BitBlt moves only what it is given. The same rectangles, worth measurably more.
  • Row zero is not agreed on. A CGImage is bottom-up; a DIB section is bottom-up unless you ask for a negative height; Denise's row zero is the top. Neither platform reports a mistake here. It renders upside down and looks like somebody laid the widgets out wrong.
  • There is already a cursor. Both hosts draw one, so the composited sprite has to stay off — and it did not, because the tree revealed it on every pointer move. Ui::show_cursor is now a decision that sticks.

The Windows CI job caught the first thing it was pointed at: two virtual key codes naming the same position, because the unsided VK_CONTROL deliberately aliases VK_LCONTROL and the test excluded only the shift case. The mapping was right; the test's exclusion was too narrow. The fix worth having was not the one-line one — denise-win32's keymap is now platform-independent and its tests run everywhere, the same split denise-drm and denise-evdev already made. A table of a hundred numbers is exactly the thing that breaks, and a CI runner is a slow place to find out.

The header is the contract

denise-ffi's header is written by hand and the Rust is checked against it, not generated from it. A generated header follows whatever the implementation says this week, which is the opposite of what a stable ABI means.

tests/header.rs does the checking, and it earns its keep on the parts a linker cannot. A missing declaration is a link error the first time anybody tries. A key number that differs between the two sides is not: the host presses Enter, the field receives Home, and nothing anywhere says so.

The numbering is not arbitrary either. A key position is named after the US layout, so positions carrying an ASCII character there are numbered with it — DENISE_KEY_A is 0x41, DENISE_KEY_SEMICOLON is 0x3B. Half the table needs no lookup and a key log is readable in hex.

Theming

The role vocabulary is borrowed from daisyUI, which got the important part right: a widget never names a colour, it names a role, and every surface role has a content partner. Swapping a theme cannot produce unreadable text, because readability is a property of the pair rather than of the widget.

let (background, foreground) = theme.pair(Role::Primary);
let corner = theme.radius(Radius::Box);

Twenty roles — base-100/200/300 plus base-content, then primary, secondary, accent, neutral, info, success, warning and error, each with a content partner. Three radius tokens by widget class (Selector, Field, Box) rather than one constant per widget, which is what stops the set drifting.

A theme is built from nine seed colours; the two recessed base surfaces and all nine content colours are derived by walking towards black or white until the mix clears WCAG 4.5:1, so a derived theme keeps its hue instead of collapsing to black on white. Theme::from_seeds is a const fn, so the built-in themes cost nothing at runtime and cannot drift out of step with the derivation rules.

Theme::validate checks every pair, and it earns its place: it caught that pure magenta and #FF5555 both top out near 6.7:1 against black and cannot reach AAA, which is why the high-contrast palette uses lightened variants.

Three themes ship — LIGHT, DARK, and HIGH_CONTRAST for panels read in glare or through a visor. On a device booting from flash, an unused theme is bytes somebody paid for.

What was not borrowed

OKLCH storage Cube roots mean floats, which mean libm on no_std and output that is no longer bit-identical across architectures. Colours are sRGB, derived with integers.
--noise A per-pixel texture makes every pixel differ from its neighbour, so no damaged region can be repainted without a seam against the region beside it. It turns every frame into a full repaint.
35 built-in themes Three.
--depth as a shadow Kept as a number. A real blur is expensive in software and spills outside the widget's bounds, so every damage rectangle would have to be inflated by the blur radius.

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 runs its 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 — denise, denise-render and denise-ui all held to it. An embedded build that quietly starts compiling winit is a regression.
  • MSRV 1.95, bumped deliberately rather than tracking stable.

Milestones

M0 Workspace, Surface/InputSource, winit backend, damage tracking, CI
M1 Software rasteriser: rects, rounded rects, lines, clipping, alpha blend. Benches.
M1.1 Theming: semantic colour roles, guaranteed-contrast content pairing, geometry tokens.
M2 DRM/KMS with legacy modesetting and page flip; fbdev fallback; evdev input. Runs with no X.
M3 Scene stack, z-index, modal dialogs, cursor sprite. Label, Button, TextInput. CoreCanvas 0.4 parity.
M4 Text: three font tiers behind feature flags, a bounded glyph atlas, keyboard layouts with dead keys.
M5 C ABI, macOS NSView, Windows child-HWND control. ActiveX shim: registration only.

M2 does not start until M1 is benchmarked. M5 does not start until the Pi story is solid — that is the entire point of the project.

M2 shipped legacy modesetting rather than atomic, which reverses the original plan. What atomic buys is FB_DAMAGE_CLIPS, plane composition and tear-free guarantees; a page flip swaps whole buffers, so damage saves rasterisation rather than bandwidth, and the one plane worth having — the hardware cursor — has a legacy equivalent. Atomic slots in behind the same seam when planes earn it.

M3 pulled the built-in bitmap font forward from M4, because a milestone shipping Label, Button and TextInput without glyphs would have been a milestone in name only. M4 then found the other half of that gap: denise-evdev reported key positions and never turned them into text, so M3's text fields could not receive a single character from real hardware. Tab and Enter worked, which is why it looked fine on the Pi.

M4 also added a tier the bootstrap did not name. It listed cosmic-text and fontdue; measuring them showed 3.1 MB against 145 KB, and a middle tier with real fonts but no shaper is what most panels actually want.

M5 was gated on the Pi story being solid, which it was not quite: the console keyboard was still unmuted, so every character typed into a Denise text field was also typed at the login shell behind it. That is fixed first — Console in denise-evdev, restoring on drop — and then the milestone starts.

M5's ActiveX shim stops at registration. The registry table is there and tested; the COM object is not. It would sit entirely on top of denise-win32, which has itself never run on Windows, and nothing available here can check it beyond "it compiles" — a long way from "a VB6 form can host it". A thousand lines of unverifiable COM stacked on an unverified control produces something that looks finished and is not. The right order is: run the control on Windows, then write the shim against a host that can load it.

Still outstanding, and deliberately not hidden:

  • The ActiveX COM object is not written. See above.
  • denise-win32 has never run. It compiles for x86_64-pc-windows-msvc and its unit tests pass on a Windows runner; its keymap is platform-independent and tested everywhere. The parts most likely to be wrong are the ones no compiler checks: message ordering, focus behaviour inside a dialog, and DPI changes. docs/windows.md is the checklist for finding out — its example reports every key, character and damage count on screen, the way denise-evdev's keys does on Linux.
  • Touch is unverified on hardware. The multitouch slot path is unit tested and a single touch is routed to widgets as a pointer would be, but no physical touchscreen has driven it.
  • No text selection, clipboard or word motion in TextInput. The measurement it needs now exists; the editing model does not.
  • The Norwegian layout is a reconstruction. æøå and the ¨^~ dead key are certain; the AltGr assignments on the +? and ´ positions are less so, and want checking against a physical keyboard.
  • Only two layouts. US and Norwegian. Adding one is about thirty lines, because a layout table lists only what differs from the Latin alphabet.
  • 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.

Development

cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo run -p hello-rect
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

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.