Skip to main content

JavaFX desktop client

The UiNode tree you already describe for the browser also renders as a native desktop application. Same model, same triggers, same vocabulary — a different painter.

This is not a web view in a window. UiTable becomes a real JavaFX TableView, UiField a real TextField, ComboBox or DatePicker. Nothing about the model was web-shaped to begin with; the browser was simply the first renderer.

The demo's Orders tab: a UiTable with row actions, a menu button and a progress bar

A UiTable with sortable columns, per-row actions, a UiMenuButton and a UiProgress — all painted from the same tree the browser would get.

<dependency>
<groupId>ai.mindconnect</groupId>
<artifactId>mc-semantic-ui-javafx</artifactId>
<version>0.2.2</version>
</dependency>

A complete small app

Everything below is one file. The tree in ui() is ordinary UiNode data — hand the very same object to SuiServerRenderer and you get HTML instead.

public class MiniApp extends Application {

// The overlay is the host ("the DOM"); the renderer paints into it; the bus
// drives the renderer — the same split as the browser's renderer + event bus.
private final SuiFxOverlay overlay = new SuiFxOverlay();
private final SuiFxRenderer renderer = new SuiFxRenderer().attach(overlay);
private final SuiFxEventBus bus = new SuiFxEventBus(renderer);

/** Plain data. Nothing here is JavaFX-specific. */
private UiNode ui() {
return UiStack.of(
UiText.of("Every field below ends up in one submit."),
UiForm.of("customer-form", "Customer details")
.field(UiField.text("name", "Name", "Ada Lovelace")
.asEditable().asRequired())
.field(UiField.text("email", "E-mail", "ada@example.com")
.asEditable())
.action(UiAction.primary("save", "Save").onClick(
UiTrigger.invoke("saveCustomer", "customer-form"))));
}

@Override
public void start(Stage stage) {
// A handler is plain Java. It runs off the FX thread by default, so a
// slow save never freezes the window.
bus.registerClientHandler("saveCustomer", ctx ->
bus.toast(UiToast.success("Saved " + ctx.string("name"))));

renderer.mount(ui());

// The overlay loads sui-fx.css itself, and toasts already land on it —
// no stylesheet wiring, no setOverlay call.
stage.setTitle("Semantic UI — JavaFX");
stage.setScene(new Scene(overlay, 520, 360));
stage.show();
}
}
Launch it from a separate class

Start the app from a main in a class that does not extend Application:

public final class MiniLauncher {
public static void main(String[] args) { Application.launch(MiniApp.class, args); }
}

A main class that extends Application needs javafx.graphics as a named module and fails on the classpath with "JavaFX runtime components are missing". The separate launcher avoids that entirely.

ctx.string("name") reads from the form payload. The bus collects it the way the browser does: every named field inside the enclosing UiForm, across arbitrary nesting — and across unselected tabs, because tab panels are painted eagerly.

Renderer, bus and overlay

The same split as the browser. SuiFxRenderer is the desktop counterpart of SuiRenderer, SuiFxEventBus of SuiEventBus.

renderer.attach(overlay)binds the renderer to its host surface
renderer.mount(UiNode)paints a tree into the host, returns the Node
renderer.applyPatch(UiPatch)applies REPLACE/APPEND/CLEAR/REMOVE to the live scene
new SuiFxEventBus(renderer)the bus that drives the renderer
bus.registerClientHandler(name, handler)a local Java handler for INVOKE
bus.toast(UiToast) / bus.showDialog(UiDialog)feedback and modals

The renderer owns mounting and patching (as in the browser); the bus owns triggers, handlers and feedback. bus.applyPatch also exists — it applies the node ops through the renderer and shows any toasts the patch carries.

Handlers run on a background thread by default; the bus hops back to the FX thread on its own when it applies the result. Pass FxHandlerThread.FX for the rare handler that must run on the UI thread.

Busy state is shown at three levels: the clicked control, a global scrim (delayed 250 ms, so fast handlers never make it flash), and the declarative UiAction.loading flag.

The server can drive it, too

Because triggers and UiPatch are the same objects the browser uses, an endpoint that already answers a browser can answer the desktop client without any change — and without a handler on the client at all:

// No registerClientHandler(...) anywhere: APPLY_RESPONSE does the work.
UiTrigger.api("GET", "/api/inventory")

If that endpoint returns a UiPatch whose operation targets inventory-panel, the client replaces exactly that panel. The demo does this over a real socket.

What is supported

22 node types render today, with no module beyond the renderer itself:

