No description
  • Scala 55.7%
  • Shell 24.3%
  • Nix 17.4%
  • Python 1.9%
  • Just 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Børge Lundsaunet 44b787ac8c a drag ends where the finger lifted, not where it last moved
ACTION_UP carries a position, and it is not always the position of the
last ACTION_MOVE. Ignoring it threw away the final segment of every
gesture, so a drag stopped fractionally short of where the finger left
the glass.

With a hand this is close to invisible: a finger usually rests for a
moment before lifting, so the last MOVE and the UP are the same point
and nothing is lost. With injected events it is neither invisible nor
intermittent in the way it first appeared — `input swipe` interpolates
its MOVEs across the duration and then reports the endpoint in the UP,
so a full-height sweep always arrived a few pixels short. Enough to miss
the end of the bar, which is exactly the case a test is checking, and
enough to be blamed on the harness. It was ours.

UP now accumulates exactly as MOVE does. The value ends where the finger
ended, which is what it always claimed to do.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-fable-5)

Co-Authored-By: eca-agent <git@eca.dev>
2026-08-14 18:02:10 +02:00
docs docs: where work runs, and how to make it run somewhere else 2026-08-14 14:13:04 +02:00
harness the slider is grabbed and moved, not pointed at 2026-08-14 17:35:56 +02:00
nix android-app.nix: the build four repos were each keeping a copy of 2026-08-13 10:14:06 +02:00
res binky-ui: the shared design system as a source module 2026-08-12 17:19:08 +02:00
scripts Controls.slider, and Level moves into ink.data to make it honest 2026-08-14 16:38:52 +02:00
src/ink a drag ends where the finger lifted, not where it last moved 2026-08-14 18:02:10 +02:00
test the slider becomes one filled shape, and so can reach zero 2026-08-14 17:10:09 +02:00
.envrc the slider becomes one filled shape, and so can reach zero 2026-08-14 17:10:09 +02:00
.gitignore Take in the build pipeline and the JSON primitives 2026-08-12 19:16:47 +02:00
flake.lock Lock nixpkgs 2026-08-12 17:24:06 +02:00
flake.nix Controls.slider, and Level moves into ink.data to make it honest 2026-08-14 16:38:52 +02:00
justfile Controls.slider, and Level moves into ink.data to make it honest 2026-08-14 16:38:52 +02:00
README.md One design doc for the suite 2026-08-12 22:21:17 +02:00
REVIEW.md Controls.slider, and Level moves into ink.data to make it honest 2026-08-14 16:38:52 +02:00

binky the octopus

binky base

The shared foundation of the binky suite (chat, notes, reader, …): the design system every app is built from, and the test harness every app is checked with. Pure black on pure white, no animations — built for the Mudita Kompakt's e-ink panel.

It started as binky-ui, and outgrew the name the moment it acquired things that are not UI.

What's in it

src/ink/ui/Ui.scala        the component factory: boxes (programmatic
                           drawables), labels, buttons, inputs, layout
src/ink/ui/Render.scala    keyed diffing + burst coalescing: the e-ink
                           rule that the cheapest redraw never happens
src/ink/data/Json.scala    tolerant JSON primitives: decoding is total,
                           and longs are written as numbers
res/values/colors.xml      ink_black, ink_white
res/values/themes.xml      Theme.Ink (incl. splash, animations off)
res/drawable/octo_splash.xml
res/drawable-nodpi/octo.png
scripts/build-apk.sh       the APK pipeline, shared by every app
docs/design.md             the rules: how it looks, how it's built —
                           the suite's only design doc
test/JsonTest.scala        laws for ink.data
harness/                   emulator e2e: boot an AVD, drive the real UI

src/ and res/ are compiled into each app. scripts/ and harness/ never ship — they build and drive the APK from outside.

ink.ui is Android code; ink.data must never import Android, because it is compiled both into the APK and into plain-JVM law checks. Anything shared that is not UI gets its own package rather than moving in with ink.ui.

