Coming from Swing
This page is for someone who already writes Swing. Most of what you know carries over: a window holds a tree of components, events arrive as callbacks, one thread owns the interface, and slow work goes elsewhere. What changes is that layout is done by widgets instead of layout managers, the thread rule is enforced, a list or a table reads your own objects instead of a model class, and the look comes from one themed palette instead of a pluggable look and feel.
Each section below shows the Swing code first and the Limn code after it, and links to the page of this guide that says the rest.
The ideas, side by side
Section titled “The ideas, side by side”| Swing | Limn |
|---|---|
JFrame |
a NativeWindow from backend.createWindow(…), and a Scene bound to it |
JPanel with a layout manager |
a layout widget: Column, Row, Grid, Wrap, Stack |
BorderLayout |
a Column or a Row, with Expanded on the part that takes the space left |
GridBagLayout, GridLayout |
Grid |
FlowLayout |
Wrap |
CardLayout |
scene.setRoot(…) for a whole page, TabbedPane for tabs |
JLabel, JButton, JCheckBox, JRadioButton |
Label, Button, Checkbox, RadioButton |
JTextField, JPasswordField, JTextArea |
TextField, PasswordField, TextArea |
JFormattedTextField |
TextField.setMask(…), Spinner, DateField |
JComboBox |
ComboBox, editable or not |
JList, JTable, JTree |
ListView, Table, Tree |
JScrollPane, JSplitPane, JTabbedPane |
ScrollView, SplitPane, TabbedPane |
JToolBar, JProgressBar, JSlider, JSpinner |
ToolBar, ProgressBar, Slider, Spinner |
JMenuBar, JMenu, JMenuItem |
MenuBar, Menu, MenuItem |
JPopupMenu |
PopupMenu |
JOptionPane, JDialog |
Dialog |
JFileChooser |
window.fileDialogs(), the system’s own chooser |
JColorChooser |
ColorPicker, ColorPickerButton |
setToolTipText |
setTooltip |
KeyStroke on a menu item |
MenuItem.setAccelerator(Accelerator.command(Keys.S)) |
SwingUtilities.invokeLater |
Ui.post |
SwingWorker |
Ui.work(…), which returns a Job you can cancel |
javax.swing.Timer |
Ui.postDelayed, which returns a handle that cancels it |
ActionListener |
onAction |
DocumentListener, ItemListener |
onChange, which runs for the user’s changes |
PropertyChangeListener |
observeChanges, which hears every change |
paintComponent(Graphics) |
a widget of your own with onPaint(Canvas) |
repaint(), revalidate() |
invalidate(), markNeedsLayout(); a built-in widget does both for itself |
look and feel, UIManager |
Theme |
TransferHandler |
drag and drop and the clipboard (Drag and drop) |
PrinterJob, JTable.print |
Document: saved as PDF, printed, previewed (Printing and PDF) |
SystemTray, TrayIcon |
Tray |
Desktop.browse, Desktop.open |
window.launcher() |
The differences that matter
Section titled “The differences that matter”The thread rule is enforced. Swing tells you to touch components only on the event dispatch thread and mostly lets you get away with breaking that, until a repaint goes wrong somewhere you cannot reproduce. In Limn a setter called from another thread throws at once, with a message that says so. The rule is the same; finding the mistake is not.
main runs the window. backend.runEventLoop() takes the thread that created the backend
and returns when the application ends. On macOS that thread has to be the process’s first one,
which is why the JVM is started with -XstartOnFirstThread there.
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. A Swing
DocumentListener fires for both, and code that fills a form often has to guard against its
own listeners. When you do want every change, the code’s included, observeChanges is the
watcher that hears them all.
No model classes. A Table takes your List<Order> and a column for each thing to show,
each a function that reads one order. There is no TableModel to implement and no
fireTableDataChanged to remember: setRows with a new list, or refresh() after changing
the objects in place.
Layout is widgets. There is no setLayout: a Column is the container and the layout at
once. There is also no absolute positioning with a null layout; sizes come from what each
widget measures, in points, and density is a setting (ControlSize) rather than a size you
hard-code.
One look, themed. There is no pluggable look and feel. Every widget draws from the
current Theme, which you can pick from the built-ins, build from your brand colours, or let
your users edit, and every open window repaints in it. Following the operating system’s light or
dark appearance is a few lines of yours (Theming).
A window
Section titled “A window”In Swing:
public static void main(String[] args) { SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("Hello, Swing"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); JPanel panel = new JPanel(); panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); panel.setBorder(BorderFactory.createEmptyBorder(24, 24, 24, 24)); panel.add(new JLabel("A window, drawn by Swing.")); panel.add(Box.createVerticalStrut(12)); JButton close = new JButton("Close"); close.addActionListener(e -> frame.dispose()); panel.add(close); frame.setContentPane(panel); frame.setSize(480, 320); frame.setVisible(true); });}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.
A form
Section titled “A form”In Swing:
JPanel fields = new JPanel(new GridBagLayout());GridBagConstraints c = new GridBagConstraints();c.insets = new Insets(5, 6, 5, 6);c.anchor = GridBagConstraints.LINE_START;c.gridx = 0; c.gridy = 0;fields.add(new JLabel("Name"), c);c.gridx = 1; c.weightx = 1; c.fill = GridBagConstraints.HORIZONTAL;fields.add(name, c);c.gridx = 0; c.gridy = 1; c.weightx = 0; c.fill = GridBagConstraints.NONE;fields.add(new JLabel("Email"), c);c.gridx = 1; c.weightx = 1; c.fill = GridBagConstraints.HORIZONTAL;fields.add(email, c);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.TRAILING));buttons.add(new JButton("Cancel"));buttons.add(save);
JPanel form = new JPanel(new BorderLayout(0, 16));form.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));form.add(fields, BorderLayout.CENTER);form.add(buttons, BorderLayout.SOUTH);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, as JLabel.setLabelFor is.
Layout has every layout widget, and Forms the Field that
puts a label, a hint and a validation message around an input.
Events
Section titled “Events”In Swing:
save.addActionListener(e -> status.setText("Saved " + name.getText()));name.getDocument().addDocumentListener(new DocumentListener() { public void insertUpdate(DocumentEvent e) { changed(); } public void removeUpdate(DocumentEvent e) { changed(); } public void changedUpdate(DocumentEvent e) { changed(); } private void changed() { save.setEnabled(!name.getText().isBlank()); }});notify.addItemListener(e -> status.setText(notify.isSelected() ? "You will be told" : "Quietly"));plan.addActionListener(e -> status.setText("Plan: " + plan.getSelectedItem()));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.
Work off the UI thread
Section titled “Work off the UI thread”In Swing:
if (worker != null) { worker.cancel(true);}status.setText("Searching…");worker = new SwingWorker<List<String>, Void>() { @Override protected List<String> doInBackground() { return repository.search(query); } @Override protected void done() { if (isCancelled()) return; try { List<String> found = get(); results.setListData(found.toArray(new String[0])); status.setText(found.size() + " found"); } catch (Exception e) { status.setText("Failed: " + e.getMessage()); } }};worker.execute();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(); }}A cancelled job delivers nothing, and every callback runs on the UI thread, so none of them
needs invokeLater. Background work has progress and cancellation.
A timer
Section titled “A timer”In Swing:
Timer timer = new Timer(1000, e -> time.setText(LocalTime.now().withNano(0).toString()));timer.start();// latertimer.stop();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.
A table
Section titled “A table”In Swing:
class OrderModel extends AbstractTableModel { private final List<Order> rows; OrderModel(List<Order> rows) { this.rows = rows; } public int getRowCount() { return rows.size(); } public int getColumnCount() { return 3; } public String getColumnName(int c) { return new String[]{"Order", "Customer", "Total"}[c]; } public Object getValueAt(int r, int c) { Order o = rows.get(r); return switch (c) { case 0 -> o.number(); case 1 -> o.customer(); default -> o.total(); }; }}JTable table = new JTable(new OrderModel(rows));table.setAutoCreateRowSorter(true);table.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { int row = table.convertRowIndexToModel(table.getSelectedRow()); status.setText("Opened " + rows.get(row).number()); } }});JScrollPane scroll = new JScrollPane(table);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;}The table scrolls by itself, sorts from its headers, and only builds the rows on screen, so a table over a million rows costs what one over twenty does. Lists and scrolling has selection, footers, frozen columns and a button in a row.
Menus and dialogs
Section titled “Menus and dialogs”In Swing:
JMenu file = new JMenu("File");JMenuItem saveItem = new JMenuItem("Save");saveItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx()));saveItem.addActionListener(e -> save.run());file.add(saveItem);file.addSeparator();JMenuItem deleteItem = new JMenuItem("Delete…");deleteItem.addActionListener(e -> { int answer = JOptionPane.showOptionDialog(frame, "This cannot be undone.", "Delete this order?", JOptionPane.DEFAULT_OPTION, JOptionPane.WARNING_MESSAGE, null, new String[]{"Keep", "Delete"}, "Keep"); if (answer == 1) delete.run();});file.add(deleteItem);JMenuBar bar = new JMenuBar();bar.add(file);frame.setJMenuBar(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; nothing blocks the way showOptionDialog does. Accelerator.command is
Command on macOS and Control elsewhere. Menus and dialogs has the
rest, the system’s menu bar on macOS included.
Choosing a file
Section titled “Choosing a file”In Swing:
JFileChooser chooser = new JFileChooser(lastFolder.toFile());chooser.setFileFilter(new FileNameExtensionFilter("PDF", "pdf"));if (chooser.showOpenDialog(frame) == JFileChooser.APPROVE_OPTION) { load.accept(chooser.getSelectedFile().toPath());}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}This is the operating system’s own chooser, not a drawing of one: a sheet on macOS, the Windows dialog, the desktop portal on Linux.
Painting your own
Section titled “Painting your own”In Swing:
class Meter extends JComponent { private float level; void setLevel(float level) { this.level = Math.max(0, Math.min(1, level)); repaint(); } @Override public Dimension getPreferredSize() { return new Dimension(160, 12); } @Override protected void paintComponent(Graphics g) { g.setColor(UIManager.getColor("Separator.foreground")); g.fillRect(0, 0, getWidth(), getHeight()); g.setColor(UIManager.getColor("ProgressBar.foreground")); g.fillRect(0, 0, Math.round(getWidth() * level), getHeight()); }}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()); }}Sizes are in points and the canvas is already scaled for the display, so the same numbers are sharp on a Retina screen and a plain one. Colours come from the theme, so the meter follows a palette change and dark mode with no code. Your own widgets has input, focus and what a screen reader is told.
The look
Section titled “The look”In Swing:
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());UIManager.put("Button.background", new Color(0x0F6E4F));SwingUtilities.updateComponentTreeUI(frame);In Limn:
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 appearance.
What Limn does not have
Section titled “What Limn does not have”Some of what Swing gives you has no Limn counterpart, and it is better to know before you plan a migration than in the middle of one.
- No mixing. Limn does not host Swing components, and a Limn scene cannot be put inside a
JFrame. 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 rich text. There is no
JEditorPaneorJTextPane: no HTML rendering, no styled document. ALabelwraps and aTextAreaedits plain text. - No web view. There is no embedded browser;
window.launcher()opens a page in the user’s own. - No internal frames or docking. There is no
JDesktopPane. ASplitPane, tabs and separate windows are what Limn offers. - No saved settings. Limn does not persist window positions or preferences for you;
java.util.prefsor a file of your own is the place for them. - No custom layout managers by constraint object. A layout of your own is a widget that
places its children (Your own widgets), not a
LayoutManager2fed with constraints.