FrameUiAppShell, UiHeader
LayoutUiStack, UiSection (tabs), UiScrollPane, UiFieldGroup
DataUiTable (sorting, row actions, pagination), UiTree, UiDetail, UiList
InputUiForm, UiField (text, textarea, number, boolean, date, select, multiselect, file), UiUpload
ActionUiAction, UiLink, UiMenu, UiMenuButton
FeedbackUiText, UiIcon, UiDialog, UiSpinner, UiProgress, toasts

Anything else paints a visible placeholder instead of throwing, so an unknown node degrades rather than taking the window down.

UiIFrame needs a WebView, so it lives in a second artifact — see iframe, in its own artifact below. UiPage is a response envelope rather than a visual node, and belongs to the event bus — see Pages and navigation.

Pages and navigation

UiTrigger.go(href) is an APPLY_RESPONSE GET, so navigating on the desktop means fetching a UiPage and applying it. SuiFxEventBus.applyPage remounts the tree, drops the previous page's dialogs before opening this page's own, and sends the toasts to the toast handler.

Relative urls

A server writes its links relatively — /admin/tools, /img/logo.svg, agents/42 — and a browser resolves them against the address of the page they arrived on. The desktop needs the same base, or most links on a real screen are simply unusable.

SuiFxEventBus sets it whenever it applies a page, so an app navigating through the bus never has to think about it. Renderers reach it through ctx.resolve(url); set it by hand with renderer.setDocumentBase(url) when mounting a tree you fetched yourself.

Only a page moves the base, exactly as in a browser: a navigation changes it, an in-place patch does not.

Extension node types

The bus's default mapper calls findAndRegisterModules(), so markdown, chart, diagram and json-viewer parse as soon as their jars are on the classpath — without it a page containing one fails to parse at all. A type nothing on the classpath knows arrives as null and paints as nothing, rather than taking the whole page down; the SPA degrades the same way.

navigate is the one field with no desktop counterpart — it is a history push, and a window has no address bar — so instead of being acted on it goes to a handler that does nothing by default:

bus.setNavigateHandler(href -> breadcrumb.setPath(href));

A page's activeStreams are acted on; see below.

Streaming

STREAM opens its url as Server-Sent Events and feeds each event to the handler registered for its name. patch is built in — the universal case, an agent writing patches as it thinks — and an app registers whatever else its protocol speaks:

bus.onStreamEvent("token", (data, handle) -> transcript.append(data));
bus.onStreamEvent("done", (data, handle) -> input.setDisable(false));

The dispatch returns as soon as the response headers are in, not when the stream ends: a button that starts a five-minute run should stop spinning once the run has started, and the user must stay free to navigate away. The reader keeps going until the server closes it or FxStreamHandle.abort() is called, so a stream outlives the tree it started from.

The server's Sui-Stream-Channel header names the channel and its SSE event ids carry the channel's sequence, which is what makes resume work: when a page lists activeStreams this bus is not already reading — after a restart, or in a second window — it reconnects to their resume urls on its own. bus.activeStreams() lists what it is reading.

The app shell

app-shell and header are part of the renderer — a screen's frame is a base component, and nothing about it needs installing.

The shell puts the header on top, the menu and the page side by side beneath it, and the footer at the bottom. The page sits in a slot registered under UiAppShell.contentId(), so a patch can swap it while the header and menu stay put — the desktop counterpart of the web shell's data-sui-slot="content". It scrolls when the page outgrows the window.

iframe, in its own artifact

One node type, one module:

<dependency>
<groupId>ai.mindconnect</groupId>
<artifactId>mc-semantic-ui-javafx-iframe</artifactId>
<version>0.2.2</version>
</dependency>
var renderer = SuiFxRenderer.createDefaultRenderer();
SuiFxIFrame.install(renderer);
SuiFxIFrame.style(scene.getRoot());

iframe is a WebView, and javafx-web carries a WebKit build per platform — tens of megabytes. An app that embeds no pages should not ship a browser engine in order to draw a table, so this one renderer lives apart and the rest of the vocabulary costs nothing.

Without the module on the classpath an iframe paints the usual placeholder, so a tree that contains one still comes up.

sandbox is not a security boundary here

The attribute is a list of permissions the HTML spec defines for an <iframe>, and a WebView implements none of that vocabulary. The renderer honours the one distinction it can actually enforce — a sandbox without allow-scripts turns JavaScript off — and can do nothing about the rest. Point a UiIFrame at content you trust.

UiHeader.ExtrasOverflow.MENU is not implemented either: the extras row wraps rather than collapsing into a dropdown.

The extension types

Two of the four extension node types are painted on the desktop, each in its own artifact so an app pays only for what it shows.

