Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Building an application

How a Goldberry application is put together: what the classes are, what each one is allowed to know, and where a thing goes when you are not sure.

The rule underneath all of it is one sentence: data flows down, events flow up (ADR-0063). Everything below is that sentence turned into files.

The four kinds of class

Values ────────► Views ────────► Actions ────────► Values
   (read)          (report)         (assign)         (notify)
What it isWhat it may know
Valuesa @Model class of plain fieldsnothing. No widget, no window, no toolkit type beyond @Bind
Actionsan @Actions record nested in the valuesthe values. Not widgets, not the window
ViewsWidget records — or a .kdl documentthe values it reads and the actions it calls
Applicationone implements Application, and no annotationall three, plus the Host. The only class that knows a window exists

Nothing points backwards. Values do not know actions exist; actions do not know views exist; a view cannot write a value, because what it is handed is an Observable with no set on it.

Values

A class of fields. Not a record — a record’s components are final and a bound field has to be assignable.

@Model
public final class Settings {

    @Bind("app.gain")                               private Number gain = 40;
    @Bind(value = "app.theme", restyle = true)      private String theme = "dark";
    @Bind(value = "app.bytesRead", repaint = false) private long bytesRead;

    // Projections are fine: a question about the fields with one answer.
    public Theme theme() {
        return "light".equals(theme) ? Theme.NORD_LIGHT : Theme.NORD_DARK;
    }
}

Each field declares what changing it costs:

  • the binding — always. Anything bound to app.gain is told.
  • a frame — by default; repaint = false for a value nothing on screen shows (ADR-0135).
  • a restyle — restyle = true when a rule depends on it, not a widget. A theme and a density, and almost nothing else (ADR-0133).

Assignment is what is observed, so hold a List and replace it rather than editing one in place. The build refuses to bind an array for exactly this reason.

Actions

A record wrapping the values, nested inside them. One method per thing a control can ask for.

@Model
public final class Settings {

    @Bind("app.gain") private Number gain = 40;

    @Actions
    public record Commands(Settings values) {

        @Action("app.louder") public void louder() { values.gain = values.gain.doubleValue() + 1; }
        @Action("app.pick")   public void pick(String name) { values.theme = name; }
    }
}

@Actions, not @Model. A class of methods holds no values and publishes no paths; calling it a model said otherwise (ADR-0139). A class carrying both markers, or an @Actions class with a @Bind field, is a build failure saying which one it should be.

Name it for its domain — Commands, Editing, Playback — and not Actions. A nested type called Actions shadows the annotation, so @Actions would resolve to your own record and you would have to write the annotation out in full. The showcase does exactly that and pays for it, on purpose, so there is one worked example of the wart.

values.gain = … notifies, even though the assignment is in a different class: the build rewrites a write to a @Bind field wherever it appears (ADR-0134).

A record, because it holds one thing and holds it immutably: no state of its own, equals that means what it says, a constructor nobody writes. Wanting a mutable field here is the signal that the thing is state — put it with the values, where the rest of the state is.

Nested, because a nestmate reaches a private field. That is the whole reason, and it is worth the one file: a sibling top-level class works too, but forces every value open to the package (ADR-0137). Nesting is scoping, not coupling — the values class holds no reference to Actions and compiles with it deleted.

Three shapes, in order of preference:

ShapeFieldsWhen
values with a nested @Actions recordprivatethe default
one class, values and methods togetherprivatea model with three fields
values and a sibling @Actions classpackage-privateyou want two files and will pay for them

An @Action method takes no argument, or one the toolkit can parse from a string (String, double, int, boolean). A button reports that something happened; a slider reports what it should become.

Views

Two forms, and they resolve names identically.

Markup, for structure:

column class="settings" {
  slider bind="app.gain" min=0 max=100 change="app.set-gain"
  button "Louder" press="app.louder"
}

Java, for anything a document should not carry — a loop, a conditional, a widget built from a list:

public record Panel(Settings settings, Settings.Commands actions) implements Widget.Stateless {

    @Override public Widget build(BuildContext context) {
        return new Column(
                new Slider(0, 100, Models.observable(settings, "app.gain"), actions::setGain),
                new Button("Louder", actions::louder));
    }
}

bind="app.gain" and Models.observable(settings, "app.gain") are the same lookup against the same registry. There is one name for a value, not two (ADR-0129).

What a view may not do

Write. A widget is handed the Observable half of a value and there is no set to call — so a control built from markup cannot reach the model even by accident, and “who changed this?” always has an answer.

Widget state versus application state

Two different things, and putting one where the other goes is the commonest mistake.

Lives inDies when
a scroll offset, a caret, which tab is open, a hoverState on the widgetthe widget is unmounted
the gain, the theme, the document being editedthe values classthe application exits

Ask: would a second screen showing this need the same answer? If yes, it is application state.

The application

One class. The only one that knows a window exists.

public final class Hello implements Application {

    private final Settings settings = new Settings();
    private final Settings.Commands actions = new Settings.Commands(settings);
    private Icons icons;

    /// Everything markup may name, and everything the window follows.
    @Override public List<Object> models() {
        return List.of(settings, actions);
    }

    @Override public void start(Host host) {
        // Anything the window owns for its whole life is made here and released
        // in stop(): a widget is a value that gets rebuilt and thrown away, so
        // nothing that costs money to build belongs in a build method. An icon
        // is parsed from the bundled set and scaled to one size, which is work
        // to do once rather than per frame.
        icons = Icons.strict().bind("plus", Icon.bundled("plus", 16));
        host.shortcut("Ctrl+T", actions::toggleTheme);
    }

