Skip to content
Theme

Coming from SWT

This page is for someone who already writes SWT, with or without JFace. The overall shape carries over: a window holds a tree of controls, events arrive as listeners, one thread owns the interface, and slow work goes elsewhere. What changes is that Limn draws its own widgets instead of wrapping the system’s, a control is made on its own and added to a container instead of being born inside a parent with style bits, there is nothing to dispose, and a list or a table reads your own objects without content and label providers.

Each section below shows the SWT code first and the Limn code after it, and links to the page of this guide that says the rest.

SWT and JFace Limn
Display the backend: new LwjglBackend()
Shell a NativeWindow from backend.createWindow(…), and a Scene bound to it
the readAndDispatch loop backend.runEventLoop()
Composite with a Layout a layout widget: Column, Row, Grid, Wrap, Stack
GridLayout and GridData Grid; Expanded and columnAlignment in place of grab and fill
RowLayout Row, or Wrap when it wraps
FillLayout a Column or a Row whose children are each Expanded
FormLayout, StackLayout no counterpart for attachments; scene.setRoot(…) or Stack for stacking
new Button(parent, SWT.PUSH) new Button("…"), added to its container
SWT.CHECK, SWT.RADIO, SWT.TOGGLE Checkbox, RadioButton, Checkbox.Variant.SWITCH
Label, Text, Text with SWT.PASSWORD, SWT.MULTI Label, TextField, PasswordField, TextArea
Combo, CCombo ComboBox, editable or not
List, Table, Tree ListView, Table, Tree
JFace TableViewer, TreeViewer and their providers Table with a column per value, Tree with a Tree.Model
ScrolledComposite, SashForm, TabFolder, CTabFolder ScrollView, SplitPane, TabbedPane
ToolBar, ProgressBar, Scale, Slider, Spinner ToolBar, ProgressBar, Slider, Spinner
DateTime DateField, DatePicker
ColorDialog ColorPicker, ColorPickerButton
Menu with SWT.BAR, SWT.CASCADE, SWT.POP_UP MenuBar, Menu, PopupMenu
MenuItem.setAccelerator(SWT.MOD1 + 'S') MenuItem.setAccelerator(Accelerator.command(Keys.S))
MessageBox, JFace MessageDialog Dialog
FileDialog, DirectoryDialog window.fileDialogs()
setToolTipText setTooltip
display.asyncExec Ui.post
display.timerExec Ui.postDelayed, which returns a handle that cancels it
a worker thread, or an Eclipse Job Ui.work(…), which returns a Job you can cancel
SWT.Selection listener onAction on a button, onSelect on a choice
ModifyListener onChange for the user’s changes, observeChanges for every change
Canvas with a PaintListener and GC a widget of your own with onPaint(Canvas)
redraw(), layout() invalidate(), markNeedsLayout(); a built-in widget does both for itself
Color, Font, Image with dispose() plain values, collected like any object
the system’s look Theme
DragSource, DropTarget, Clipboard drag and drop and the clipboard (Drag and drop)
Printer, PrintDialog Document: saved as PDF, printed, previewed (Printing and PDF)
Tray, TrayItem Tray
Program.launch window.launcher()

Limn draws its own widgets. An SWT button is the system’s button; a Limn button is drawn by Limn, the same on every system, in the palette of the current Theme. What stays the system’s own is what a user expects to be: the menu bar on macOS, the file choosers, the print dialog and the tray. Screen readers are still told everything: Limn speaks to each system’s accessibility interface itself (Accessibility).

A widget is made on its own. There is no parent argument and no style bits. A widget is created, configured with setters, and added to a container, and it can be taken out and put somewhere else later. What a style bit chose becomes a class (PasswordField), a variant (Checkbox.Variant.SWITCH) or a setter.

Nothing to dispose. Colours, fonts and images are plain Java values, and a window’s widgets go when the window does. There is no dispose() to forget and no “widget is disposed” exception to chase.

