libLuna Documentation
PL EN
GitHub

Start · 10 minutes

Quick start

From an empty project to a window that reacts to clicks and paints a real widget. All in one file and without the Selene compositor — any Wayland compositor will do.

What you need

ComponentRequirementWhy
SystemLinuxWayland, evdev, wl_shm.
Rust1.80+ (stable)A rustup toolchain is enough.
CompositorWaylandSelene, GNOME, KDE, Sway — anything with xdg-shell.
FontsAdwaita Sans / DejaVuThe text engine looks for fonts installed on the system.

You do not need GTK, Qt, GObject or pkg-config. libLuna links like any other Rust library.

Add the dependency

A typical application only needs the libluna facade — it re-exports everything else. Inside the monorepo, use a path dependency:

toml · Cargo.toml
[dependencies]
libluna = { path = "../libluna/crates/libluna" }
# or from the repository:
# libluna = { git = "https://github.com/mijsys/libLuna.git" }
# eventually, from a registry:
# libluna = "0.0.4"

One use is enough:

rust
use libluna::prelude::*;

Scaffolder

The fastest way to start is the bundled template:

bash
./libluna/tools/libluna-new.sh ~/projects/my-app
cd ~/projects/my-app
cargo run

The first window

This program opens an 800×600 window, picks the locale from the environment, paints the background and draws text. That is the whole contract: the platform calls your closure on every frame, and you fill the scene.

rust · src/main.rs
use libluna::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    set_locale(Locale::from_env());

    let config = AppConfig::new("My app")
        .initial_size(800, 600)
        .app_id("eu.luna.MyApp");

    run(config, |frame: &mut Frame| {
        let theme = frame.theme;
        let (w, h) = (frame.size.width, frame.size.height);

        frame.scene.fill_rect(
            Rect::from_xywh(0.0, 0.0, w, h),
            theme.colors.window_bg,
        );
        frame.scene.text(
            TextRun::new(Point::new(24.0, 64.0), "Hello, libLuna", 24.0)
                .with_weight(700)
                .with_color(theme.colors.text),
        );
    })
}

frame.scene is cleared before every frame. Paint the whole frame from scratch — you do not need to call clear(), but you cannot rely on anything left from the previous frame.

Run and look

bash
# your application
cargo run

# the example from the libLuna repository
cargo run --manifest-path libluna/Cargo.toml -p libluna --example minimal

# the widget gallery without opening a window (PNG)
cargo run --manifest-path apps/luna-demo/Cargo.toml --bin gallery

# a screenshot of any demo screen
cargo run --manifest-path apps/luna-demo/Cargo.toml --bin render -- docs/images/out.png

If no window appears, check WAYLAND_DISPLAY and make sure the session really runs on Wayland. libLuna has no X11 backend — XWayland only serves X11 applications inside Selene.

What the app receives: Frame

Frame is the whole context of one frame. The most important fields:

FieldTypeMeaning
scene&mut SceneScene graph to fill; logical pixels.
themeThemeResolved theme: palette, spacing, radius, typography, metrics.
text&TextEngineText measurement and rasterisation (system or mono).
sizeSizeCurrent surface size in logical pixels.
scalef64Physical/logical scale (e.g. 2.0 on HiDPI).
events&[Event]Events since the previous frame.
dtf64Time since the previous frame, in seconds.

Common methods

MethodPurpose
request_frame()Ask for another frame while an animation is running.
request_frame_after(delay)Schedule a frame for a clock or timeout.
measure_text(text, size, weight)Measure text without drawing it.
set_title(...), close()Window title and closing an xdg-toplevel.
begin_move(), begin_resize(edges)Dragging and resizing from your own CSD.
copy_to_clipboard(...), paste_from_clipboard()System clipboard; a paste arrives as Event::Paste.
set_preference(...), set_accent(...)Change theme and accent; persisted in the config.
waker()Wake the event loop safely from another thread.

The first event

Events live in frame.events. This example toggles a colour on a left click:

