Native thick-client UIs in Scala 3. One Element tree, real platform widgets —
android.view.* on Android, UIKit/AppKit on iOS and macOS, GTK4 on Linux.
Scala 3.9 · sbt 2.0.9 · Scala Native 0.5.12 · Apache-2.0 · pre-release, not on Maven Central yet
Scala covers the server (JVM), the browser (Laminar, Tyrian) and the command line (Scala Native). It has no answer for the thick client — no way to ship a Scala app to a phone or a desktop that looks and feels like it belongs there. Kotlin has Compose Multiplatform, Dart has Flutter, JS has React Native, C# has MAUI, Rust has Tauri. Scala has nothing, and that gap keeps it out of an entire class of products.
Thicket does not draw its own widgets. Every leaf of the tree is a real GtkButton,
android.widget.Button or UIButton, so platform fidelity is true by construction and
accessibility comes with the control rather than being retrofitted onto a canvas.
| Works today | Linux/GTK4, Android, macOS, iOS simulator — the same app on all four |
| Widgets | 26 of 32 as widgets, all 26 measured on every renderer (TabView reached AppKit and UIKit in #69; RadioGroup and Stepper, built as §12.2a exceptions, in #150: radio NSButtons and an iOS checkmark list, NSStepper and UIStepper). 32 of 32 covered, counting the six the framework provides another way (SafeArea and Link as props, List as LazyColumn, NavigationStack as Nav, Menu as a context-menu prop, Toolbar as screen actions) → docs/12 |
| Navigation | Native container on all four: AdwNavigationView, a FrameLayout + Slide, UINavigationController, a macOS sidebar. Back gesture and title bar are the platform's own |
| Theming | By role, scoped to a subtree with Provide |
| Published artefacts | net.leibman, tagged v0.1.0 — but not on Maven Central yet, so publishLocal is the only route. docs/14 |
| API stability | None. Expect breaking changes without notice |
| Windows | Not started, and out of the 0.1 scope |
This is early, and the honest shape of it is: the parts that exist are finished on all four platforms rather than sketched on one. Twenty-six widgets on every renderer, with a self-test on each host that reads the tree back out of the real toolkit. Where a prop is handled but not yet honoured on a platform, docs/12 §12.4 says which.
What is genuinely missing: nothing from the v1 catalogue — its last two entries, Radio and
Stepper, are built as §12.2a exceptions (#150) — and Maven Central publishing. An app can be built from
outside this repo on GTK, macOS and the iOS simulator — bin/verify-getting-started.sh and
bin/verify-getting-started-apple.sh check exactly that, by building a template outside the
repo against published artefacts and running it.
| For | You need |
|---|---|
| Everything | JDK 21+, sbt 2 (cs install sbt), coursier |
| Linux desktop | the apt packages below |
| Android | Android SDK (platform 36 + build-tools), an emulator or device |
| macOS / iOS | Xcode, on a Mac — see spikes/MAC-SETUP.md |
sudo apt install clang libunwind-dev pkg-config libgtk-4-dev libadwaita-1-dev librsvg2-common
| Package | Needed for | Verified against |
|---|---|---|
clang |
Scala Native compiles and links through it | 21.1.6 |
libunwind-dev |
Scala Native's unwinder — the runtime will not link without it | 1.8.3 |
pkg-config |
the build runs pkg-config --cflags gtk4 libadwaita-1 and the matching --libs to get the compile and link flags, so a missing pkg-config fails the build, not just the link — and says which packages to install |
2.5.1 |
libgtk-4-dev |
the GTK4 renderer | 4.22.4 |
libadwaita-1-dev |
AdwNavigationView, for native navigation containers. GTK core has no equivalent — GtkStack gives transitions but no back-gesture semantics |
1.9.1 |
libidn2-dev |
only for HTTP: sttp-model links libidn2 for host names (thicket-http-gtk loads libcurl itself at run time) |
2.3.8 |
librsvg2-common |
gdk-pixbuf's SVG loader: GTK draws an Image from an SVG file or SVG bytes through it, as the gallery and todo apps do with the logo. Usually already present with a desktop |
2.61.3 |
python3 |
bin/coverage.sh reads the scoverage report with it |
any 3.x |
The -dev packages are the point: the runtime libraries alone are not enough, because
Scala Native compiles against the C headers.
Everything else is fetched by coursier and needs nothing installed. bin/logo-assets.sh,
which regenerates the logo's PNGs and app SVG from thicket-logo.svg, also needs Inkscape 1.x.
git clone git@github.com:rleibman/thicket.git
cd thicket
sbt --error counterGtk/nativeLink
./target/out/native0.5/scala-3.9.0/counter-gtk/counter-gtk
That is the Todo demo: a bound text field, a checkbox, keyed list rows, navigation, and a
10 000-row virtualised list. The first nativeLink takes a few minutes; after that it is
seconds.
For Android, with an emulator running:
./examples/todo-android/build.sh # builds the Scala, hands the JAR to Gradle, installs, launches
sbt --error galleryGtk/nativeLink
./target/out/native0.5/scala-3.9.0/gallery-gtk/gallery-gtk
examples/gallery is one screen holding every component the framework has, built for all
four platforms. It is a conformance surface rather than a showcase: TestRenderer answers
for every widget, so a widget that renders on GTK and does nothing at all on Android would
pass the whole unit-test suite. Only a screen that uses everything, rendered by each real
toolkit, catches that — which is why adding a component without adding it here is treated as
an incomplete change (docs/12 §12.10).
THICKET_GALLERY_SELFTEST=1 makes it read the tree back out of the toolkit and print
pass/fail per component.
This is examples/counter-gtk/src/main/scala/example/Counter.scala in full — the smallest
complete Thicket app, and it compiles:
package example
import thicket.core.AppRoot
import thicket.core.dsl.*
import thicket.renderer.gtk.GtkApp
import thicket.signals.Var
object Counter {
def main(args: Array[String]): Unit = {
val _ = GtkApp.run("dev.thicket.counter", 380, 220) {
val count = Var(0)
AppRoot(
"Thicket counter",
Column(spacing = 16, padding = 24)(
Label(count.map(n => s"Count: $n")),
Row(spacing = 8)(
Button("−")(count.update(_ - 1)),
Button("+")(count.update(_ + 1)),
Button("Reset")(count.set(0))
)
)
)
}
}
}
Point counterGtk's Compile / mainClass at example.Counter in build.sbt, then
nativeLink and run it as above.
templates/hello-thicket is a complete standalone sbt project — copy it anywhere:
cp -r templates/hello-thicket ~/my-app && cd ~/my-app
sbt nativeLink && ./target/out/native0.5/scala-3.9.0/hello-thicket/hello-thicket
It depends on published artefacts rather than on this repo. Until a release is on Maven Central you need a local publish first:
cd /path/to/thicket && sbt 'signalsNative/publishLocal; rendererApiNative/publishLocal; coreNative/publishLocal; rendererGtk/publishLocal; sbtThicket/publishLocal'
The whole of the build configuration is one plugin:
// project/plugins.sbt
addSbtPlugin("net.leibman" % "sbt-thicket" % thicketVersion)
// build.sbt
lazy val app = project.in(file(".")).enablePlugins(ThicketGtkPlugin)
ThicketGtkPlugin brings sbt-scala-native, adds thicket-core and thicket-renderer-gtk
at its own version, and sets the GTK nativeConfig — the pkg-config compile and link
flags, LTO.none, Mode.debug, GC.immix. Before it existed an app copied about a dozen
lines of that out of Thicket's own build.sbt, and got a link failure with nothing pointing
at the cause if it got the GC wrong. The flags are not a second copy: build.sbt and the
plugin compile the same source file. See
tools/sbt-thicket/README.md.
If you would rather name the artefacts yourself, the suffixes are explicit because sbt 2
has no %%%:
libraryDependencies ++= Seq(
"net.leibman" % "thicket-core_native0.5_3" % thicketVersion,
"net.leibman" % "thicket-renderer-gtk_native0.5_3" % thicketVersion
)
On Apple the plugin also carries the Swift shim: thicket-renderer-apple's jar contains
libthicketapple.a for macOS and the iOS simulator, and the plugin unpacks it from the
resolved jar, so nothing has to be cloned or built (#41).
- macOS —
enablePlugins(ThicketMacPlugin). A plain binary, like GTK's: templates/hello-thicket-macos. - iOS simulator —
enablePlugins(ThicketIosPlugin)builds the Scala half as a static library; iOS needs a Swift host that ownsmain, so the template is a directory with one: templates/hello-thicket-ios, run withios-app/build-app.sh.
./bin/verify-getting-started-apple.sh checks both on a Mac.
./bin/verify-getting-started.sh checks all of the above still works, by building the
template outside the repo against a fresh local publish. See
docs/14-releasing.md for versions and the compatibility policy.
What is going on. count is a Var — a signal. count.map(...) is a derived view, not
a subscription, so it needs no lifetime management. Label takes either a String or a
Signal[String]; given a signal, a change re-renders that one label and nothing else. There
is no setState, no virtual-DOM diff of the whole app, and no effect type in sight.
1. Signals. Fine-grained reactivity — push-mark, pull-validate, glitch-free, with an equality cutoff. A change touches only the widgets that actually read it.
val items = Var(List.empty[Item])
val done = items.map(_.count(_.done)) // a derived view: no Owner, no disposal
val label = Signal.computed(s"${done()} of ${items().size} done") // memoised, scoped
2. Elements. A description of the UI, not the UI itself. Structure that changes over time is explicit, so the reconciler knows what to do with it:
Show(model.isEmpty)(Label("Nothing left to do.")) // conditional subtree
Switch(route)(r => screenFor(r)) // one of N
ForEach(items, key = (i: Item) => i.id) { item => ... } // keyed; reorders in place
LazyColumn(items, key = (i: Item) => i.id) { item => ... } // virtualised: only visible rows exist
3. Effects at the edges. The core is synchronous and effect-free. ZIO arrives through a
bridge, so a ZIO service layer drives the UI without the widget API knowing about F[_]:
val user: Signal[RemoteData[Throwable, User]] = fetchUser(id).asSignal
Switch(user) {
case RemoteData.Loading => Label("Loading…")
case RemoteData.Failed(e) => Label(s"Failed: ${e.getMessage}")
case RemoteData.Done(u) => Label(u.name)
}
A cats-effect bridge is planned; the core needs no change to accept one.
Name a role, not a colour. Roles map to each platform's own tokens, so dark mode and the user's accessibility contrast settings keep working:
Theme.install(Theme.platform.withColor(ColorRole.Accent, Rgb(0x2E, 0x6F, 0x40)))
There is deliberately no way to say "grey #767676". A theme that pushes its own palette at every platform is how cross-platform apps come to look like none of them.
The app is a function; each host mounts it.
// Linux
GtkApp.run("dev.example.app", 460, 440) { MyApp(model) }
// Android: extend ThicketActivity, which mounts the pages and applies title, Up, menu and Back
class MainActivity extends ThicketActivity {
protected def createApp(saved: Option[Bundle])(using Owner) = TodoApp(model)
}
examples/shared/TodoApp.scala knows nothing about any platform; counter-gtk,
todo-android and todo-apple each mount it in a handful of lines. That is the whole
porting story.
Not asserted — measured, with the method recorded in docs/10 and the spike reports.
| App size (iOS hello app) | 0.53 MB vs React Native 26.0 MB, Gluon 60.1 MB |
| Cold start (iOS simulator) | 425 ms vs React Native 702 ms |
| Android cold start | 388 ms, against hand-written Kotlin's 418 ms |
| Android release APK | 165 KB |
| Signal update | 77 / 219 / 419 ns per node (JVM / Native / JS), budget 1 000 ns |
| Mount 10 000 rows | ~56 ms; single-row update ~1.5 ms |
| 10 000-row list | ~66 live views on Android, ~205 on GTK |
modules/
signals/ fine-grained reactivity (JVM / JS / Native)
renderer-api/ the renderer contract — one small, versioned interface
core/ element tree, reconciler, navigation, theming
effect-zio/ the ZIO bridge
storage/ app directories and preferences; storage-gtk/, storage-android/, storage-apple/
sqlite/ SQLite through plain JDBC; sqlite-android/ (a JDBC driver), Native via a library
secrets/ tokens and passwords in the platform's secure store; secrets-gtk/, secrets-android/, secrets-apple/
device/ the app's lifecycle and permissions; device-gtk/, device-android/
location/ the device's position; location-android/ (GTK: Location.unavailable)
motion/ acceleration, rotation rate, attitude; motion-android/, motion-ios/ (GTK, macOS: Motion.unavailable)
clipboard/ text on the system clipboard; clipboard-gtk/, clipboard-android/, clipboard-apple/
haptics/ haptic taps; haptics-android/, haptics-apple/ (GTK: Haptics.none)
pickers/ photo and document pickers, save dialog; pickers-gtk/, pickers-android/
camera/ taking a photo with the camera app; camera-android/, camera-apple/ (GTK, macOS: Camera.unavailable)
share/ the share sheet; share-android/, share-apple/ (GTK: Share.unavailable)
camera-preview/ a live camera preview in the app's layout; camera-preview-android/ (GTK: CameraPreview.unavailable)
renderer-gtk/ GTK4 (Scala Native)
renderer-android/ android.view (Scala on ART)
renderer-apple/ AppKit + UIKit (Scala Native + Swift shim)
http-apple/ an sttp backend over URLSession, for iOS (no libcurl) and macOS
http-gtk/ an sttp backend over libcurl, for GTK (in place of sttp's CurlBackend)
http-android/ sttp's HttpURLConnectionBackend, with repeated headers and cancellation
examples/
shared/ TodoApp — the platform-free demo every host mounts
counter-gtk/ the todo app on GTK (the name predates it)
todo-android/ todo-apple/
gallery/ every component on one screen, for every platform, with self-tests
http/ the same HTTP checks against a local server for every platform's backend
(Linux and Apple hosts here; Android's run in the gallery's self-test)
tools/ shim-gen (the Apple ABI), sbt-thicket (the published plugin)
bin/ checks: GTK self-tests, getting-started, coverage, the Mac's verify-apple
docs/ the plan, the findings, the living status, phase reviews
spikes/ phase 0's briefs and reports (the code is at commit 8eccd90)
Writing a renderer means implementing one trait, Renderer: creating, updating, inserting,
moving, removing and destroying widgets, presenting the ones that float (Alert, Sheet),
a virtualising list, posting to the UI thread, and a layout half that no renderer uses yet.
It has absorbed four structurally different toolkits; when it grows, every renderer breaks
until it is handled, which is the point:
modules/renderer-api.
sbt --error checkLinux # every test on every backend, format, headers, every renderer and example compiled
bin/gtk-selftests.sh # both GTK self-tests; fails on a failed check or any Gtk-CRITICAL
SELFTEST=1 examples/gallery/android/build.sh # the gallery on an Android device or emulator, HTTP checks included
sbt --error httpLinux/nativeLink && examples/http/selftest.sh linux # the HTTP checks over thicket-http-gtk
SELFTEST=1 examples/todo-android/build.sh
checkLinux is what CI runs (.github/workflows/ci.yml), with the GTK self-tests under
xvfb; the Apple self-tests run on a self-hosted Mac (apple.yml). Counts as of 2026-11-02:
signals 30, core 176, effect-zio 25, storage 14, sqlite 2 and secrets 2 per backend,
storage-gtk 4, storage-apple 4, secrets-gtk 2, http-gtk 5, http-android 5, device 8,
location 7, motion 5, clipboard 1, haptics 2, pickers 4, camera 2, share 5 and camera-preview 2 per backend, shim-gen 15,
sbt-thicket 9; GTK 148 + 69 self-test checks, Android 102 + 84 (2026-11-03, the gallery's with
the HTTP checks, every device section's, the Platform section's and the Camera preview's),
the HTTP checks on Linux 12.
Coverage over the JVM-tested modules:
./bin/coverage.sh # 94.04% statement, 88.22% branch on 2026-10-21; exits non-zero below the ratchet
It forces a real compile on every run, without touching the machine-wide sbt cache, and it
fails if any module's measurements went missing — see docs/12 §12.9. The figure covers the
effect-free core; the three renderers are exercised by self-tests that drive the real
toolkit, which no coverage tool instruments.
Three things that will bite you:
sbt testis incremental on sbt 2 and will run zero tests and report success. AlwaystestOnly *.- The renderers and examples are not in the root aggregate, so
sbt testFullworks on a fresh clone. They are aggregated byplatformsinstead:sbt platforms/Test/compiletype-checks all of them on Linux, the Apple apps included (runmodules/renderer-android/setup.shfirst). RootscalafmtAllandheaderCheckAllreach intoplatformstoo.sbt checkLinuxis what CI runs. Linking an Apple app needs a Mac. - The heap is set in
.jvmopts(4G). At sbt's default 1G,testFullruns out of memory compiling the JS and Native tests in parallel. - Braces, not significant indentation.
-no-indentis on; the compiler rejects it.
Apple work is done on a macOS laptop and raised as issues rather than attempted elsewhere.
Phase 0 is complete and returned GO (docs/10). The work is now phased towards a 0.1, one branch and one PR per phase — docs/13-phases.md.
0.1 is defined as a claim that can be falsified rather than a feature count: an outside developer can build and ship a real app for Android, iOS and one desktop without reading the framework's source.
| # | File | Contents |
|---|---|---|
| 12 | docs/12-component-status.md | Start here. Done vs left, per widget and per renderer |
| 13 | docs/13-phases.md | Phases, branches, PRs, exit criteria |
| 14 | docs/14-releasing.md | Versions from tags, early-semver, what a consumer needs, how to cut a release |
| 15 | docs/15-icon-system.md | The icon system: the design, and where what was built differs |
| 16 | docs/16-device-services.md | Device services — location, motion, camera, files, storage, HTTP: what Thicket provides, what libraries cover, the plan |
| 11 | docs/11-what-it-looks-like.md | The design ideas, and the gap each one exposed |
| 10 | docs/10-phase-0-findings.md | Every phase-0 measurement and the GO decision |
| — | docs/decisions.md | Versions, tooling, the dated decision log |
| — | docs/screenshots/ | What it looks like, read critically |
| 1–9 | docs/ | The original plan: vision, landscape, requirements, architecture options, technical design, roadmap, open questions |
Docs 11–15 and every README are living; decisions.md, 09 and 10 take dated additions;
01–08 are frozen background, and facts marked [unverified] there are claims a spike had
not yet settled (decision log, 2026-10-20). Whole-repository reviews are in
docs/phase-reviews/.
Early, and the most useful contribution is a widget or a renderer. Before starting:
- Read docs/12 so you are not duplicating work, and docs/decisions.md for the binding choices.
- A widget is done only when every existing renderer implements it and a test exercises it. "Compiles" is not done.
- Measure. Every claim in these docs is a number with a method; a PR that says "works" without one will come back.
Apache-2.0. See LICENSE.
