Hello World [Kotlin]
Introduction
The Kotlin binding rides on the same JNA
layer as Java, so it loads the prebuilt libazul native library directly. You write
idiomatic Kotlin — a data class, a LayoutCallback SAM that returns a Dom, and the
companion-object App factory — and the generated wrappers handle the FFI.
Installation
You need Kotlin 1.9+, JDK 17+, JNA 5.14+, and the native libazul library.
Azul is not on Maven Central, but there is a self-hosted maven2 repository
served from azul.rs (generated by the release CI). Add it next to
mavenCentral() and depend on rs.azul:azul (the jar bundles the Java
bindings plus a Linux x86-64 libazul.so as a JNA resource; on other
platforms also do step 1 below). Only JNA itself
(net.java.dev.jna:jna:5.14.0) comes from Maven Central as usual:
repositories {
mavenCentral()
maven { url = uri("https://azul.rs/ui/maven") }
}
dependencies {
implementation("net.java.dev.jna:jna:5.14.0")
implementation("rs.azul:azul:0.2.0")
}
Alternatively, install manually:
-
Download the native library from the release page (
libazul.dylib/libazul.so/azul.dll). -
Add the generated
Azul.ktbindings (plus optional Gradle scaffolding) to your sources:wget https://azul.rs/ui/release/0.2.0/Azul.kt wget https://azul.rs/ui/release/0.2.0/build.gradle.kts wget https://azul.rs/ui/release/0.2.0/settings.gradle.kts
The native library must be discoverable via -Djna.library.path /
DYLD_LIBRARY_PATH / LD_LIBRARY_PATH / PATH.
Simple „Counter“ Example
package com.azul
import com.sun.jna.Pointer
// Plain data class - the "single source of truth" for app state.
class MyDataModel(var counter: Int)
private val MODEL = MyDataModel(5)
// Click callback: write the Update int through the out-pointer.
private val onClick = AzulNativeManaged.ButtonOnClickCallbackInvokerCallback { _, dataPtr, _, outPtr ->
val m = AzulHostInvoker.refanyGet(dataPtr)
val result = if (m is MyDataModel) { m.counter += 1; Update.RefreshDom.value }
else Update.DoNothing.value
outPtr!!.setInt(0, result)
}
// Typed layout callback: returns a Dom directly; the bridge splices the bytes
// into the native out-pointer internally.
private val layout = AzulHostInvoker.LayoutCallback { _, dataPtr, _ ->
val m = AzulHostInvoker.refanyGet(dataPtr)
if (m !is MyDataModel) {
Dom.createBody()
} else {
val label = Dom.createDiv()
.withCss("font-size: 32px;")
.withChild(Dom.createText(m.counter.toString()))
val buttonDom = Button.create("Increase counter")
.withButtonType(ButtonType.Primary.value)
.onClick(m, onClick)
.dom()
Dom.createBody()
.withChild(label)
.withChild(buttonDom)
}
}
fun main() {
// `use { }` disposes the App (C-side delete) when the block exits.
App.create(AzulHostInvoker.refanyWrap(MODEL), AppConfig.create()).use { app ->
app.run(WindowCreateOptions.create(layout))
}
}
Three things to notice.
refanyWrap/refanyGetwithissmart-casts — the same object instance is handed back to every callback;if (m is MyDataModel)both guards and smart-casts. On mismatch returnDom.createBody()/Update.DoNothing.value.LayoutCallbackSAM returnsDom— the companionWindowCreateOptions.createfactory hides the host-invoker register + JNA byte-splice. Note the!!on the nullablePointer?out-pointer beforesetInt.- Fluent wrapper API —
Dom.createBody().withChild(...)andButton.create(...).withButtonType(...).onClick(data, fn).dom(). The click handler is the event's typed SAM (ButtonOnClickCallbackInvokerCallbackforButton.onClick).AzulString.toString()decodes UTF-8 intokotlin.String.
Build and run
kotlinc -J-Xmx4g -cp $JNA_JAR Azul.kt HelloWorld.kt \
-include-runtime -d hello-world.jar
# macOS requires -XstartOnFirstThread (Cocoa main-thread rule).
DYLD_LIBRARY_PATH=. java -XstartOnFirstThread -Djna.library.path=. \
-cp hello-world.jar:$JNA_JAR com.azul.HelloWorldKt
$JNA_JAR points at your jna-5.14.0.jar (from Maven Central,
net.java.dev.jna:jna:5.14.0). On Linux/Windows drop
-XstartOnFirstThread and use LD_LIBRARY_PATH / PATH.
Alternatively — and recommended — use the Gradle project from the
repository's examples/kotlin/ directory
(build.gradle.kts):
gradle run pulls JNA from Maven Central, compiles Azul.kt +
HelloWorld.kt with daemon caching (the 4 GB compiler heap is preset in
gradle.properties), and wires jna.library.path onto the run task for
you.
You should see the window pictured on the hello-world landing page.
Common errors
UnsatisfiedLinkError— native library not on the JNA library path.- No window on macOS —
-XstartOnFirstThreadmissing. - Counter does not advance — the click handler wrote
Update.DoNothing.value. NullPointerExceptiononoutPtr— the!!unwrap on the SAM's nullablePointer?arg is required; keep it.- Process hangs at exit on Windows — the example builds and runs the whole
headless layout, but the JVM may not terminate afterwards. This is a known
JNA-on-Windows behaviour: the JVM exits only once all non-daemon threads
end and the native event queue is drained, so a native (libazul) thread or
window left on the JVM thread keeps it alive. The binding itself is fine — it
passes the full run on macOS, and the Java binding (same JVM) runs on Windows
— so a fix needs a Windows-host thread dump of the hung JVM. The E2E board
reports Kotlin
⊘ SKIPon Windows for this reason.
Coming Up Next
- Application Architecture — architecting a larger Azul application
- Document Object Model — the Dom tree: node types, hierarchy, and CSS
- Hello World [Java]