rust · click handling
use libluna::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = AppConfig::new("Click").initial_size(640, 400);
    let mut active = false;

    run(config, move |frame: &mut Frame| {
        for event in frame.events {
            if let Event::PointerButton(pe) = event {
                if pe.button == PointerButton::Left
                    && pe.state == ButtonState::Pressed
                {
                    active = !active;
                }
            }
        }

        let theme = frame.theme;
        let (w, h) = (frame.size.width, frame.size.height);
        let color = if active { theme.colors.accent } else { theme.colors.card_bg };

        frame.scene.fill_rect(Rect::from_xywh(0.0, 0.0, w, h), theme.colors.window_bg);
        frame.scene.fill_rounded_rect(
            Rect::from_xywh(24.0, 24.0, w - 48.0, h - 48.0),
            theme.radius.large,
            color,
        );
    })
}

Event kinds

VariantData
PointerButtonPosition, button, state, modifiers.
PointerMotionPosition and modifiers (for hover).
PointerLeaveThe pointer left the surface.
Scrolldx/dy in steps.
KeyKeysym, keycode, text, state, modifiers, repeat.
Text, PasteComposed text and clipboard paste.
WindowResize, ScaleChanged, Focused, CloseRequested, Redraw.

Drawing the scene

Scene is a list of commands, not retained state. Every method returns &mut Self, so calls can be chained.

MethodEffect
fill_rect(rect, color)Filled rectangle.
fill_rounded_rect(rect, radius, color)Rounded rectangle.
stroke_rounded_rect(...)Rounded rectangle outline.
stroke_line(from, to, width, color)Line segment.
fill_circle(center, radius, color)Filled circle.
stroke_circle(...), stroke_polyline(...), fill_polygon(...)Outlines and polygons.
text(TextRun)Text with origin.y as the baseline.
image(rect, data)RGBA image inside a rectangle.
push_clip(rect) / pop_clip()Clip drawing (rounded variants too).
rust · text
frame.scene.text(
    TextRun::new(Point::new(24.0, 48.0), "Title", theme.typography.title.size)
        .with_weight(theme.typography.title.weight)
        .with_color(theme.colors.text),
);

Remember: origin.y is the baseline of the first line, not the top edge of the text.

The first widget

Widgets are immediate: they keep state in an ordinary struct, and you call paint and click_at. There is no object tree and no manual refcounting.

rust · Button
use libluna::prelude::*;
use libluna::widgets::{Button, ButtonVariant, PaintCtx};

fn draw_ui(scene: &mut Scene, theme: &Theme, text: &TextEngine) {
    let rect = Rect::from_xywh(24.0, 24.0, 120.0, 36.0);
    let mut button = Button::new("Save")
        .variant(ButtonVariant::Suggested);

    let mut ctx = PaintCtx::new(scene, theme, text);
    button.paint(&mut ctx, rect);
}

fn handle(event: &Event) -> bool {
    let rect = Rect::from_xywh(24.0, 24.0, 120.0, 36.0);
    let button = Button::new("Save");

    match event {
        Event::PointerButton(pe) => button.click_at(rect, pe.position),
        _ => false,
    }
}

In practice you keep the widget in application state (so it remembers hover/pressed and its value) instead of recreating it on every click. Full examples: Widget library.

Layout without manual maths

Above the widgets there is a retained layout tree: you build Nodes and the engine measures them and assigns rectangles to leaves.

rust · Layout
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::from_xywh(0.0, 0.0, frame.size.width, frame.size.height),
    &layout_ctx,
);

rects contains leaf rectangles in DFS order — map them to your widgets. Details: API → layout.

Translations from day one

The key is the English string. A missing translation falls back to English, and every language is a separate file.

rust · i18n
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");
FileContent
i18n/pl.lang__name__ = Polski and Settings = Ustawienia.
~/.config/luna/i18n/*.langExtra languages loaded at startup; they override built-in ones.

Built-in catalogues are pl, en and de. More: Concepts → i18n.

Next steps