    @Override public Widget root() {
        return new Panel(settings, actions);
    }

    @Override public List<Stylesheet> stylesheets() {
        return Controls.stylesheets(settings.theme());
    }

    @Override public void stop() {
        icons.close();
    }

    public static void main(String[] args) {
        Goldberry.run(new Hello());
    }
}

models() is the whole of the wiring. From that one list the toolkit gets:

  • what a document’s bind= and press= resolve against;
  • when to repaint — any value that asks;
  • when to restyle — any value declared restyle = true.

There is no repaint() call anywhere in an application, and there should not be one (ADR-0128).

More than one model

The list is a list because a window has actions of its own — “open the menu”, “toggle the HUD” — that need a Host and therefore have no business on a view model. Put them on the Application itself, annotate it @Model, and add this:

/// The window's own actions. Two Runnables, so this knows what they are called
/// and nothing about who performs them.
@Actions
public record WindowActions(Runnable openMenu) {
    @Action("app.open-menu") public void open() { openMenu.run(); }
}
public final class Hello implements Application {

    private final WindowActions window = new WindowActions(this::openMenu);

    @Override public List<Object> models() { return List.of(settings, actions, window); }

    private void openMenu() { host.popup(…); }
}

The Application itself is not a @Model. It owns the window, the lifecycle and the native resources; making it also a thing markup resolves names against puts two unrelated roles on one class (ADR-0138).

Two models may not claim one name; the build says which two.

Inflating a document

var inflater = Widgets.inflater(icons, models().toArray());
var window   = inflater.inflate(KdlParser.resource(Hello.class, "window.kdl").getFirst());

From models(), not from a list written out again. Two lists that must agree are two lists that will not: the showcase shipped for one commit with actions missing from the inflater and present in models(), so every test passed and the window threw no action named "app.toggle-theme" is bound on the first frame.

icons is the one registry that cannot be derived: an Icon is parsed from the icon set and built scaled to one size, so icon="plus" in a document reloaded on every keystroke would re-parse and re-scale one per reload. Markup may name an icon and must never build one. (It used to be a stronger rule — an icon held a BlendPath, a native allocation, and had to be closed exactly once. Since ADR-0277 an Icon is an immutable value whose close() does nothing, so the cost is work rather than a leak.)

Widget names need no registration at all. Every module on the path that ships widgets announces itself, so Widgets.inflater already knows button and column and anything a third widget module brought with it (ADR-0131).

Shipping a widget

If you are writing widgets rather than using them:

@Markup("gauge")
public record Gauge(double value, Observable<?> source, Attributes attributes)
        implements Widget.Leaf, Styled, Paints {

    public static Widget inflate(KdlNode node, List<Widget> children, Wiring wiring) {
        return new Gauge(node.numberProperty("value", 0), wiring.bound(node),
                Attributes.of(node));
    }
}

That is the whole registration. The build collects every @Markup class in the module into a catalog and declares it as a service; an application that never names your module gets your widget.

Every built-in must be constructible three ways — Java, KDL, and styleable by CSS — and a test enforces it. Hold your own widgets to the same rule; it is what makes a document portable between an application and a preview tool.

Where does it go?

I have…It goes…
a value a widget showsa @Bind field on the values class
a value nothing shows, but something watches@Bind(…, repaint = false)
a value a stylesheet depends on@Bind(…, restyle = true)
something a button doesan @Action on the actions class
something only Java callsa plain method on the actions class
a derived answer about the valuesa method on the values class
a scroll offset, a caretState on the widget
an icon, a font, a native handleopened in start, closed in stop
a menu, a popup, an acceleratorthe Application, which has the Host
a new node name for markup@Markup on the widget
an action that needs the Hosta small @Actions record of Runnables, built by the application

The package layout that follows

com.example.app
├── Hello.java              Application: the Host, the lifecycle. No annotation.
├── Settings.java           @Model values, with a nested @Actions record Commands
├── WindowActions.java      @Actions record: the actions that need the Host
├── window.kdl              structure
├── app.css                 appearance
└── ui/
    ├── Panel.java          Widget records
    └── Gauge.java          @Markup, if you ship widgets

Views in their own package, because they should be replaceable without touching the model. Everything else is flat: there is not enough of it to file.

Starting fast

What a user waits for is the process, not the toolkit, so it depends on how the application is launched. For the showcase, from exec to its first frame on a Linux desktop (ADR-0506):

LaunchFirst frame
GraalVM native imageabout 520 ms, the window open at about 120 ms
JVM with a JDK 25 AOT cacheabout 1.3 s
JVM, coldabout 2 s

A native image is the fastest and the one a release of the showcase ships. On the JVM, train an AOT cache (JEP 483, JEP 514, JEP 515): run the application once with -XX:AOTCacheOutput=app.aot through the screens a user opens first, then launch it with -XX:AOTCache=app.aot. Classes then arrive loaded and linked and the hot methods already profiled. The cache belongs to your application — it is specific to the JDK build and the module path, and it is trained on your screens — which is why the toolkit documents it rather than shipping one.

-Dgoldberry.log.level=TRACE prints the start-up timeline after the first frame: each phase from process start, so you can see which part is yours.

What the build does to all this

Your model is plain Java; the build makes assignments to it observable, using the JDK’s class-file API on the compiled class. That is one Gradle plugin or one Maven <execution>, and it is the subject of its own page — including what happens when you forget it, which is a loud error naming the missing step rather than a control that renders perfectly and never moves.