Skip to content
Theme

Widgets and scenes

Four ideas carry the whole toolkit. Once these are in place, everything else in this guide is detail.

A Button is a widget. So is a Column, and so is the padding around it. There is no separate category of “layout manager” or “container”: a layout is simply a widget whose job is to place its children.

Column column = new Column();
column.gap(12);
column.add(new Label("Name"));
column.add(new TextField());

That means composition is the only structuring tool you need, and a “screen” is a method that returns a widget. Extract a group of fields into a method and you have a reusable component; there is no registration step.

2. A scene binds a widget tree to a window

Section titled “2. A scene binds a widget tree to a window”

Scene is what turns a tree into a running interface: it measures, lays out, dispatches input and paints.

Scene scene = new Scene(root);
scene.bind(window);

You mutate widgets normally after that (label.setText(…), column.add(…)), and the scene works out what has to be measured and repainted again. You never call a repaint yourself.

One scene can serve a window for its whole life. To show another page in it, give the scene another root:

scene.setRoot(ordersPage());

The page that leaves is told it left, as a widget taken out of its container is, and whatever it had open (a menu, a popover) closes with it. If the keyboard was on that page, or in something it had open, it goes to the new page’s first field. A widget belongs to one place at a time, a scene’s root, an overlay or a container’s child, so the page that left can be shown again later or put anywhere else; while it is a scene’s root, every other place refuses it.

It is the thread that created the backend, and it is the only thread allowed to touch widgets. Calling a widget setter from a worker thread throws, immediately and with a clear message, rather than corrupting the frame three seconds later.

To get back onto it from anywhere:

Ui.post(() -> status.setText("Done"));

Anything that takes real time, such as a file, a network call or a decode, must not run on this thread, or the window stops responding for as long as it takes. Background work is the whole answer to that, and it is worth reading before you write your first loading screen.

Every component reads its metrics from a control size: one of XSMALL, SMALL, MEDIUM (the default), LARGE, XLARGE. It is inherited down the tree, so setting it once on a scene changes the padding, the type and the hit targets of everything in it:

scene.setControlSize(ControlSize.SMALL);

Use it for a compact mode, a dense inspector panel, or a touch-friendly kiosk build. You almost never set a pixel size on a component yourself; the step decides.

Three looks for a button, and a tone for what destroys something. The filled PRIMARY button is the one action a view is for; SECONDARY is outlined, for the others beside it; TERTIARY has no fill or border until the pointer is over it, for a quiet action that should not compete. Any of them takes setDestructive(true) for a button that deletes or discards, which draws it in the palette’s danger colour. The box is the same at every style, so a row of them lines up. A button with an icon and no caption is square, as wide as its step is tall, with the icon in the middle; setMinWidth and setMaxWidth give one button another width. Give it a tooltip, which is also the name a screen reader says for it.

static Row actions() {
Row row = new Row();
row.gap(12).crossAlignment(Flex.CrossAlignment.CENTER);
// The one thing the view is for, filled; a second choice, outlined; a quiet one, plain.
row.add(new Button("Approve"));
row.add(new Button("Save draft").setStyle(Button.Style.SECONDARY));
row.add(new Button("Skip").setStyle(Button.Style.TERTIARY));
// What deletes or discards says so in the palette's danger colour, at any style.
row.add(new Button("Delete order").setStyle(Button.Style.SECONDARY).setDestructive(true));
return row;
}

A Badge says a status in a few letters: a pill tinted in a Tone, a dot or an icon in the tone at its leading end, and the word in the palette’s text colour, so it reads whatever the tone. Badge.ofCount(n) shows a number, “99+” past 99, in the digits of the language shown; Badge.dot(name) is a dot alone, with a name a screen reader says in its place.

static Badge statusBadge(Status status) {
return switch (status) {
case OPEN -> new Badge("Open").setTone(Tone.INFO);
case PAID -> new Badge("Paid").setTone(Tone.SUCCESS);
case OVERDUE -> new Badge("Overdue").setTone(Tone.DANGER);
};
}
static Badge unread(int count) {
// "99+" past 99, in the digits of the language shown; named for a reader by the sentence.
return Badge.ofCount(count).setTone(Tone.ACCENT).setAccessibleName(count + " unread");
}

A ProgressBar says how far a piece of work has gone: setProgress from 0 to 1, which the fill eases to, or a sweep with setIndeterminate(true) for work that cannot say. Background work shows a job reporting into one.

An Avatar is a person as a circle: their picture with setImage, or their initials over a colour of their own, both from the name. An AvatarGroup overlaps them and ends in “+k” past the most it shows, with the hidden names in its tooltip. To a reader an avatar is an image named by the person (take it out with setAccessibleIgnored(true) beside the name it repeats), and a group is one group described by every name, the hidden ones included.

static Row approvers(List<String> names) {
AvatarGroup group = new AvatarGroup();
for (String name : names) {
group.add(new Avatar(name)); // initials and a colour of their own, from the name
}
group.setMaxShown(4); // three faces and "+k", the rest in its tooltip
group.setAccessibleName("Approvers"); // read as one group described by every name
Row row = new Row();
row.gap(8).crossAlignment(Flex.CrossAlignment.CENTER);
row.add(new Label("Approvers"));
row.add(group);
return row;
}

Every public signature says whether it takes or answers null, in JSpecify’s annotations: the packages are @NullMarked, so a type without a mark is never null, and @Nullable names the places that are. setIcon(null) clears an icon because its parameter says @Nullable Icon; widget.parent() can be null because its return type says so; new Scene(null) is a mistake the signature already warned you about.

@Nullable Widget<?> parent = widget.parent(); // null while it is not in a tree
button.setIcon(null); // allowed: the parameter is @Nullable

Nothing enforces this at run time, and nothing needs adding for the annotations to be there: they come with the toolkit’s dependency, and no code reads them once the program runs; a modular application may even leave the jar off its module path. An IDE reads them and warns at the call; Kotlin turns them into Widget<*>? and Icon?; a nullness checker that runs in the build turns them into build errors, for your own code as well once you mark it @NullMarked too.

When no combination of the existing ones will do, extend Widget and implement two methods: onMeasure says how big you want to be given the constraints you are handed, and onPaint draws you onto a Canvas. A widget names itself as its type argument, so that the setters every widget has return your type.

class Dot extends Widget<Dot> {
@Override
protected Size onMeasure(Constraints constraints) {
return constraints.constrain(16, 16);
}
@Override
protected void onPaint(Canvas canvas) {
canvas.fillCircle(width() / 2, height() / 2, 8, Theme.of(this).primary());
}
}

Where a widget is only a type in your code — a field that holds any widget, a list of them — write Widget<?>. Your own widgets takes a real one all the way: input, what to mark when it changes, the two change channels, and what a screen reader is told.