- Rust 68.1%
- Kotlin 27%
- Shell 2.2%
- Nix 1.5%
- PowerShell 1%
- Other 0.2%
|
|
||
|---|---|---|
| .githooks | ||
| .woodpecker | ||
| android | ||
| assets | ||
| crates | ||
| installer | ||
| scripts | ||
| testdata/sync-fixtures | ||
| .envrc | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
expans
A ground-up, cross-platform rewrite of the expans whiteboard.
Architecture
expans-core: platform-independent document model, geometry, undo/redo.expans-format: versioned reader/writer for the Android-compatiblecanvas.jsoninside.expansZIP archives.expans-sync: backend-neutral sync decisions and transport contract.expans-desktop: GTK4 Linux application. Stylus input usesGtkGestureStylusand records pressure for every point.expans-render: shared, platform-independent renderer. Tessellation (stroke width/pressure law, grid backgrounds) is always built and host-tested; a wgpu backend behind thegpufeature draws the triangle list to a surface.expans-android: versioned UniFFI bridge overexpans-core/expans-format, compiled to a per-ABI.soand consumed by the Android app.android: native Jetpack Compose application. Document logic and.expansI/O run in Rust through the bridge; Compose hosts the chrome/UI.
The desktop canvas uses GTK's current view background and foreground palette. Dark/light theme changes invalidate the committed-content cache and repaint it with the new canvas and grid colors.
The current desktop canvas is restored from and saved to
$XDG_DATA_HOME/expans/canvases/autosave.expans. Saving happens on window
close so archive compression never runs inside the stylus input path.
The Android app calls expans-core/expans-format directly through the
expans-android UniFFI bridge. The bridge exposes a small, versioned surface
(bridge_version(), an EditorSession for open/save/mutations) so document
logic and .expans I/O are never duplicated in Kotlin.
The canvas is drawn by Rust: Compose hosts a SurfaceView (CanvasSurfaceView)
whose Surface is handed to a hand-written JNI renderer (render_jni.rs). That
renderer turns the Surface into an ANativeWindow, creates a wgpu surface, and
draws the tessellated document from expans-render — so the canvas is
pixel-consistent with desktop and as fast as possible, while toolbars/chrome stay
in Compose. (An Android Surface can't travel through UniFFI, so surface
lifecycle uses manual JNI while the data API stays UniFFI.) The next phase wires
stylus/gesture input through the bridge to drive redraws.
Rendering performance
Committed drawing content is cached separately from the active stroke. Pointer motion therefore renders only the new live stroke over a cached surface instead of replaying the full document for every pen sample.
Elements are indexed into fixed world-space cells. Panning and zooming query only visible cells, while erasing and hit testing query only cells around the pointer. This index is shared logic that a later Android renderer can reuse.
Current desktop tools
- Pressure-sensitive pen and highlighter.
- Line, rectangle, ellipse, and arrow tools with live previews.
- Spatially filtered whole-element eraser.
- Basic selection, delete, undo, redo, and clear.
- Width and preset color controls.
- Native New, Open, Save, and Save As dialogs.
- Embedded PNG display and PDF-folder import.
Archive saves preserve embedded files that the desktop does not understand, so opening and saving an Android canvas does not discard images, PDFs, galleries, or thumbnails.
Development
nix develop
cargo test --workspace
cargo run -p expans-desktop
Building with Nix
The flake builds both clients reproducibly:
nix build .#desktop # GTK4 app (also the default output)
nix build .#android # debug-signed release APK -> result/app-release.apk
nix build .#expans-jni # just the cross-compiled bridge .so (arm64-v8a, x86_64)
.#android cross-compiles the bridge with a fenix toolchain (nixpkgs rustc
lacks the Android target std) and assembles the APK with Gradle. Gradle's
dependencies are pinned in android/deps.json; regenerate that lock after
changing any Android dependency:
$(nix build .#android.mitmCache.updateScript --no-link --print-out-paths)
Android (manual / iterative workflow):
The Rust cross-compile needs the Android target std, which nixpkgs rustc does
not ship, so use a rustup toolchain (one-time host setup):
rustup toolchain install stable
rustup target add aarch64-linux-android x86_64-linux-android
Then enter the Android dev shell (provides JDK 17, the SDK/NDK, cargo-ndk, and
Gradle) and build the per-ABI bridge .so before the app:
nix develop .#android
cargo ndk -t arm64-v8a -t x86_64 \
-o android/app/src/main/jniLibs build -p expans-android --release
cd android
./gradlew :app:assembleDebug # ordinary build, uses the .so above
./gradlew :app:testDebugUnitTest # host JVM round-trip test (loads the bridge)
PDF page rasterization (the shared renderer's PdfFolder support) dlopens
libpdfium.so at runtime, so the manual workflow must also drop the prebuilt
pdfium next to the bridge for the device ABI (the nix build .#android path does
this automatically):
cp "$(nix build --no-link --print-out-paths \
'.#packages.x86_64-linux.expans-jni')/arm64-v8a/libpdfium.so" \
android/app/src/main/jniLibs/arm64-v8a/
Without it the app still runs; importing a PDF just logs "no pages" and inserts nothing. nixpkgs ships no x86_64-Android pdfium prebuilt, so PDF rasterization is unavailable on the x86_64 emulator.
Passing -PbuildRust makes Gradle run cargo-ndk itself as part of the build;
without it, Gradle uses whatever .so already exist under
app/src/main/jniLibs. The generated UniFFI Kotlin bindings live under
app/src/main/java/uniffi/; regenerate them after changing the bridge API with:
cargo run -p expans-android --features cli --bin uniffi-bindgen -- generate \
--library android/app/src/main/jniLibs/x86_64/libexpans_android.so \
--language kotlin --out-dir android/app/src/main/java
Navigation:
- Scroll or two-finger scroll pans the world.
Ctrl+ scroll zooms around the pointer.
Migration order
- ✅ Define a small, versioned bridge (
expans-android) over the stable Rust document model, with optional native packaging (-PbuildRust) kept out of ordinary Gradle builds. - ✅ Stand up a native Compose app that loads a
.expansthrough the bridge. - ✅ Rust wgpu renderer (
expans-render): tessellate strokes/shapes/grid backgrounds and draw them into a Compose-hostedSurfaceViewvia manual JNI. - ◧ Input + editing: one-pointer pen drawing (live + commit) and
two-pointer pan/pinch-zoom feed the renderer over JNI (
CanvasSurfaceView). The renderer and the UniFFIEditorSessionnow share one document (the renderer holds anArc<EditorSession>), so the live element count, undo/redo (Material 3 app-bar actions), andsaveall act on the same canvas. Still to do: stylus tool-type/pressure tuning and palm rejection, tool/color/width UI, autosave, and a multi-canvas open/list screen. - Tablet-class adaptive layouts; share
expans-renderwith desktop to retire duplicated drawing.