The consumer contract

Code and resources

The pipeline that does this — aapt2 → javac → scalac → d8 → zipalign → apksigner — lives here, in scripts/build-apk.sh. It was identical in all three apps, so each app now keeps only a thin scripts/build-apk.sh saying who it is:

export APP_ROOT="$ROOT" APP_NAME="binky-notes" BASE_DIR
exec bash "$BASE_DIR/scripts/build-apk.sh" "$@"

It expects APP_ROOT/app/{src,res,AndroidManifest.xml} and APP_ROOT/scripts/deps.txt, and consumes the module in two places:

  1. scalac$BASE_DIR/src/**/*.scala joins the source list (app code does import ink.ui.{Render, Ui}, import ink.data.Json).
  2. aapt2$BASE_DIR/res is compiled alongside the app's own res/ and both are linked; shared resources merge into the app's package, so manifests and themes can reference @drawable/octo, @style/Theme.Ink directly. Apps must not redeclare a resource name the module already provides — aapt2 will refuse the duplicate.

Because src/ is compiled into every APK, every consumer's scripts/deps.txt must contain at least the coordinates in this repo's scripts/deps.txt (today: scala3-library and ujson).

BASE_DIR resolution mirrors the hermetic/impure split of the build:

  • checkout builds default to a sibling checkout, ../binky-base — edit here, rebuild there, no commit dance;
  • hermetic builds get the module as a locked flake input:
inputs.binky-base = {
  url = "git+file:///home/blund/prosjekt/binky-base";  # or the forge URL
  flake = false;
};
# … in packages.apk's buildPhase:
export BASE_DIR=${binky-base}

After changing binky-base, consumers pick it up with nix flake update binky-base (checkout builds see it immediately).

The emulator harness

harness/run.sh boots a headless AVD, installs an APK, and runs an app-supplied scenario against it:

bash "$BASE_DIR/harness/run.sh" \
  --apk build/binky-notes.apk \
  --app ink.notes \
  --scenario test/emu/scenario.sh

(--activity defaults to .MainActivity; --keep leaves the emulator running for the next run, --no-clear keeps the app's existing data.) Each app wraps this in scripts/test-emu.sh, nix run .#test-emu and just test-emu.

A scenario is a bash file with the helpers already in scope:

expect_text "no notes yet"     # retries until it appears (or fails with a screenshot)
refute_text "no notes yet"     # assert absent — settles, then insists
tap_text "+ note"              # tap the view whose text contains this
tap_edit 0                     # tap the nth EditText, top to bottom
type_text "milk and bread"     # spaces and punctuation handled
hide_keyboard; back            # keyevents, named
restart_app                    # force-stop + relaunch: the process boundary
serve_dir test/fixtures        # exports FIXTURE_URL=http://10.0.2.2:PORT
step "what happens next"       # a heading in the log
shot "after-restart"           # a numbered screenshot

Assertions retry, actions don't: a UI that hasn't settled yet is normal, but a tap that misses is a bug in the scenario. Every failure leaves a screenshot and the last view dump in build/emu/. Views are matched by parsing the uiautomator XML rather than grepping it, so & and " in the text on screen match the text you wrote.

restart_app is the point of the whole thing. Pure laws cover pure code; the harness covers what only breaks when state crosses a process boundary — persistence, migrations, reinstalls. Both of the codec bugs that cost this suite a day would have died on the first restart_app.

If ANDROID_SERIAL names an already-running device or emulator, the harness uses it and skips the AVD boot — which is the fast path while writing a scenario. It refuses to touch a device it wasn't pointed at.

Checks

nix flake check does two things:

  • compile — builds src/ against android.jar. The module's only build artifact is source, so "it compiles" is half its contract.
  • json-laws — runs test/JsonTest.scala on a plain JVM (just test locally). Shared code gets shared tests: ink.data.Json exists because the same JSON bug ate data in two apps at once, and it lived in the one file no app's tests could reach.

The harness is exercised by the apps that use it.