A handler answers the user. onChange, onAction, onSelect and the other handlers run when the user acts, not when your code calls setText or setSelectedIndex. An SWT ModifyListener fires for both. When you do want every change, the code’s included, observeChanges is the watcher that hears them all.

No providers. A Table takes your List<Order> and a column for each thing to show, each a function that reads one order. That function is the content provider and the label provider at once: setRows with a new list, or refresh() after changing the objects in place.

The thread rule is the same. A widget setter called from another thread throws at once, as SWT’s “Invalid thread access” does. There is no syncExec: hand a result to the UI thread with Ui.post, or use Ui.work, whose callbacks already run there.

main runs the window. backend.runEventLoop() is the readAndDispatch loop, written once by the toolkit, and returns when the application ends. On macOS the thread has to be the process’s first one, which is why the JVM is started with -XstartOnFirstThread there, as for SWT.

In SWT:

public static void main(String[] args) {
Display display = new Display();
Shell shell = new Shell(display);
shell.setText("Hello, SWT");
shell.setSize(480, 320);
GridLayout layout = new GridLayout(1, false);
layout.marginWidth = layout.marginHeight = 24;
layout.verticalSpacing = 12;
shell.setLayout(layout);
new Label(shell, SWT.NONE).setText("A window, drawn by SWT.");
Button close = new Button(shell, SWT.PUSH);
close.setText("Close");
close.addListener(SWT.Selection, e -> shell.close());
shell.open();
while (!shell.isDisposed()) {
if (!display.readAndDispatch()) {
display.sleep();
}
}
display.dispose();
}

In Limn:

import limn.backend.Backend;
import limn.backend.NativeWindow;
import limn.backend.WindowConfig;
import limn.backend.lwjgl.LwjglBackend;
import limn.components.Button;
import limn.components.Label;
import limn.scene.Insets;
import limn.scene.Scene;
import limn.scene.layout.Column;
import limn.scene.layout.Padding;
/** A window with a label and a button that closes it. */
public final class HelloWindow {
public static void main(String[] args) {
// macOS needs -XstartOnFirstThread on the JVM command line; it is macOS-only and a
// JVM elsewhere given it will not start.
try (Backend backend = new LwjglBackend()) {
NativeWindow window = backend.createWindow(
WindowConfig.of("Hello, Limn", 480, 320));
Column column = new Column();
column.gap(12);
column.add(new Label("A window, drawn by Limn."));
column.add(new Button("Close").onAction(window::requestClose));
Scene scene = new Scene(new Padding(Insets.all(24), column));
scene.bind(window);
backend.runEventLoop();
}
}
}

Install and first window has the dependency and the command line.

In SWT:

Composite form = new Composite(parent, SWT.NONE);
GridLayout layout = new GridLayout(2, false);
layout.marginWidth = layout.marginHeight = 20;
layout.horizontalSpacing = 12;
layout.verticalSpacing = 10;
form.setLayout(layout);
new Label(form, SWT.NONE).setText("Name");
Text name = new Text(form, SWT.BORDER);
name.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
new Label(form, SWT.NONE).setText("Email");
Text email = new Text(form, SWT.BORDER);
email.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
Composite buttons = new Composite(form, SWT.NONE);
buttons.setLayoutData(new GridData(SWT.END, SWT.CENTER, true, false, 2, 1));
buttons.setLayout(new RowLayout(SWT.HORIZONTAL));
new Button(buttons, SWT.PUSH).setText("Cancel");
Button save = new Button(buttons, SWT.PUSH);
save.setText("Save");

In Limn:

