No description
  • Rust 68.1%
  • Kotlin 27%
  • Shell 2.2%
  • Nix 1.5%
  • PowerShell 1%
  • Other 0.2%
Find a file
mrfluffy 3278b0d5e4
All checks were successful
ci/woodpecker/push/release Pipeline was successful
fix: android logo
2026-07-09 11:05:53 +01:00
.githooks Add Nix flake checks and a UniFFI binding-drift guard 2026-06-23 17:23:58 +01:00
.woodpecker fix: new pipeline method 2026-07-09 09:43:04 +01:00
android fix: android logo 2026-07-09 11:05:53 +01:00
assets new woodpecker stuff 2026-07-01 23:19:02 +01:00
crates fix: lots of stuff I am to lazy to go through 2026-07-08 22:51:26 +01:00
installer new woodpecker stuff 2026-07-01 23:19:02 +01:00
scripts fix: new pipeline method 2026-07-09 09:43:04 +01:00
testdata/sync-fixtures fix: lots of stuff I am to lazy to go through 2026-07-08 22:51:26 +01:00
.envrc feat: init 2026-06-16 01:34:16 +01:00
.gitignore lots of things to lazy 2026-06-29 13:29:02 +01:00
AGENTS.md fix: new pipeline method 2026-07-09 09:43:04 +01:00
Cargo.lock new woodpecker stuff 2026-07-01 23:19:02 +01:00
Cargo.toml lots of things to lazy 2026-06-29 13:29:02 +01:00
flake.lock Add Nix flake checks and a UniFFI binding-drift guard 2026-06-23 17:23:58 +01:00
flake.nix fix: allow wine to use inno in headless mode 2026-07-02 15:55:50 +01:00
LICENSE feat: licens bitch 2026-06-22 17:15:42 +01:00
README.md Import and display PDFs via the shared renderer 2026-06-23 20:27:51 +01:00

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-compatible canvas.json inside .expans ZIP archives.
  • expans-sync: backend-neutral sync decisions and transport contract.
  • expans-desktop: GTK4 Linux application. Stylus input uses GtkGestureStylus and 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 the gpu feature draws the triangle list to a surface.
  • expans-android: versioned UniFFI bridge over expans-core/expans-format, compiled to a per-ABI .so and consumed by the Android app.
  • android: native Jetpack Compose application. Document logic and .expans I/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

  1. Define a small, versioned bridge (expans-android) over the stable Rust document model, with optional native packaging (-PbuildRust) kept out of ordinary Gradle builds.
  2. Stand up a native Compose app that loads a .expans through the bridge.
  3. Rust wgpu renderer (expans-render): tessellate strokes/shapes/grid backgrounds and draw them into a Compose-hosted SurfaceView via manual JNI.
  4. Input + editing: one-pointer pen drawing (live + commit) and two-pointer pan/pinch-zoom feed the renderer over JNI (CanvasSurfaceView). The renderer and the UniFFI EditorSession now share one document (the renderer holds an Arc<EditorSession>), so the live element count, undo/redo (Material 3 app-bar actions), and save all 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.
  5. Tablet-class adaptive layouts; share expans-render with desktop to retire duplicated drawing.