libLuna Documentation
PL EN
GitHub

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

There 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.

rust · signature
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.

GroupCommands
ShapesFillRect, FillRoundedRect, StrokeRoundedRect, StrokeLine, FillCircle, StrokeCircle, StrokePolyline, FillPolygon.
ClippingPushClip, PushClipRounded, PopClip.
ContentText(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.

rust · typical contract
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.

rust · state on the application side
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:

  1. measure — how much space a subtree wants, given Constraints (min/max).
  2. allocate — where the leaves go. Spacer and Expanded absorb slack along the stack axis.
rust · layout tree
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.

StageWhat you do
HoverOn PointerMotion, check hit(rect, pos) and update the highlight.
ClickOn PointerButton { Pressed }, call click_at / *_at.
KeyboardOn Event::Key, inspect keysym, text, modifiers and focus.
FocusKeep the active widget identifier. libLuna has no global focus manager.
CloseEvent::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.

FieldContent
colorswindow_bg, view_bg, card_bg, text, accent, border, states, etc.
spacingxs=3, sm=6, md=12, lg=18, xl=24.
radiussmall=6, medium=9, large=12, pill.
typographytitle, heading, body, caption.
metricsPer-widget geometry: button.height, switch.width, headerbar.height…
appearanceLight 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.

lang · i18n/pl.lang
__name__ = Polski
Appearance = Wygląd
Settings = Ustawienia
Hello, {name} = Witaj, {name}
rust
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.