No description
  • Scala 81.1%
  • Swift 11.1%
  • C 4.3%
  • Shell 3%
  • Kotlin 0.3%
  • Other 0.2%
Find a file
Roberto Leibman 857cdaac38 Replace munit with zio-test (#12)
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
2026-09-29 14:44:47 -07:00
docs Replace munit with zio-test (#12) 2026-09-29 14:44:47 -07:00
examples Apple: honour Prop.Axis, and fix the circular width it exposed (#11) 2026-09-29 13:17:00 -07:00
modules Replace munit with zio-test (#12) 2026-09-29 14:44:47 -07:00
project M0: first working stack — signals, contract, reconciler, GTK renderer 2026-09-19 23:02:37 -07:00
spikes S9: the ZIO bridge works on iOS — and a handle table must not use synchronized 2026-09-23 06:54:27 -07:00
tools/shim-gen Replace munit with zio-test (#12) 2026-09-29 14:44:47 -07:00
.gitignore Android renderer: one UI, two platforms 2026-09-20 10:42:20 -07:00
.scalafmt.conf Switch to brace syntax everywhere, enforced by -no-indent 2026-09-21 14:03:20 -07:00
build.sbt Replace munit with zio-test (#12) 2026-09-29 14:44:47 -07:00
CLAUDE.md Replace munit with zio-test (#12) 2026-09-29 14:44:47 -07:00
LICENSE A real README, an actual LICENSE, and docs/13: phases as branches and PRs 2026-09-28 22:04:50 -07:00
README.md A real README, an actual LICENSE, and docs/13: phases as branches and PRs 2026-09-28 22:04:50 -07:00

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

The demo app on Android


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 test is incremental on sbt 2 and will run zero tests and report success. Always testOnly *.
  • 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-indent is 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:

  1. Read docs/12 so you are not duplicating work, and docs/decisions.md for the binding choices.
  2. A widget is done only when every existing renderer implements it and a test exercises it. "Compiles" is not done.
  3. 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.