static Widget<?> contactForm(TextField name, TextField email, Button save) {
Grid fields = new Grid(2);
fields.gap(12).rowGap(10).expandedColumn(1)
.columnAlignment(1, Flex.CrossAlignment.STRETCH);
fields.add(new Label("Name").setLabelFor(name));
fields.add(name);
fields.add(new Label("Email").setLabelFor(email));
fields.add(email);
Row buttons = new Row();
buttons.gap(8);
buttons.add(Expanded.spacer(1)); // takes the room left: the buttons go to the end
buttons.add(new Button("Cancel").setStyle(Button.Style.SECONDARY));
buttons.add(save);
Column form = new Column();
form.gap(16).crossAlignment(Flex.CrossAlignment.STRETCH);
form.add(fields);
form.add(buttons);
return new Padding(Insets.all(20), form);
}

setLabelFor is what a screen reader reads as the field’s name, the job a label placed before a text field does on its own in SWT. Layout has every layout widget, and Forms the Field that puts a label, a hint and a validation message around an input.

In SWT:

save.addListener(SWT.Selection, e -> status.setText("Saved " + name.getText()));
name.addModifyListener(e -> save.setEnabled(!name.getText().isBlank()));
notify.addListener(SWT.Selection, e ->
status.setText(notify.getSelection() ? "You will be told" : "Quietly"));
plan.addListener(SWT.Selection, e -> status.setText("Plan: " + plan.getText()));

In Limn:

static void wire(Button save, TextField name, Checkbox notify, ComboBox<String> plan,
Label status) {
save.onAction(() -> status.setText("Saved " + name.text()));
// A handler answers the user: it runs when they type, not on setText.
name.onChange(text -> save.setEnabled(!text.isBlank()));
notify.onChange(on -> status.setText(on ? "You will be told" : "Quietly"));
plan.onSelect(() -> status.setText("Plan: " + plan.selectedItem()));
// A watcher hears every change, the code's included: the label mirrors the field.
name.observeChanges((widget, change) -> status.setText(name.text()));
}

A widget has one handler of each kind, and setting a second throws: pass null first to replace it. Watchers, as many as you like, are what observeChanges adds.

In SWT:

status.setText("Searching…");
int asked = ++generation; // an older search must not land after a newer one
new Thread(() -> {
try {
List<String> found = repository.search(query);
display.asyncExec(() -> {
if (asked != generation || results.isDisposed()) return;
results.setItems(found.toArray(new String[0]));
status.setText(found.size() + " found");
});
} catch (Exception e) {
display.asyncExec(() -> status.setText("Failed: " + e.getMessage()));
}
}).start();

In Limn:

static final class Search {
private Job job;
void find(String query, Function<String, List<String>> repository,
ListView<String> results, Label status) {
if (job != null) {
job.cancel(); // the answer to an older question is not wanted any more
}
status.setText("Searching…");
job = Ui.work(progress -> repository.apply(query)) // on a worker
.onSuccess(found -> { // on the UI thread
results.setItems(found);
status.setText(found.size() + " found");
})
.onFailure(error -> status.setText("Failed: " + error.getMessage()))
.start();
}
}

Every callback runs on the UI thread, so none of them needs asyncExec, and a cancelled job delivers nothing, which is the generation check written once. Background work has progress and cancellation.

In SWT:

Runnable tick = new Runnable() {
@Override public void run() {
if (time.isDisposed()) return;
time.setText(LocalTime.now().withNano(0).toString());
display.timerExec(1000, this);
}
};
display.timerExec(0, tick);
// later
display.timerExec(-1, tick);

In Limn:

static final class Clock {
private Subscription next;
void start(Label time) {
time.setText(LocalTime.now().withNano(0).toString());
next = Ui.postDelayed(() -> start(time), 1000); // re-armed on every tick
}
void stop() {
if (next != null) {
next.cancel();
}
}
}

For something that moves on screen, a transition or a ticker is the better tool: Animations.

In SWT:

