zgx

Getting started

From nothing to a window, a list, selectable text, a settings window and a shipped app, in Rust.

From nothing to a window, then a list, selectable text, a settings window and a shipped app: first in Rust, then in Luau with zgx-run. Every snippet here is compiled (Rust) or run headless (Luau) by the repository’s tests, so they work as written.

zgx runs on macOS, Windows and Linux. On Linux, install the fontconfig, xkbcommon and D-Bus development packages and pkg-config; on Windows, use the MSVC toolchain (notes).

Add the crates, from crates.io:

[dependencies]
zgx = "0.1"
zgx-desktop = "0.1"
# The component library (a data grid, a tree, tabs, toasts…), if you use it.
zgx-components = "0.1"

Or, for what is not released yet (the changelog says what), from its repository:

[dependencies]
zgx = { git = "https://github.com/zeronsh/zgx" }
zgx-desktop = { git = "https://github.com/zeronsh/zgx" }
zgx-components = { git = "https://github.com/zeronsh/zgx" }

That builds as it is: zgx’s own forks (of cosmic-text, taffy and mlua) come with it as crates of their own. A few more of its dependencies are patched in zgx’s repository alone (winit, wgpu-hal, the AccessKit adapters), and an app gets their releases instead, which zgx builds with, short of those fixes: Wayland popups open in their window, Metal frames take more command buffers, VoiceOver misses headings and grid rows, a field’s error message is said only in its description (releasing lists them). To have them too, add the patches your app’s build then takes from zgx’s repository (a dependency’s [patch] never reaches an app):

[patch.crates-io]
wgpu-hal = { git = "https://github.com/zeronsh/zgx" }
winit = { git = "https://github.com/zeronsh/zgx" }
accesskit_macos = { git = "https://github.com/zeronsh/zgx" }
accesskit_windows = { git = "https://github.com/zeronsh/zgx" }
accesskit_unix = { git = "https://github.com/zeronsh/zgx" }
accesskit_atspi_common = { git = "https://github.com/zeronsh/zgx" }
accesskit_consumer = { git = "https://github.com/zeronsh/zgx" }

The first build compiles the stack once (about a minute on an Apple M-series Mac, release); later ones take seconds.

A window

Application opens windows; render mounts a view in one. Views are built once: column, row and div lay out their children as CSS’s flexbox does (children stretch across and shrink to fit), and styles are methods.

use zgx::compose::prelude::*;
use zgx_desktop::{Application, WindowOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::new()
        .window(WindowOptions {
            title: "Hello".into(),
            width: 480.,
            height: 320.,
            ..Default::default()
        })
        .run(|cx| {
            cx.render(column().p(24.).gap(12.).child(text("Hello, zgx")));
        })
}

State

A signal holds a value; what reads it updates when it changes, and nothing else does. text_signal follows the signals it reads; a button’s on_click writes one.

use zgx::compose::prelude::*;
use zgx_desktop::{Application, WindowOptions};

fn counter() -> View {
    component(|cx| {
        let count = cx.state(0);
        let shown = count.clone();
        row()
            .p(24.)
            .gap(12.)
            .items_center()
            .child(button().child(text("Add")).on_click(move || {
                count.set(count.get() + 1);
            }))
            .child(text_signal(move || format!("Clicked {} times", shown.get())))
    })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::new()
        .window(WindowOptions::default())
        .run(|cx| {
            cx.render(counter());
        })
}

A list

virtual_list mounts only the rows in view, so ten thousand cost what twenty do. keyboard_navigation(true) makes it one Tab stop that the arrows, Page Up and Down, Home and End move through.

use zgx::compose::prelude::*;
use zgx_desktop::{Application, WindowOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::new()
        .window(WindowOptions::default())
        .run(|cx| {
            let offset = cx.ui.signal(0.);
            cx.render(
                virtual_list(
                    offset,
                    32.,
                    4,
                    || 10_000,
                    |index| index,
                    |_, index, _| row().px(12.).items_center().child(text(format!("Row {index}"))),
                )
                .w_full()
                .h_full()
                .keyboard_navigation(true),
            );
        })
}

Selectable text

