Mental model
A frame, not a tree.
libLuna does not hide drawing from you. The platform gives you a frame; you change state and build a scene. Widgets are ordinary structs that know how to paint themselves and answer hit tests. That is the whole philosophy.
The loop of one frame
Eventrun(config, |frame| …)you mutate state and fill frame.sceneScenea list of commands: shapes, clip, text, imagesSoftwareRenderer → wl_shmthe CPU paints RGBA8888; the compositor shows the bufferThere is no hidden tree to update. Application state is yours. The scene is rebuilt from scratch every frame. If nothing changes, the platform does not call your closure — unless you request a frame.
Frame — the frame context
Every closure call receives &mut Frame. It is both the drawing context and the event listener.
pub fn run<F>(config: AppConfig, render: F) -> Result<(), Box<dyn Error>>
where
F: FnMut(&mut Frame<'_>) + 'static;
The closure is FnMut, so it can own and mutate its own state (e.g. let mut selected = 0; before run). There is no global store and no manual Rc<RefCell<…>>.
Need an animation? Call frame.request_frame() on frames where the animation is running. Use frame.dt as the time step.
A scene is a list of commands
Scene does not store widget state. It stores DrawCommands — enough for the renderer to paint pixels, and little enough to test without a window.
| Group | Commands |
|---|---|
| Shapes | FillRect, FillRoundedRect, StrokeRoundedRect, StrokeLine, FillCircle, StrokeCircle, StrokePolyline, FillPolygon. |
| Clipping | PushClip, PushClipRounded, PopClip. |
| Content | Text(TextRun), Image, Background. |
The scene is backend-independent. Today it is rasterised by SoftwareRenderer (tiny-skia + fontdue). A GPU backend can be added later without changing widgets, because the contract is still a command list.
Immediate widgets
A widget is not a node in a tree and has no parent. It is a struct with presentation state that the application keeps wherever it likes.
impl Button {
pub fn new(label: impl Into<String>) -> Self;
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect);
pub fn hit(&self, rect: Rect, p: Point) -> bool;
pub fn click_at(&self, rect: Rect, p: Point) -> bool;
}
paint does not mutate state. click_at only says whether a click hit; the application decides whether to change state. Exceptions (e.g. TabBar, Entry) take &mut self because they must remember a caret, selection or active tab.
let mut button = Button::new("Save").variant(ButtonVariant::Suggested);
run(config, move |frame: &mut Frame| {
let rect = Rect::from_xywh(24.0, 24.0, 120.0, 36.0);
for event in frame.events {
if let Event::PointerButton(pe) = event {
if pe.button == PointerButton::Left
&& pe.state == ButtonState::Pressed
&& button.click_at(rect, pe.position)
{
save();
}
}
}
let theme = frame.theme;
let mut ctx = PaintCtx::new(frame.scene, &theme, frame.text);
button.paint(&mut ctx, rect);
});
Immediate does not mean “stateless”. Hover, pressed and focus still have to be set on the widget (set_hovered, set_pressed) or kept in your own state. libLuna does not do that automatically for a whole tree.
Layout: measure → allocate
Manual rectangle maths does not scale. That is why widgets have a retained Node tree above them with two passes:
- measure — how much space a subtree wants, given
Constraints(min/max). - allocate — where the leaves go.
SpacerandExpandedabsorb slack along the stack axis.
use libluna::widgets::layout::{Layout, LayoutCtx, Node};
let tree = Node::padding(
Insets::splat(16.0),
Node::vbox(vec![
Node::label("Settings", theme.typography.heading),
Node::spacer(),
Node::hbox(vec![
Node::fixed(120.0, 34.0),
Node::spacer(),
Node::fixed(80.0, 34.0),
])
.gap(8.0),
])
.gap(10.0),
);
let layout_ctx = LayoutCtx { theme: &theme, text: frame.text };
let rects = Layout::new(tree).fit(rect, &layout_ctx);
rects are leaf rectangles in DFS order. The application maps them to concrete widgets. The contract is deliberately simple: layout does not know what a leaf paints, and a widget does not know where it sits in the tree.
Events and hit testing
The platform gives you one list of Events per frame. There is no automatic tree propagation — you decide which widgets receive an event and in what order.
| Stage | What you do |
|---|---|
| Hover | On PointerMotion, check hit(rect, pos) and update the highlight. |
| Click | On PointerButton { Pressed }, call click_at / *_at. |
| Keyboard | On Event::Key, inspect keysym, text, modifiers and focus. |
| Focus | Keep the active widget identifier. libLuna has no global focus manager. |
| Close | Event::Window(WindowEvent::CloseRequested) → e.g. frame.close(). |
This is a deliberate M0–M2 trade-off: less magic, more control. When the layout and tree become retained, the library will take over part of the routing, but the paint/hit contract stays.
The theme is data
Theme is a complete set of tokens: palette, spacing, radii, typography and control metrics. Widgets read geometry from it, so a theme changes sizes as well as colours.
| Field | Content |
|---|---|
colors | window_bg, view_bg, card_bg, text, accent, border, states, etc. |
spacing | xs=3, sm=6, md=12, lg=18, xl=24. |
radius | small=6, medium=9, large=12, pill. |
typography | title, heading, body, caption. |
metrics | Per-widget geometry: button.height, switch.width, headerbar.height… |
appearance | Light or Dark. |
Styles can also come from *.theme files. Details and examples: Themes & tokens.
i18n without gettext
The key is the English string, and a translation is a simple key = value file. A missing entry falls back to English.
__name__ = Polski
Appearance = Wygląd
Settings = Ustawienia
Hello, {name} = Witaj, {name}
use libluna::t;
use libluna::prelude::*;
set_locale(Locale::from_env());
let title = tr("Settings");
let hello = tr_args("Hello, {name}", &[("name", "Ania")]);
let shortcut = t!("Settings");
Built-in catalogues: pl, en, de. Additional files loaded from ~/.config/luna/i18n, ~/.local/share/luna/i18n and /usr/share/luna/i18n override the built-in ones.
Deliberately simple
No automatic focus
The application decides which widget has focus and how it moves. That leaves room for your own policy.
No accessibility tree
AT-SPI is planned later. There is no screen-reader bridge yet.
Basic text shaping
fontdue covers basic scripts. cosmic-text is planned without changing the measure/draw contract.
CPU rendering
The buffer goes through wl_shm. GPU/dmabuf is planned but will not change the scene API.