TableViewer viewer = new TableViewer(parent, SWT.BORDER | SWT.FULL_SELECTION);
viewer.getTable().setHeaderVisible(true);
viewer.setContentProvider(ArrayContentProvider.getInstance());
TableViewerColumn number = new TableViewerColumn(viewer, SWT.NONE);
number.getColumn().setText("Order");
number.getColumn().setWidth(90);
number.setLabelProvider(new ColumnLabelProvider() {
@Override public String getText(Object o) { return ((Order) o).number(); }
});
TableViewerColumn customer = new TableViewerColumn(viewer, SWT.NONE);
customer.getColumn().setText("Customer");
customer.getColumn().setWidth(200);
customer.setLabelProvider(new ColumnLabelProvider() {
@Override public String getText(Object o) { return ((Order) o).customer(); }
});
TableViewerColumn total = new TableViewerColumn(viewer, SWT.RIGHT);
total.getColumn().setText("Total");
total.getColumn().setWidth(100);
total.setLabelProvider(new ColumnLabelProvider() {
@Override public String getText(Object o) { return String.valueOf(((Order) o).total()); }
});
viewer.setInput(rows);
viewer.addDoubleClickListener(e -> {
Order o = (Order) e.getStructuredSelection().getFirstElement();
status.setText("Opened " + o.number());
});

In Limn:

static Table<Order> orders(List<Order> rows, Label status) {
Table<Order> table = new Table<>(List.of(
Column.text("Order", Order::number).width(90),
Column.text("Customer", Order::customer).weight(1),
Column.numeric("Total", Order::total).width(100)));
table.setRows(rows); // the list itself; sorting by a header is built in
table.onActivate(row -> status.setText("Opened " + table.rows().get(row).number()));
return table;
}

A column is the label provider and the content provider in one function. The table sorts from its headers with no ViewerComparator, and only builds the rows on screen, so a table over a million rows costs what one over twenty does, with no SWT.VIRTUAL to ask for. Lists and scrolling has selection, footers, frozen columns and a button in a row.

In SWT:

Menu bar = new Menu(shell, SWT.BAR);
MenuItem fileItem = new MenuItem(bar, SWT.CASCADE);
fileItem.setText("&File");
Menu file = new Menu(shell, SWT.DROP_DOWN);
fileItem.setMenu(file);
MenuItem saveItem = new MenuItem(file, SWT.PUSH);
saveItem.setText("&Save\tCtrl+S");
saveItem.setAccelerator(SWT.MOD1 + 'S');
saveItem.addListener(SWT.Selection, e -> save.run());
new MenuItem(file, SWT.SEPARATOR);
MenuItem deleteItem = new MenuItem(file, SWT.PUSH);
deleteItem.setText("Delete…");
deleteItem.addListener(SWT.Selection, e -> {
MessageBox box = new MessageBox(shell, SWT.ICON_WARNING | SWT.YES | SWT.NO);
box.setText("Delete this order?");
box.setMessage("This cannot be undone.");
if (box.open() == SWT.YES) delete.run();
});
shell.setMenuBar(bar);

In Limn:

static MenuBar menuBar(Widget<?> owner, Runnable save, Runnable delete) {
Menu file = new Menu()
.add(MenuItem.of("Save", save).setAccelerator(Accelerator.command(Keys.S)))
.addSeparator()
.add(MenuItem.of("Delete…", () -> confirmDelete(owner, delete)));
MenuBar bar = new MenuBar(); // on macOS it is shown in the system's menu bar
bar.addMenu("File", file);
return bar;
}
static void confirmDelete(Widget<?> owner, Runnable delete) {
new Dialog("Delete this order?", "This cannot be undone.")
.addPrimaryButton("Keep", "keep")
.addDestructiveButton("Delete", "delete")
.setCancelResult("keep")
.show(owner) // returns at once
.thenAccept(answer -> { // the choice, later, on the UI thread
if ("delete".equals(answer)) {
delete.run();
}
});
}

show returns at once and the answer arrives later, so the window keeps drawing while the question is up, where MessageBox.open waits. The buttons are named for what they do rather than Yes and No. Accelerator.command is SWT.MOD1: Command on macOS, Control elsewhere. Menus and dialogs has the rest.

