- Scala 81.1%
- Swift 11.1%
- C 4.3%
- Shell 3%
- Kotlin 0.3%
- Other 0.2%
munit is gone from the build and from every source. `munit-scalacheck` went with it, because property testing comes with zio-test rather than as a second dependency. | Target | Tests | |---|---| | `signalsJVM` / `signalsNative` | 21 | | `signalsJS` | 19 — no `ThreadGuardSpec`, JS has no threads | | `coreJVM` / `coreJS` / `coreNative` | 73 | | `effectZioJVM` | 15 | | `shimGen` | 10 | Core's 73 is exactly the 73 that existed before, so nothing was quietly dropped in the conversion. ## What the migration found The reason to read this PR rather than skim it: three latent problems fell out, none of them guessed at. **1. The signal graph's tracking context is two process-global `var`s.** zio-test runs a suite's tests in parallel by default; munit does not. That broke the graph on the very first run — `NullPointerException` on `Runtime.collected`, `ConcurrentModificationException` inside `relink`. This is not a bug: the graph is owned by exactly one thread, which `ThreadGuard` enforces in a running app, and the globals are deliberate because an `Option` allocation per tracked read is measurable against the 1 µs/node budget. But **nothing in the test suite said so**, and munit had been hiding it behind `parallelExecution := false`. Every graph-touching spec now carries `@@ TestAspect.sequential` with the reason written down. **2. `TestAspect.sequential` orders tests *within* a spec and promises nothing *between* specs**, which zio-test runs concurrently. `BridgeSpec` and `RemoteScreenSpec` both install `UiThread` and `ThreadGuard`; they raced, and `RemoteScreen`'s error test timed out waiting for a state the other suite's fibres were competing for. Suites sharing process-global state **cannot be separate specs** — they are now one, `EffectZioSpec`. **3. Phase 2 shipped a test that only ever passed on the JVM.** Scala.js has no int/double distinction, so `7.0.toString` is `"7"` there and `"7.0"` on the JVM. `CatalogueSuite` compared *rendered strings* for `Prop.Value`, `Prop.Range` and `Prop.Progress` — and had never been run on JS or Native, where it fails. `TestRenderer` now keeps numeric props in a separate `nums: Map[String, Double]` instead of stringifying them. That third one is a process failure as much as a bug: I had only ever run `coreJVM/testOnly`. The rule is now written down in `docs/12` §12.8 — **running one backend is not running the tests.** ## Mechanics worth a look in review - **`Checks`** accumulates assertions made *during* a test. These tests drive a mutable graph and check it after each step, because the interesting property is usually *how many times* something happened between two writes, which the end state cannot show. Restructuring every test around a single final assertion would have discarded exactly that. - munit's `beforeEach`/`afterEach` became `TestAspect.before`/`after`, which has the advantage of being visible at the suite's declaration rather than buried in an override. - **shim-gen has no `signals` dependency**, so rather than duplicate `Checks`, its two specs collect every mismatch and assert once. That is better regardless: a drifting ABI usually drifts in several places, and a check that stops at the first makes you run it N times to learn how bad it is. - `test` is ambiguous under both `import zio.*` (the *package* `zio.test`) and `import zio.test.*` (the *method*). Scala 3 treats the wildcards as equal precedence whichever order they appear in, so `RemoteScreenTests` qualifies its call sites. - zio-test-sbt for Native is built against scala-native `test-interface` 0.5.10 against this build's 0.5.12. Binary compatible across 0.5.x patches, so one narrow `libraryDependencySchemes` entry says so rather than disabling eviction errors build-wide. ## Also in this branch - `docs/12` §12.8 documents how tests are run and the three rules above. - `decisions.md` gains two entries: never stringify a number in a cross-built test, and **sbt 2's disk cache does not notice a deleted test source** — renaming a suite left `nativeLink` failing on the old name through `clean`, through deleting `target/out`, and through touching sources. Clearing `~/.cache/sbt/v2` *and* `target/out/<platform>` is what fixes it. That cost me twenty minutes twice. - Both `CLAUDE.md` files gain the standing rule that I never merge a PR. Unrelated to the migration, and swept into the same commit — say the word if you would rather it were split out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #12 |
||
|---|---|---|
| docs | ||
| examples | ||
| modules | ||
| project | ||
| spikes | ||
| tools/shim-gen | ||
| .gitignore | ||
| .scalafmt.conf | ||
| build.sbt | ||
| CLAUDE.md | ||
| LICENSE | ||
| README.md | ||
scala-ui
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
Why
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.
scala-ui 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.
Status — read this before you start
| Works today | Linux/GTK4, Android, macOS, iOS simulator — the same app on all four |
| Widgets | 9 of the 32 in the v1 catalogue → docs/12 |
| Published artefacts | None yet. You build from this repository |
| API stability | None. Expect breaking changes without notice |
| Windows | Not started, and out of the 0.1 scope |
This is early. It is real software with measured numbers, not a prototype — but you cannot
libraryDependencies += it yet, and the catalogue is a quarter complete. If you need a
Slider today, it isn't here.
Getting started
Prerequisites
| For | You need |
|---|---|
| Everything | JDK 21+, sbt 2 (cs install sbt), coursier |
| Linux/GTK4 | libgtk-4-dev, libunwind-dev, clang |
| Android | Android SDK (platform 36 + build-tools), an emulator or device |
| macOS / iOS | Xcode, on a Mac — see spikes/MAC-SETUP.md |
Scala Native needs clang, libunwind-dev and GTK's development headers; on Ubuntu:
sudo apt install clang libunwind-dev libgtk-4-dev
Run the demo in one command
git clone ssh://forgejo@forgejo.leibmanland.com/rleibman/scala-ui.git
cd scala-ui
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
Your first app
This is examples/counter-gtk/src/main/scala/example/Counter.scala in full — the smallest
complete scala-ui app, and it compiles:
package example
import scalaui.core.AppRoot
import scalaui.core.dsl.*
import scalaui.renderer.gtk.GtkApp
import scalaui.signals.Var
object Counter {
def main(args: Array[String]): Unit = {
val _ = GtkApp.run("dev.scalaui.counter", 380, 220) {
val count = Var(0)
AppRoot(
"scala-ui 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.
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.
The three concepts
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.
Theming
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.
Running the same app on another platform
The app is a function; each host mounts it.
// Linux
GtkApp.run("dev.example.app", 460, 440) { MyApp(model) }
// Android, in onCreate
val mounted = Reconciler.mount(AndroidRenderer(this), app.element)
setContentView(mounted.handle)
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.
What it measured
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 | 154 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 |
Project layout
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
renderer-gtk/ GTK4 (Scala Native)
renderer-android/ android.view (Scala on ART)
renderer-apple/ AppKit + UIKit (Scala Native + Swift shim)
examples/
shared/ TodoApp — the platform-free demo every host mounts
counter-gtk/ todo-android/ todo-apple/
docs/ the plan, the findings, the living status
spikes/ phase 0, one directory and REPORT.md per spike
Writing a renderer means implementing one trait — create, update, insertAfter,
removeChild, destroy, measure. It has absorbed four structurally different toolkits
without changing: modules/renderer-api.
Development
sbt --error "signalsJVM/testOnly *; coreJVM/testOnly *; effectZioJVM/testOnly *" # 98 tests
SCALAUI_SELFTEST=1 ./target/out/native0.5/scala-3.9.0/counter-gtk/counter-gtk # GTK self-test
adb shell am start -n dev.scalaui.todo/example.android.MainActivity --ez selftest true
Three things that will bite you:
sbt testis incremental on sbt 2 and will run zero tests and report success. AlwaystestOnly *.- The Apple modules are not in the root aggregate, on purpose, so Linux and Android builds are unaffected by them. Build them on a Mac.
- 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.
Roadmap
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.
Documents
| # | 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 |
| 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 10–13 and decisions.md are living. Docs 01–09 are frozen background; facts marked
[unverified] there are claims a spike had not yet settled.
Contributing
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.
Licence
Apache-2.0. See LICENSE.