markdown — walked into real controls rather than an embedded browser:

<dependency>
<groupId>ai.mindconnect</groupId>
<artifactId>mc-semantic-ui-javafx-markdown</artifactId>
<version>0.2.2</version>
</dependency>
SuiFxMarkdown.install(renderer);
SuiFxMarkdown.style(scene.getRoot());

Headings, paragraphs, lists, quotes, rules and code blocks; inline bold, italic, code and links. Emphasis accumulates, so bold inside italic arrives as both. A link dispatches through the bus, so a relative one resolves against the page the document came on. Images show their alt text rather than blocking the window on a slow fetch.

The obvious shortcut — render to HTML, hand it to a WebView — would drag a WebKit build onto every app that shows a paragraph of text, and the result would sit in the window as a foreign object with its own fonts, its own selection and its own scrollbars, deaf to the palette around it.

json-viewer — pretty-printed, in a read-only text area:

<dependency>
<groupId>ai.mindconnect</groupId>
<artifactId>mc-semantic-ui-javafx-json</artifactId>
<version>0.2.2</version>
</dependency>
SuiFxJson.install(renderer);
SuiFxJson.style(scene.getRoot());

The web node is backed by a component with IDE-style folding; there is no desktop equivalent and building one would be a tree widget's worth of work for a node whose job is to let someone read a payload. A text area rather than a label, because the reason a payload is on screen is usually that someone wants a value out of it — and it scrolls on its own when the payload is large, which is the case the node exists for. Malformed JSON is shown verbatim: that is exactly what the reader is looking for.

expandLevel and theme are the web component's own knobs and are ignored rather than approximated.

diagram and chart have no JavaFX painter yet. Their types parse — the bus registers every extension module on the classpath — so a page carrying one comes up with that node blank rather than failing.

Icons

Icons come from the same icons.svg sprite the browser loads: the resolver rebuilds a symbol's shapes as JavaFX geometry, so an icon token means the same glyph in all three renderers and adding one to the sprite lights it up everywhere at once. All 2037 Lucide symbols are covered.

The web icon is 1em in currentColor. JavaFX has no such inheritance, so a glyph attached to a control binds to that control's font and text fill instead — it tracks the label through hover, disable and theme changes. Every model type that carries an icon token renders it: UiAction, UiField, UiLink, UiList, UiMenuButton, UiMenuItem, UiSectionEntry, UiTable, UiTreeNode, plus the standalone UiIcon.

Point it somewhere else — a different sprite, an icon font, your own drawings — the same way the browser does, with setIconResolver:

renderer.setIconResolver(token -> myOwnGlyphFor(token));

An unknown token paints nothing rather than failing: a typo costs a glyph, not the window.

One place where the same model looks different

UiMenu.State.RAIL collapses the menu here instead of narrowing it to an icon strip — a rail is a column of icons, and there are none yet. With toggle, the hamburger button stays behind so the menu can be reopened; without one, RAIL behaves like HIDDEN.

UiField's CURRENCY, PERCENT, DATETIME and REFERENCE render as a plain text field rather than a dedicated control, and min/max/step are carried but not enforced.

UiScrollPane sticks to the newest content the same way, but without the floating jump-to-latest arrow — scrolling back down is what re-arms it. Its maxHeight takes pixels; a viewport-relative length like 60vh leaves the pane uncapped, filling the space its parent column has left.

A UiForm with text, select, date, boolean and textarea fields

A UiForm: every named control inside it lands in one payload, however deeply nested.

Actions, links, spinners, progress bars and a dialog button

Actions, links, spinners and progress — the declarative kind, driven by the model rather than by code.

Every field type, in a scroll pane

Every UiField type. Tab content sits in its own scroll pane, so a long form behaves like a web page rather than being clipped.

Six of the seven trigger behaviours work: APPLY_RESPONSE, INVOKE, PATCH, DOWNLOAD, OPEN_IN_TAB, UPLOAD. STREAM is not implemented — it is registered as a behaviour that throws, so you get a clear error rather than silence, and can register your own.

Styling

sui-fx.css is the JavaFX counterpart of sui.css. Every node carries a sui-<type> style class, so the selectors read like the web ones:

.sui-table { -fx-background-color: -sui-surface; }
.sui-action.is-loading { -fx-opacity: 0.6; }

Run the demo

mvn -pl javafx/mc-semantic-ui-javafx javafx:run

Ten tabs covering every supported node, a long-running handler with live progress, drag-and-drop upload, and an embedded HTTP server whose endpoints answer with UiPatch JSON.

See the module README for the current limitations.