Text is not selectable unless asked, as in an app; selectable(true) makes a subtree a document you drag across, double-press for words and copy from, as on a web page.

use zgx::compose::prelude::*;
use zgx_desktop::{Application, WindowOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::new()
        .window(WindowOptions::default())
        .run(|cx| {
            cx.render(
                column()
                    .selectable(true)
                    .p(24.)
                    .gap(8.)
                    .child(text("Release notes").text_size(22.).heading(1))
                    .child(text("Drag across these lines to select them, then copy.").text_wrap(true)),
            );
        })
}

A settings window

cx.windows.open opens another window. An application’s windows share one reactive runtime: a signal changed in the settings window updates the main one.

use zgx::compose::prelude::*;
use zgx_desktop::{Application, WindowOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::new()
        .window(WindowOptions::default())
        .run(|cx| {
            let dark = cx.ui.signal(true);
            let windows = cx.windows.clone();
            let shared = dark.clone();
            let shown = dark.clone();
            cx.render(
                column()
                    .p(24.)
                    .gap(12.)
                    .child(text_signal(move || {
                        if shown.get() { "Dark".into() } else { "Light".into() }
                    }))
                    .child(button().child(text("Settings…")).on_click(move || {
                        let dark = shared.clone();
                        windows.open(
                            WindowOptions {
                                title: "Settings".into(),
                                width: 360.,
                                height: 200.,
                                ..Default::default()
                            },
                            move |cx| {
                                cx.render(column().p(24.).child(checkbox("Dark appearance", dark)));
                            },
                        );
                    })),
            );
        })
}

Shipping

cargo build --profile dist builds the app to ship. Add the profile to your workspace’s Cargo.toml:

# release, optimized as one program: about 14% smaller and a little faster,
# for a slower link. Keep `release` for day-to-day optimized builds.
[profile.dist]
inherits = "release"
lto = "fat"
codegen-units = 1

Profile-guided optimization is a further, optional step: build the app instrumented, run what it does, and build it again with what the run recorded. On zgx’s headless scenes it saves 2–19% of the instructions (the heavy dashboard 12%, Luau animation 19%), in scenes it was not trained on as much as in those it was. The profile fits the source it was recorded from, so record again after changes. Inside this repository, python3 scripts/perf/pgo.py -p zgx-run --bin zgx-run does all three builds, training on zgx’s headless scenes and on any command you give it ('{bin} …'), and writes target/dist-pgo/zgx-run. For an app of your own (rustup component add llvm-tools first):

RUSTFLAGS="-Cprofile-generate=/tmp/pgo" cargo build --profile dist
./target/dist/my-app            # use it as people do, then quit
"$(rustc --print sysroot)"/lib/rustlib/*/bin/llvm-profdata merge -o /tmp/app.profdata /tmp/pgo
RUSTFLAGS="-Cprofile-use=/tmp/app.profdata" cargo build --profile dist

zgx-desktop’s mimalloc feature makes mimalloc the program’s allocator: 1–10% fewer cycles in zgx’s headless scenes (text 7%, layout 6%, animation 9%, the heavy dashboard 2%) for about 2–3 MB more memory. It also smooths the frames after a large removal: a large subtree leaves the scene at once and zgx frees its nodes a millisecond’s worth per frame, but the system allocator on macOS can take 6–9 ms over the first chunk it frees (it hands pages back), where with mimalloc it stays within the millisecond. Turn it on with features = ["mimalloc"] on the dependency, unless your program declares a #[global_allocator] of its own.

Release binaries keep their symbol table, so backtraces and crash reports name functions. Stripping it saves about another 1.4 MB, and is your choice to make: add strip = "symbols" to [profile.dist] and keep the symbols apart, in a dSYM, to read crash reports with. On macOS that is split-debuginfo = "packed" with debug = "line-tables-only" in the profile; Cargo writes <binary>.dSYM beside the binary. Keep it with each release you ship, so crash reports from it can be symbolicated (atos, or Xcode’s Organizer).

To ship it on Windows (an icon, a manifest, one instance per user, an installer), see shipping on Windows; on macOS, bundle the binary in an .app with an Info.plist (the terminal example’s scripts/zterm-app.sh does).

Edit on GitHub