In SWT:

FileDialog dialog = new FileDialog(shell, SWT.OPEN);
dialog.setText("Open invoice");
dialog.setFilterPath(lastFolder.toString());
dialog.setFilterNames(new String[]{"PDF"});
dialog.setFilterExtensions(new String[]{"*.pdf"});
String chosen = dialog.open();
if (chosen != null) {
load.accept(Path.of(chosen));
}

In Limn:

static void open(NativeWindow window, Path lastFolder, Consumer<Path> load) {
window.fileDialogs()
.openFile("Open invoice", lastFolder, FileDialogs.Filter.of("PDF", "*.pdf"))
.thenAccept(chosen -> chosen.ifPresent(load)); // empty means cancelled
}

Both are the system’s own chooser. Limn’s does not block: the window keeps drawing while the user browses, and an empty answer means cancelled.

In SWT:

Canvas meter = new Canvas(parent, SWT.DOUBLE_BUFFERED);
meter.setLayoutData(new GridData(160, 12));
meter.addPaintListener(e -> {
Rectangle area = meter.getClientArea();
e.gc.setBackground(display.getSystemColor(SWT.COLOR_WIDGET_NORMAL_SHADOW));
e.gc.fillRectangle(area);
e.gc.setBackground(display.getSystemColor(SWT.COLOR_LIST_SELECTION));
e.gc.fillRectangle(0, 0, Math.round(area.width * level), area.height);
});
// after a change of level
meter.redraw();

In Limn:

public static final class Meter extends Widget<Meter> {
private float level; // 0 to 1
public Meter setLevel(float newLevel) {
Ui.checkUiThread();
level = Math.max(0, Math.min(1, newLevel));
invalidate(); // repaint at the same size; markNeedsLayout() for a new size
return this;
}
@Override
protected Size onMeasure(Constraints constraints) {
return constraints.constrain(160, 12);
}
@Override
protected void onPaint(Canvas canvas) {
Theme theme = Theme.of(this);
canvas.fillRect(0, 0, width(), height(), theme.outline());
canvas.fillRect(0, 0, width() * level, height(), theme.primary());
}
}

The widget says how big it wants to be instead of being given layout data, and its colours come from the theme, so it follows a palette change with no code and nothing to dispose. Sizes are in points and the canvas is already scaled for the display. Your own widgets has input, focus and what a screen reader is told.

SWT has none of its own to change: its controls are the system’s. Limn’s look is a palette, which you pick from the built-ins or build from your brand:

static void brand() {
Theme.builder("Acme", false)
.background(Color.WHITE)
.primary(Color.rgb(0x0F6E4F))
.deriveAccentStates() // hover and pressed tones solved from the accent
.cornerScale(1.6f)
.build()
.apply(); // every open window repaints in it
}

Theming has the built-in palettes, density, fonts and following the operating system’s light or dark appearance.

Some of what SWT and the Eclipse platform give you has no Limn counterpart, and it is better to know before you plan a migration than in the middle of one.

  • No native controls. That is the design rather than a gap, but it is the first thing an SWT user notices: a Limn window looks like Limn on every system, not like that system’s own controls.
  • No workbench. There are no views, editors, perspectives, extension points or plug-ins: Limn is a toolkit, not an application platform. An RCP application moves its screens, and keeps or replaces its own structure around them.
  • No data binding. There is no counterpart to JFace data binding; values are set from handlers and watchers.
  • No mixing. Limn does not host SWT controls, and a Limn scene cannot be put inside a Shell. Running both toolkits in one process is not something Limn supports or tests, so a migration moves an application, or a separate program of it, at a time.
  • No browser and no styled text. There is no Browser widget; window.launcher() opens a page in the user’s own. There is no StyledText: a Label wraps and a TextArea edits plain text.
  • No form attachments. There is no FormLayout; a layout of your own is a widget that places its children (Your own widgets).