denise-ui
The scene graph, widgets and compositor for Denise, a direct-rendering UI toolkit in Rust for embedded Linux and systems without a desktop environment.
A retained tree of widgets in a generational arena, stacked into scenes, drawn
through denise-render into a
denise::Surface. This is the layer that turns "a rasteriser and a display" into
a user interface.
use ;
use ;
use Ui;
#
No callbacks
A widget holds a value of your message type and emits it when something happens,
so every state change lands in one match you wrote — no closures holding
Rc<RefCell<_>>, no widget referencing another widget, no M: Clone bound.
The widgets
Twenty-five of them, deliberately few:
Panel |
A surface with an optional border |
Label |
Static text, aligned in its box |
Button |
Emits a message of your type |
TextInput |
Editing, a caret, and the only widget that animates by default |
Checkbox · Toggle |
A boolean, as a box or as a switch |
RadioGroup |
One choice from a few. One node, so one tab stop |
Progress · Slider |
A value in a range, as output and as input |
Divider · Badge · Alert |
A rule, a pill, a banner |
Tabs · List |
One selected from many, horizontally or vertically |
RadialProgress |
A ring, with room for a number in the middle |
Spinner |
An arc that turns. The one widget that can keep a device awake |
Select |
The closed half of a dropdown; open_select is the open half |
Image |
A picture with a fit mode. Bring your own pixels — denise-image decodes them |
Rating |
Stars. Continuous to read, whole stars to set |
Avatar |
A picture, or initials on a colour derived from them |
Table |
Cells under a pinned header. Windows its data, so row count is free |
Timeline |
Events in order: time, disc, connector, label |
Carousel |
Pictures sliding on the advance clock. One wake per hold |
Collapse |
A section that folds to its header; Accordion adds exclusivity |
Video |
The rectangle a video plane sits in — the frames never come through the tree |
The bar a widget has to clear is being something several panels would otherwise
each get subtly wrong — focus handling, keyboard semantics, hit areas, disabled
states — not saving a caller three fill_rect calls. More are being added one at
a time against issue #6.
Every one names theme roles rather than colours, and every surface/foreground pair is contrast-checked by a test in all three built-in themes.
Damage is the toolkit's job
There are no dirty flags to set. Ui::widget_mut invalidates on access; hover,
press, focus and enabled are tracked by the tree; moving, resizing, showing or
removing a node damages both the old rectangle and the new. Ui::render returns
false and draws nothing when nothing changed, which is the state a kiosk should
be in almost all the time.
One knob for how fast animation runs
ui.set_motion; // 30 fps: half the wakes, half the cost
ui.set_motion; // reduced motion, or a tight power budget
Every moving thing in the tree runs at this rate — spinners, knobs crossing,
carousel slides, layout tweens, toast fades — because a widget says that it is
moving (Wake::Animating) and the tree says when. It used to be four private
constants in four widgets, which is one decision copied four times and reachable
from nowhere.
It is a sample rate and not a duration. A toggle still crosses in 120 ms and
a carousel still advances after eight seconds at any setting: those are
deadlines, spelled Wake::At, and turning the rate down draws a transition in
fewer positions rather than making it take longer. Quantising a schedule to a
frame rate would be a bug, so the two are different words.
Motion::None is not merely a very slow rate. Transitions land at their end
state at once, the animating set empties, and the tree asks for no wake at all —
the prefers-reduced-motion answer, and the right setting where any animation
is a bad trade. Schedules survive it: a tooltip still appears after its dwell, a
toast still goes after its hold, a carousel still rotates. It cuts between
pictures instead of sliding between them.
The default is 16 ms, because sixty is what stops a turning arc reading as a stutter. What that costs is the gallery on a Pi 3A+ over DRM, one spinner turning, with nothing changed between runs but the flag:
| CPU | |
|---|---|
| 16 ms, the default | 4.20% |
| 33 ms | 2.06% |
| 50 ms | 1.26% |
| off | 0.00% |
Which of those is right depends on the deployment, which is why it is a setting
on the tree and not a constant in a widget — and not on Theme, since swapping
dark for light should not change the power budget. A single widget that
genuinely differs can still override it, Spinner::with_frame_ms being the one
that does.
What is not here
No layout engine. Nodes take 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. Widgets with a natural size offer
preferred_width/preferred_height as queries the application makes — the tree
never calls them, and that is the line.
Scrolling
Mark a node ui.set_scrollable(view, true) and it is a viewport: content is
clipped to it, the wheel scrolls it (innermost under the pointer, after the
hovered widget declines), PageUp/Down page the scrollable holding focus, a
touch on its background drags it, and focusing — or moving a List selection —
below the fold scrolls the target into view. No smooth scrolling, deliberately:
a kiosk animating a fling at 60 Hz is the idle-cost story in reverse.
Layers
A modal is push_scene(dim): another root over a dimmed backdrop, with input
and Tab structurally confined to it. A popup is push_popup(anchor, size, side):
anchored to a node, flipped to the other side when the surface runs out, closed
by Escape or a press outside it — which is swallowed, never delivered to what
is underneath — with focus returning to the anchor.
A tooltip is neither, and is not a node either: ui.set_tooltip(id, "text")
stores a string, and the tree runs the dwell timer, places the bubble, dismisses
it on any press or key and draws it above every widget. It needs hover, so it
does nothing on a touch-only panel.
ui.toast(text, role) is the same arrangement for notifications: the tree
stacks them from the bottom edge, fades them in and out, dismisses one that is
pressed — swallowing the press, so it does not also reach what was underneath —
and removes them without anybody asking. During the hold it costs a single wake,
not a frame rate. Alert remains the inline banner for the message that has a
place in the layout.
Features
| Feature | Default | What it does |
|---|---|---|
std |
✅ | Off gives no_std + alloc |
truetype |
Real TrueType fonts, via denise-text |
|
shaping |
Ligatures, bidi and font fallback, via denise-text |
#![forbid(unsafe_code)].
Status
M5 complete, M6 in progress. cargo run -p denise-ui --example showcase -- dark showcase.ppm renders every widget in every state to a file, which is how a
theme or font change gets reviewed without a display.
MIT licensed. Part of Denise — see the repository README for the whole picture.