diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index 65cbae8..06f77cb 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -1630,6 +1630,26 @@ export fn tally_session_load(?*Session, ?[*]const u8, usize) Status; leaving the session as it was, and an allocation-failure sweep over save and load. ABI version 5. +### 6.8 Programmer mode from a frontend that holds the value + +**Built, for the programmer screen (9.3), ABI version 6.** Two additions, both because +the screen keeps a register rather than a text answer: + +- **`pattern` in the programmer result:** the value masked to the width, as lowercase + hex with no prefix or grouping (`"pattern":"ff"`). The rows are for reading and their + format is free to change; this is for holding. Unsigned at the width, so a negative + 8-bit result is `f0`, never sign-extended past it. Before it, the only way to learn + the value was to parse the binary row. +- **`configureProgrammer(handle, bits, signed, bigEndian)` in JNI:** read the session's + configuration, change the three programmer fields, configure. The display budget is + untouched, as `configureDisplay` leaves the programmer fields untouched. The C ABI + already had this through `tally_session_configure`; only JNI lacked it. + +Tested in `c_api` (masking, a negative at 8 bits, zero, all 128 bits of -1), the C ABI +check through the header, JNI against the synthetic table (each of the three settings +visible in one result, a nonzero `jboolean` as true, refused widths changing nothing), +`jvm-test` (an int and two booleans through a real JVM), and an instrumented test. + ## 7. CLI Design @@ -2204,9 +2224,10 @@ need. ## 9. Android UI Design -The shell (9.1) and every screen but standard (9.3-9.7) are still DESIGN ONLY. 9.2 was -rewritten after the first build ran on a device, which is the input the rest of this -section was waiting for. +The shell (9.1), standard (9.2), programmer (9.3) and convert (9.6) are built; struct +layout, financial and tablet (9.4, 9.5, 9.7) are still DESIGN ONLY. 9.2 was rewritten +after the first build ran on a device, which is the input the rest of this section was +waiting for. That first build was a text field, the system keyboard, an `=` button and a monospace scrollback: the TUI's prompt moved onto glass, the exact paradigm the paragraph below @@ -2242,8 +2263,9 @@ The Android app uses a fundamentally different interaction model from the TUI. W ### 9.1 Navigation & Shell -**Built: a hamburger drawer, as in NCalc.** Calculator, Convert, and Settings below a -divider, under the app name and the engine version (which crossed JNI to be there). +**Built: a hamburger drawer, as in NCalc.** Calculator, Programmer, Convert, and +Settings below a divider, under the app name and the engine version (which crossed JNI +to be there). Opened by the hamburger only; an edge swipe would fight the system back gesture and the keys nearest the edge. Each screen also has a one-tap link to the other in its top bar, because the drawer is two taps and switching between calculating and converting is the @@ -2256,8 +2278,9 @@ not the system's dynamic colours: those were tried and, on the test device, gave keypad's accent bands light containers in dark mode. The original plan, kept for the record: a bottom navigation bar with 4 destinations, -**Standard**, **Programmer**, **Financial**, **Convert**. Programmer and Financial are -not built, and will be drawer entries when they are. +**Standard**, **Programmer**, **Financial**, **Convert**. Financial is not built, and +will be a drawer entry when it is. Programmer has no top-bar link: calculating and +converting are the common move between screens, and the drawer covers the rest. ``` ┌─────────────────────────────────────────┐ @@ -2355,53 +2378,143 @@ on a physical device. ### 9.3 Programmer Mode Screen -Split into three vertical zones: **value display**, **bit grid**, and **input buttons**. +**Built (first version), for reaction on a device.** Replaces a wireframe that predated +the TUI's programmer mode and disagreed with it on width, hex format, signedness and +editing. This one starts from what the TUI actually does (8.2, 8.2.1), the standard +screen's pad-and-preview model (9.2), and the principles at the top of section 9. ``` -┌─────────────────────────────────────────┐ -│ ┌─── Bit Width ────────────────────┐ │ -│ │ [ 8 ] [ 16 ] [ 32 ] [●64] │ │ ← segmented control -│ └──────────────────────────────────┘ │ -│ │ -│ ┌─── Value Display ────────────────┐ │ -│ │ DEC 4,294,967,295 │ │ ← tappable to switch primary -│ │ HEX 0xFFFF_FFFF │ │ -│ │ OCT 0o37_777_777_777 │ │ -│ │ BIN 1111 1111 1111 1111 11.. │ │ -│ │ SIGN -1 (signed) │ │ -│ └──────────────────────────────────┘ │ -│ │ -│ ┌─── Bit Grid ─────────────────────┐ │ -│ │ 31 30 29 28 │ 27 26 25 24 │ │ ← bit index labels -│ │ 1 1 1 1 │ 1 1 1 1 │ │ ← tappable cells -│ │ 23 22 21 20 │ 19 18 17 16 │ │ -│ │ 1 1 1 1 │ 1 1 1 1 │ │ -│ │ 15 14 13 12 │ 11 10 9 8 │ │ -│ │ 1 1 1 1 │ 1 1 1 1 │ │ -│ │ 7 6 5 4 │ 3 2 1 0 │ │ -│ │ 1 1 1 1 │ 1 1 1 1 │ │ -│ └──────────────────────────────────┘ │ -│ │ -│ ┌─── Operators ────────────────────┐ │ -│ │ AND│ OR │XOR │NOT │ << │ >> │ROL │ │ ← bitwise op buttons -│ ├────┼────┼────┼────┼────┼────┼────┤ │ -│ │ A │ B │ C │ D │ E │ F │ │ │ ← hex input -│ ├────┼────┼────┼────┼────┼────┤ │ │ -│ │ 7 │ 8 │ 9 │ 0x│ 0b│CLR │ │ │ ← digits + base prefix -│ ├────┼────┼────┼────┼────┼────┤ │ │ -│ │ 4 │ 5 │ 6 │ + │ - │ = │ │ │ -│ ├────┼────┼────┼────┼────┼────┤ │ │ -│ │ 1 │ 2 │ 3 │ × │ ÷ │ ⌫ │ │ │ -│ └────┴────┴────┴────┴────┴────┘ │ │ -└─────────────────────────────────────────┘ ++-----------------------------------------+ +| = Programmer | +| [8][16][32][64][128] signed BE | <- settings strip, fixed positions +|-----------------------------------------| +| 0xDEAD << 4 | 3| | <- expression, cursor, no keyboard +| = 0xDEAD3 (dim) | <- live preview, in the input base +| DEC(s) 912,083 | +| DEC(u) 912,083 | <- every row is the shown value +| [HEX] 00 00 00 00 00 0D EA D3 | <- the input base, highlighted in place +| OCT 0 000 000 000 000 003 365 323 | +| ASCII . . . . . . . . | +| BIN (warning, if any) | +| 63 0000 0000 0000 0000 | <- bit grid: 16 per row, bit 0 always +| 47 0000 0000 0000 0000 | bottom right; tap toggles +| 31 0000 0000 0000 1101 | +| 15 1110 1010 1101 0011 | +|-----------------------------------------| +| AND OR XOR NOT mod | <- operator band +| << >> >>> ROL ROR | +|-----------------------------------------| +| A B C D E F | <- dimmed where the base has no such digit +| 7 8 9 CLR >` and how a decimal literal re-enters; byte order decides the HEX +and ASCII rows only. Both DEC rows are always shown (FR-2.5). + +**BIN is the grid.** A 128-bit binary row is four lines on a phone and says exactly what +the grid says, so there is no BIN row; its label sits above the grid. The grid is 16 bits +a row, nibble-grouped, with each row's top bit index on the left. Its area is the same +height at every width: bit 0 is always bottom right, so bit N is in the same place at +every width that has it, and 128 bits fit by halving the row height. + +**It reopens as it was left:** width, signedness, byte order, input base, register, +expression and cursor, and the committed expression on display. + +**A long-press copies a row** - on its label or its value - as something you could type: +`0xBEEE`, `0o137356`, `0b1011...`, `48878`, `-18`. Prefixed, ungrouped, at the width on +screen, and for hex most significant first whatever the byte order, since the byte order +is a view and the clipboard is the value (the clipboard column of 2, "Programmer Mode +Formatting"). DEC(s) and DEC(u) each copy their own reading; BIN copies from its label +above the grid; ASCII has no typed form and copies nothing. Verified on the emulator by +pasting into the calculator: `0xBEEE` evaluated to 48,878, and an 8-bit DEC(s) copied +as `-18`. + +**The rows are one line each, at a size chosen per width,** from the longest value that +width can produce (octal is the longest: 57 characters at 128 bits), so typing never +reflows them and nothing below moves. The size is measured with the monospace face +rather than assumed, which takes the device's font scale into account; the first +version guessed a glyph width and clipped the last octal digits, because the theme's +body style adds letter spacing the guess did not include. At 128 bits that size is +small (about 9 sp for octal); the grid uses the room the shorter rows leave. + +Verified on the emulator by driving it by label: `D E A D` in hex gives `0xDEAD`; ` << ` +then `4` writes `0xDEAD << 0x4` and previews 912,080 in both DEC rows; a trailing operator dims +the rows and grid without moving them; backspace takes ` << ` whole; `=`, then AND, +writes `0xDEAD0 and `; bit taps on the register, and on a preview (`=` then flip); at +8 bits the warning appears and 64 brings 32,977 back; DEC(s) dims A-F and `200 =` reads +-56 with `200` above it; little-endian `0xDEADBEEF and 0xFF` reads `EF 00 00 00`; a +force-stop restores width, byte order and the half-typed expression; light and dark. +Not verified: a physical device (until the install at the end), TalkBack, landscape. + +Rejected or deferred, and why: +- **Editing each row in place** (the TUI's positional editing). The TUI does it with a + cursor and a keyboard; on glass it means a small target per digit. The input base plus + a pad does the same job with large keys, and the grid covers per-bit edits. +- **A separate primary-base display** (the old wireframe's "tap to make primary"). + Folded into the rows: the label is the switch, and the highlight is the state. +- **History** is not on this screen yet; the committed expression above the answer is + the only record. The TUI shares one history between modes, which this app's two + screens do not. +- **The float view (Ctrl-F), ASCII literals (`'ELF'`) and the system keyboard** are not + built: none needs the engine to change, and each is easier to place once the screen + has been used. +- **Struct layout** (9.4) waits for FR-3. ### 9.4 Struct Layout Screen (Programmer sub-screen) diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 365ca08..e3e605b 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -2120,7 +2120,34 @@ when the process died. Now all of it comes back (design 9.2, 6.7). - Decided: clearing history keeps variables and `Ans`. Replaying history on launch was the alternative, and rejected - see design 6.7. -### Task 6.4: Implement programmer mode screen +### Task 6.4: Implement programmer mode screen [DONE, first version for reaction] + +Design 9.3, rewritten from the TUI's actual programmer mode and the standard screen's +pad-and-preview model; the old plan below this line (segmented 8-64, tappable primary +base, base prefix keys, struct layout button) was superseded. + +- Engine (design 6.8, ABI 6): `pattern` in the programmer result, and JNI + `configureProgrammer`. 995 host tests, the C ABI check, `jvm-test`; the three Android + libraries still have no undefined symbols, TLS or `DT_NEEDED`. +- Kotlin: `Programmer.kt` (the rules: base prefixes, operator-whole backspace, `=` and + continuing from the register as a literal, bit taps, the width lens, CLR then C, + save/restore), 11 JVM tests, with a mutation check (a flip that ignores the width + lens fails them). `ProgrammerViewModel.kt` (own session and thread, newest-wins + previews, dimming held between keystrokes), `ProgrammerScreen.kt`, a drawer entry, + `Action.Digit`/`Action.Prefix` and dimmed keys in `KeyBlock`. One instrumented test + (14 pass). +- Verified on the emulator: see design 9.3. +- Follow-up from first use on the phone: a long-press on a row's label or value copies + it as a typed literal (design 9.3). And the expression line no longer goes blank while + the register holds a value: it falls back to the register as a literal, which an + operator continues from and backspace brings back to edit. Reported as "the value is + not saving"; the phone's saved state showed it was saved, and the blank line was what + made it look lost. Two more JVM tests (13), one replaying the exact state the phone + had saved; a mutation check (the old blank line) fails two of them. +- Not built: history, the float view, ASCII literals and the system keyboard, struct + layout (FR-3). + +Superseded plan: - Compose UI: segmented control for bit width (8/16/32/64) - Value display cards: DEC, HEX, OCT, BIN, signed/unsigned (tappable to switch primary input base) - Bit grid: custom Canvas composable, tappable cells with bit index labels, haptic on toggle @@ -2250,7 +2277,43 @@ mi, backspace gave `98.` miles. - TUI smoke tests (script key sequences, verify screen output via libvaxis test mode if available) - Android instrumented tests for JNI bridge -### Task 7.3: CI/CD setup +### Task 7.3: CI/CD setup [DONE, unverified on the runners] + +`.forgejo/workflows/build.yaml`, after the repositories in `~/src` that already run on +git.lerch.org (zfin's publish-to-generic-registry, flexilib and release-tracker's HSM +signing). Four jobs: engine (fmt, zlint, build, test, jvm-test, release builds for +x86_64/aarch64 Linux musl and aarch64 macOS), android (`zig build android`, the library +audit, JVM unit tests, debug and unsigned release APKs with lint's release checks), sign +(master only, on `ubuntu-latest-with-hsm`) and publish (master only, generic registry +under the short SHA and `latest`). ntfy notifications on every job, as elsewhere. + +- **APK signing with the HSM:** `.forgejo/actions/hsm-apksign`, a docker action shaped + like action-hsm-sign (sibling container, pcscd socket, uhubctl), whose signer is the + pkcs11 image's recipe plus Debian's apksigner over SunPKCS11. Rebuilt from the recipe + rather than `FROM git.lerch.org/lobo/pkcs11:1`, which is the 2023 Debian 11 build whose + security archive has left the mirrors. Two things found by running it: apksigner's + `--provider-class` is refused by the module system on Java 17, so the JDK's own + SunPKCS11 entry is configured through `java.security.properties` instead; and Java only + offers a PKCS#11 key with a certificate, so `make-cert` writes a self-signed one to the + token once (README, "Signing"). `mise run apk-signer-test` runs the image against + SoftHSM: make-cert, sign, `apksigner verify` (v2 and v3), and the APK's signer key equal + to the token's public key. The SoftHSM-signed release APK installed and ran on the + emulator. Debian's `apksigner` package depends on the desktop JRE (X11, GTK, Mesa: 115 + packages); its jar and wrapper are unpacked over the headless JRE instead (42 packages, + 649 MB to 361 MB), and apt's dependency check still passes. `android/audit-libs.sh` (and `mise run android-audit`) is the symbol audit + that had been done by hand; it fails on a libc-linked library, checked. +- **Deviations from the plan above:** toolchains come from setup-zig (the version from + `build.zig.zon`) and pinned, SHA-256-checked downloads of zlint and Gradle, not mise: + mise-action hands the job's token to mise as `GITHUB_TOKEN`, and on Forgejo that is not + a GitHub token, which mise's GitHub-release backends (zlint, prek) would send to + GitHub's API. Other platforms are + cross-compiled, not built on their own runners. The coverage gate stays in pre-commit + only (wttr.in has the same step disabled in CI; not investigated). No AAB. +- **Not verified:** nothing has run on the Forgejo runners yet; the HSM and the host's + pcscd are exercised only by the first master build; `make-cert` must be run once on the + HSM host before it. + +Original plan: - Forgejo Actions workflow (at `git.lerch.org/lobo/tally`): - Use mise to provision toolchains (same `.mise.toml` as local dev) - Build + test on Linux, macOS, Windows @@ -2261,7 +2324,9 @@ mi, backspace gave `98.` miles. - Android: produce signed APK/AAB ### Task 7.4: Documentation -- `README.md`: project overview, build instructions, usage examples +- `README.md`: DONE - overview, the three frontends, install and signature verification, + building, build steps and mise tasks, layout and the decisions behind it, tests, CI/CD + and the one-time signing setup. - `CONTRIBUTING.md`: dev setup, architecture overview, how to add an ABI profile - CLI `--help` text finalized - Struct DSL syntax reference (in README or separate doc) diff --git a/android/README.md b/android/README.md index 28d4aa7..8291cfe 100644 --- a/android/README.md +++ b/android/README.md @@ -1,11 +1,12 @@ # Tally for Android -Two screens, reached from a hamburger drawer: the standard calculator (design 9.2), an -NCalc-style pad with a live answer as you type, and the converter (design 9.6), which -reopens where it was left and shows a value in every unit at once. Settings has the +Three screens, reached from a hamburger drawer: the standard calculator (design 9.2), an +NCalc-style pad with a live answer as you type; programmer mode (design 9.3), every base +of a value at once and a bit grid to edit it; and the converter (design 9.6), which shows +a value in every unit at once. Each reopens exactly as it was left. Settings has the theme: follow the device, light or dark. The engine behind it is the finished one, so `2^100 + 1`, `x = 5` then `x * 2` and `cagr(10000, 25000, 5) * 100` all work. The -programmer and financial screens (design 9) are not built. +financial screen (design 9.5) is not built. ## Building @@ -56,9 +57,21 @@ cd android && gradle connectedAndroidTest Kotlin, and that a closed session refuses to evaluate instead of crashing. It passes on an Android 15 x86_64 image with 16KB pages. Note that it uninstalls the app when it finishes. -Nothing has been verified on a physical device yet, and arm64-v8a is the interesting one: -it is the ABI phones actually use, and the only one where the page-size bug in design 6.3 -showed up. +The rules the screens follow - the converter's memory, programmer mode's input and +register, the saved calculator state - are plain Kotlin with JVM tests that need no +device: `gradle testDebugUnitTest`. + +Verified on a physical device too: a Galaxy S23+ (arm64-v8a, 4 KB pages, Android 16). +arm64-v8a is the interesting ABI: it is the one phones actually use, and the only one +where the page-size bug in design 6.3 showed up. `mise run android-audit` checks every +ABI's library for the symbols that would make `dlopen` fail. + +## Release builds + +`gradle assembleRelease` makes an unsigned release APK. CI signs it with the HSM (see +the top-level README, "Signing"); `mise run apk-signer-test` runs that signing against +SoftHSM locally. A release APK and a debug build are signed by different keys, so one +cannot update the other: uninstall first. ## What is deliberately not here @@ -67,4 +80,4 @@ showed up. is the point of having one engine. - **No error table.** Messages come from `engine.phrase` through the JSON. - **No unit list.** `TallySession.unitCatalog()` returns the engine's own tables, aliases - included, for whenever the convert screen is built. + and display names included, and the converter is built from it. diff --git a/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt b/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt index b902450..dc27b96 100644 --- a/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt +++ b/android/app/src/androidTest/java/dev/lerch/tally/TallyEngineTest.kt @@ -51,6 +51,22 @@ class TallyEngineTest { } } + @Test + fun programmerModeIsConfiguredAndHandsBackItsPattern() { + TallySession().use { session -> + // An int and two booleans through JNI: 16-bit, unsigned, little-endian. + session.configureProgrammer(16, signed = false, bigEndian = false) + val result = session.eval("0x18001", TallyEngine.MODE_PROGRAMMER) + assertEquals(16, result.bits) + assertEquals("8001", result.pattern) + assertEquals("01 80", result.rows?.hex) + // Signed now: `>>` copies the sign bit, and the pattern is still unsigned. + session.configureProgrammer(16, signed = true, bigEndian = true) + assertEquals("ffff", session.preview("0x8000 >> 15", TallyEngine.MODE_PROGRAMMER).pattern) + assertEquals("-1", session.preview("0x8000 >> 15", TallyEngine.MODE_PROGRAMMER).rows?.decSigned) + } + } + @Test fun aPreviewAnswersAndStoresNothing() { TallySession().use { session -> diff --git a/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt b/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt index 806824c..5e179de 100644 --- a/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt +++ b/android/app/src/main/java/dev/lerch/tally/AppPrefs.kt @@ -6,11 +6,11 @@ import android.content.Context internal enum class ThemeChoice { System, Light, Dark } /** The screens the app opens on. Settings is reachable but never reopened at launch. */ -internal enum class Screen { Calculator, Convert, Settings } +internal enum class Screen { Calculator, Programmer, Convert, Settings } /** - * What survives the app being closed: the theme, the screen it was on, and the - * converter exactly as it was left. + * What survives the app being closed: the theme, the screen it was on, and each screen + * exactly as it was left. * * `SharedPreferences` rather than DataStore: a handful of small values, written on the * main thread with `apply()` (which is asynchronous), and no dependency to add for it. @@ -32,6 +32,11 @@ internal class AppPrefs(context: Context) { get() = prefs.getString(KEY_CONVERTER, null) set(value) = prefs.edit().putString(KEY_CONVERTER, value).apply() + /** The programmer screen's whole state, as [Programmer.serialize] writes it. */ + var programmer: String? + get() = prefs.getString(KEY_PROGRAMMER, null) + set(value) = prefs.edit().putString(KEY_PROGRAMMER, value).apply() + // -- The calculator (design 9.2), each part as [CalcCodec] writes it -- /** The engine's variables and `Ans`, as `tally_session_save` wrote them. */ @@ -70,6 +75,7 @@ internal class AppPrefs(context: Context) { const val KEY_THEME = "theme" const val KEY_SCREEN = "screen" const val KEY_CONVERTER = "converter" + const val KEY_PROGRAMMER = "programmer" const val KEY_CALC_ENGINE = "calc.engine" const val KEY_CALC_HISTORY = "calc.history" const val KEY_CALC_COMMITTED = "calc.committed" diff --git a/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt b/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt index cd094c8..7ff8a3d 100644 --- a/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt +++ b/android/app/src/main/java/dev/lerch/tally/CalculatorScreen.kt @@ -145,8 +145,8 @@ private fun handle(model: TallyViewModel, action: Action) { Action.Clear -> model.clear() Action.Equals -> model.commit() Action.Keyboard -> model.startTyping() - // The converter's key; the calculator has `-` for that. - Action.Sign -> {} + // The converter's key, and programmer mode's; the calculator has `-` for a sign. + Action.Sign, is Action.Digit, is Action.Prefix -> {} } } @@ -159,7 +159,7 @@ private fun handle(model: TallyViewModel, action: Action) { */ @OptIn(ExperimentalComposeUiApi::class) @Composable -private fun KeyboardGate(allow: Boolean, content: @Composable () -> Unit) { +internal fun KeyboardGate(allow: Boolean, content: @Composable () -> Unit) { InterceptPlatformTextInput( interceptor = { request, next -> if (allow) next.startInputMethod(request) else awaitCancellation() diff --git a/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt b/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt index 638f6f7..e0686ca 100644 --- a/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt +++ b/android/app/src/main/java/dev/lerch/tally/ConvertScreen.kt @@ -119,7 +119,7 @@ private fun handle(model: ConvertViewModel, action: Action) { Action.Backspace -> model.backspace() Action.Clear -> model.clear() Action.Sign -> model.toggleSign() - is Action.Variable, Action.Equals, Action.Keyboard -> {} + is Action.Variable, Action.Equals, Action.Keyboard, is Action.Digit, is Action.Prefix -> {} } } diff --git a/android/app/src/main/java/dev/lerch/tally/Keypad.kt b/android/app/src/main/java/dev/lerch/tally/Keypad.kt index d7390dc..7a8406b 100644 --- a/android/app/src/main/java/dev/lerch/tally/Keypad.kt +++ b/android/app/src/main/java/dev/lerch/tally/Keypad.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView @@ -59,6 +60,12 @@ internal sealed interface Action { /** The converter's `+/-`: flip the sign of the whole value. */ data object Sign : Action + + /** Programmer mode: a digit in the input base, which may need the base's prefix. */ + data class Digit(val digit: Char) : Action + + /** Programmer mode: a prefix operator - NOT - which after `=` wraps the value. */ + data class Prefix(val text: String) : Action } internal enum class Tone { Digit, Operator, Function, Clear, Equals } @@ -233,6 +240,8 @@ internal fun KeyBlock( onStore: (String) -> Unit, onClearAll: () -> Unit, modifier: Modifier = Modifier, + /** Keys that stay in place but do nothing, dimmed: digits a base does not have. */ + enabled: (Key) -> Boolean = { true }, ) { Column( modifier = modifier @@ -248,6 +257,7 @@ internal fun KeyBlock( onAction = onAction, onStore = onStore, onClearAll = onClearAll, + enabled = enabled(key), modifier = Modifier.weight(key.weight).fillMaxHeight(), ) } @@ -263,6 +273,7 @@ private fun KeyButton( onAction: (Action) -> Unit, onStore: (String) -> Unit, onClearAll: () -> Unit, + enabled: Boolean, modifier: Modifier = Modifier, ) { val view = LocalView.current @@ -274,6 +285,8 @@ private fun KeyButton( Tone.Clear -> colors.errorContainer to colors.onErrorContainer Tone.Equals -> colors.primary to colors.onPrimary } + // Material's disabled emphasis: the key keeps its place and its shape. + val alpha = if (enabled) 1f else 0.38f // Long-press does something only where there is something worth hiding behind it: // storing into a variable, and clearing everything from backspace. @@ -292,10 +305,12 @@ private fun KeyButton( contentAlignment = Alignment.Center, modifier = modifier .padding(3.dp) + .alpha(alpha) .clip(RoundedCornerShape(12.dp)) .background(container) .semantics { key.spoken?.let { contentDescription = it } } .combinedClickable( + enabled = enabled, role = Role.Button, onLongClickLabel = longPressLabel, onLongClick = longPress?.let { action -> diff --git a/android/app/src/main/java/dev/lerch/tally/MainActivity.kt b/android/app/src/main/java/dev/lerch/tally/MainActivity.kt index 371a76b..b1dbb74 100644 --- a/android/app/src/main/java/dev/lerch/tally/MainActivity.kt +++ b/android/app/src/main/java/dev/lerch/tally/MainActivity.kt @@ -140,7 +140,11 @@ private fun AppShell(app: AppViewModel) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), ) - for ((target, label) in listOf(Screen.Calculator to "Calculator", Screen.Convert to "Convert")) { + for ((target, label) in listOf( + Screen.Calculator to "Calculator", + Screen.Programmer to "Programmer", + Screen.Convert to "Convert", + )) { NavigationDrawerItem( label = { Text(label) }, selected = app.screen == target, @@ -165,6 +169,7 @@ private fun AppShell(app: AppViewModel) { onMenu = openMenu, onConvert = { app.open(Screen.Convert) }, ) + Screen.Programmer -> ProgrammerScreen(onMenu = openMenu) Screen.Convert -> ConvertScreen( onMenu = openMenu, onCalculator = { app.open(Screen.Calculator) }, diff --git a/android/app/src/main/java/dev/lerch/tally/Programmer.kt b/android/app/src/main/java/dev/lerch/tally/Programmer.kt new file mode 100644 index 0000000..6c0ebab --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/Programmer.kt @@ -0,0 +1,258 @@ +package dev.lerch.tally + +import java.math.BigInteger + +/** The bases a number is typed in, chosen by tapping a row's label (design 9.3). */ +internal enum class Base(val prefix: String, val radix: Int) { + Hex("0x", 16), + Dec("", 10), + Oct("0o", 8), + Bin("0b", 2); + + /** Whether [digit] is one this base writes. Hex digits are upper case, as the pad has them. */ + fun has(digit: Char): Boolean = digit in '0'..'9' && digit - '0' < radix || radix == 16 && digit in 'A'..'F' +} + +/** + * The programmer screen's state, and every rule about how it changes (design 9.3). + * + * Two things: an expression, which is engine syntax as the pad writes it, and a value, + * which is a register - a 128-bit pattern, as the TUI's `prog_value` is. The engine + * evaluates and renders; this decides what is typed and what is held. Plain Kotlin, so + * the rules run as JVM unit tests. + * + * Immutable: every operation returns the next state. + */ +internal data class Programmer( + /** 8, 16, 32, 64 or 128. A lens: changing it never changes [value]. */ + val width: Int = 64, + val signed: Boolean = true, + val bigEndian: Boolean = true, + val base: Base = Base.Hex, + /** The register, `0 <= value < 2^128`. May hold bits above [width]; see [hiddenBits]. */ + val value: BigInteger = BigInteger.ZERO, + val text: String = "", + val selectionStart: Int = 0, + val selectionEnd: Int = 0, + /** + * The expression `=` last put in the register, shown above it until the next key. Set + * only while [text] is empty: it is what the display holds instead of an expression. + */ + val committed: String? = null, +) { + /** The register at the width on screen: what every row and the grid show. */ + val shown: BigInteger get() = value.and(mask(width)) + + /** True while the register holds bits the width on screen does not show. */ + val hiddenBits: Boolean get() = value.shiftRight(width).signum() != 0 + + /** + * What the expression line shows, dimmed, while nothing is typed: the expression `=` + * last put in the register, or else the register itself as a literal in the input + * base. Never blank while the register holds a value - a value made by tapping bits, + * or left after CLR took the expression, looked lost with nothing there. Null only + * when there is nothing to say: something is typed, or the register is zero. + * + * The pad treats it as the expression on display, whichever it is: an operator + * continues from the register, and backspace brings the line back to edit. + */ + val line: String? + get() = when { + text.isNotEmpty() -> null + committed != null -> committed + shown.signum() != 0 -> literal() + else -> null + } + + // -- The strip -- + + fun withWidth(bits: Int): Programmer = if (bits in WIDTHS) copy(width = bits) else this + + fun withSigned(on: Boolean): Programmer = copy(signed = on) + + fun withBigEndian(on: Boolean): Programmer = copy(bigEndian = on) + + fun withBase(next: Base): Programmer = copy(base = next) + + // -- The pad -- + + /** + * A digit in the input base. One that starts a number is written with the base's + * prefix, so `F F` in hex is `0xFF`; one next to a number continues it, whatever its + * base, so an existing literal can be edited. A digit the base does not have does + * nothing - the pad shows it dimmed. With the [line] showing, a digit starts fresh. + */ + fun digit(d: Char): Programmer { + if (!base.has(d)) return this + val from = if (line != null) startFrom("") else this + val before = from.text.substring(0, from.selectionStart) + val inNumber = numberBefore(before).isNotEmpty() + return from.insertText(if (inNumber) "$d" else base.prefix + d) + } + + /** + * An operator or a parenthesis. With the [line] showing - after `=`, or a value + * made by tapping - one that [continues] starts from the register, written as a + * literal in the input base: the programmer evaluator has no `Ans`, so the pad writes + * the value in instead. + */ + fun insert(op: String, continues: Boolean): Programmer { + val from = if (line != null) startFrom(if (continues) literal() else "") else this + return from.insertText(op) + } + + /** A prefix operator - NOT. With the [line] showing it wraps the register: `~0xF0`. */ + fun prefix(op: String): Programmer { + if (line == null) return insertText(op) + val operand = literal().let { if (it.startsWith("-")) "($it)" else it } + return startFrom("").insertText(op + operand) + } + + /** + * Delete before the cursor. An operator goes whole, spaces and all; a prefix left + * with no digits after it goes with its last digit. With the [line] showing it brings + * the line back for editing instead, as the standard screen does after `=`. + */ + fun backspace(): Programmer { + line?.let { return startFrom(it) } + if (selectionStart != selectionEnd) return replace(selectionStart, selectionEnd, "") + if (selectionStart == 0) return this + val before = text.substring(0, selectionStart) + val op = OPERATORS.firstOrNull { before.endsWith(it) } + if (op != null) return replace(selectionStart - op.length, selectionStart, "") + val shorter = before.dropLast(1) + val bare = Base.entries.firstOrNull { it.prefix.isNotEmpty() && numberBefore(shorter) == it.prefix } + val cut = if (bare != null) bare.prefix.length + 1 else 1 + return replace(selectionStart - cut, selectionStart, "") + } + + /** CLR: the expression; with nothing typed, the register too (a calculator's CE, then C). */ + fun clear(): Programmer { + if (text.isEmpty()) return copy(value = BigInteger.ZERO, committed = null) + return copy(text = "", selectionStart = 0, selectionEnd = 0) + } + + /** Long-press on backspace: the expression and the register. */ + fun clearAll(): Programmer = copy(value = BigInteger.ZERO, text = "", selectionStart = 0, selectionEnd = 0, committed = null) + + /** A tap in the field moving the cursor. Clamped, so a stale position cannot land outside. */ + fun select(start: Int, end: Int): Programmer = + copy(selectionStart = start.coerceIn(0, text.length), selectionEnd = end.coerceIn(0, text.length)) + + // -- The engine's answers -- + + /** `=` answered: [pattern] goes in the register, and the expression is shown above it. */ + fun commit(pattern: BigInteger): Programmer { + val expression = text.trim() + if (expression.isEmpty()) return this + return copy(value = pattern, text = "", selectionStart = 0, selectionEnd = 0, committed = expression) + } + + /** + * A tap on bit [bit] of the grid, which edits what the grid shows. With nothing typed + * that is the register. With an expression it is the expression's value, [preview]: + * the tap is `=` and then the flip. Without one - the expression does not evaluate, + * and the grid is dimmed - the tap does nothing. The flip commits to the width on + * screen, clearing any hidden bits. The expression line then shows the new value + * ([line]). + */ + fun toggleBit(bit: Int, preview: BigInteger?): Programmer { + if (bit !in 0 until width) return this + val from = if (text.isBlank()) this else commit(preview ?: return this) + return from.copy(value = from.shown.flipBit(bit), committed = null) + } + + /** The register as the pad writes it: in the input base, at the width, signed if so. */ + fun literal(): String = format(shown, base, width, signed) + + /** + * What a long-press copies from a row: the value on screen as something you could + * type, with its prefix and without the grouping (design 2, "Programmer Mode + * Formatting"). Hex is the canonical most-significant-first number whatever the byte + * order on display, because the byte order is a view and the clipboard is the value. + */ + fun copied(pattern: BigInteger, base: Base, signedDecimal: Boolean): String = + format(pattern.and(mask(width)), base, width, signedDecimal) + + // -- Saving -- + + fun serialize(): String = listOf( + FORMAT, + width.toString(), + if (signed) "1" else "0", + if (bigEndian) "1" else "0", + base.name, + value.toString(16), + selectionStart.toString(), + selectionEnd.toString(), + CalcCodec.escape(text), + if (committed != null) "1" else "0", + CalcCodec.escape(committed ?: ""), + ).joinToString("\t") + + /** The expression replaced by [start], cursor at its end: the [line] is being edited now. */ + private fun startFrom(start: String): Programmer = + copy(text = start, selectionStart = start.length, selectionEnd = start.length, committed = null) + + private fun insertText(t: String): Programmer { + val start = minOf(selectionStart, selectionEnd) + val end = maxOf(selectionStart, selectionEnd) + return replace(start, end, t) + } + + private fun replace(start: Int, end: Int, t: String): Programmer { + val next = text.replaceRange(start, end, t) + val at = start + t.length + return copy(text = next, selectionStart = at, selectionEnd = at, committed = null) + } + + companion object { + val WIDTHS = listOf(8, 16, 32, 64, 128) + + /** What every binary operator key writes. Longest first, so `>>>` is not read as `>>`. */ + val OPERATORS = listOf(" >>> ", " and ", " xor ", " rol ", " ror ", " << ", " >> ", " or ", " % ", " * ", " / ", " + ", " - ") + + private const val FORMAT = "programmer v1" + + private val LIMIT: BigInteger = BigInteger.ONE.shiftLeft(128) + + fun mask(bits: Int): BigInteger = BigInteger.ONE.shiftLeft(bits).subtract(BigInteger.ONE) + + /** [v], already masked to [width], as a literal: prefixed, ungrouped, upper-case digits. */ + private fun format(v: BigInteger, base: Base, width: Int, signedDecimal: Boolean): String = when (base) { + Base.Dec -> if (signedDecimal && v.testBit(width - 1)) v.subtract(BigInteger.ONE.shiftLeft(width)).toString() else v.toString() + else -> base.prefix + v.toString(base.radix).uppercase() + } + + /** The number literal ending [before], or empty: a run of letters and digits starting with a digit. */ + private fun numberBefore(before: String): String { + val run = before.takeLastWhile { it.isLetterOrDigit() || it == '_' } + return if (run.firstOrNull()?.isDigit() == true) run else "" + } + + /** Null for anything that is not a saved state: a fresh start, not a guess. */ + fun restore(saved: String?): Programmer? { + val f = saved?.split('\t') ?: return null + if (f.size != 11 || f[0] != FORMAT) return null + val width = f[1].toIntOrNull()?.takeIf { it in WIDTHS } ?: return null + val base = Base.entries.firstOrNull { it.name == f[4] } ?: return null + val value = runCatching { BigInteger(f[5], 16) }.getOrNull() + ?.takeIf { it.signum() >= 0 && it < LIMIT } ?: return null + val text = CalcCodec.unescape(f[8]) ?: return null + val committed = if (f[9] == "1") CalcCodec.unescape(f[10]) ?: return null else null + val start = f[6].toIntOrNull()?.coerceIn(0, text.length) ?: return null + val end = f[7].toIntOrNull()?.coerceIn(0, text.length) ?: return null + return Programmer( + width = width, + signed = f[2] == "1", + bigEndian = f[3] == "1", + base = base, + value = value, + text = text, + selectionStart = start, + selectionEnd = end, + committed = committed?.takeIf { text.isEmpty() }, + ) + } + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/ProgrammerScreen.kt b/android/app/src/main/java/dev/lerch/tally/ProgrammerScreen.kt new file mode 100644 index 0000000..fcfdd94 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/ProgrammerScreen.kt @@ -0,0 +1,508 @@ +package dev.lerch.tally + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import java.math.BigInteger + +/** + * Programmer mode (design 9.3): an expression and a register, every base of whichever is + * shown, and a bit grid that edits it. + * + * Every position on this screen is fixed. The width, signedness and byte order change + * what the rows say, never where they are; the rows are one line at a size chosen for the + * width's longest possible value, so typing never reflows them; and the grid's bit 0 is + * bottom right at every width. + */ +@Composable +internal fun ProgrammerScreen(onMenu: () -> Unit, model: ProgrammerViewModel = viewModel()) { + Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + TopRow(title = "Programmer", onMenu = onMenu) + Strip(model) + Expression(model, modifier = Modifier.fillMaxWidth().height(76.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(4.4f) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(horizontal = 8.dp), + ) { + ValueRows(model) + BitGrid(model, modifier = Modifier.fillMaxWidth().weight(1f)) + } + KeyBlock( + rows = operatorRows, + background = MaterialTheme.colorScheme.secondaryContainer, + onAction = model::onAction, + onStore = {}, + onClearAll = model::clearAll, + modifier = Modifier.fillMaxWidth().weight(1.4f), + ) + KeyBlock( + rows = numberRows, + background = MaterialTheme.colorScheme.surfaceContainerHigh, + onAction = model::onAction, + onStore = {}, + onClearAll = model::clearAll, + enabled = { key -> (key.action as? Action.Digit)?.let { model.state.base.has(it.digit) } ?: true }, + modifier = Modifier.fillMaxWidth().weight(3.6f), + ) + } +} + +// -- The pad -- + +private const val TIMES = "\u00D7" +private const val DIVIDE = "\u00F7" +private const val MINUS = "\u2212" +private const val BACKSPACE = "\u232B" + +private fun op(label: String, text: String, spoken: String? = null, tone: Tone = Tone.Function) = + Key(label, Action.Insert(text, continues = true), tone, spoken) + +private fun digit(d: Char) = Key("$d", Action.Digit(d), Tone.Digit) + +/** The operators, with their spaces: the keyword forms need them, and backspace takes them whole. */ +private val operatorRows: List> = listOf( + listOf( + op("AND", " and ", "and"), + op("OR", " or ", "or"), + op("XOR", " xor ", "exclusive or"), + Key("NOT", Action.Prefix("~"), Tone.Function, "not"), + op("mod", " % ", "modulo"), + ), + listOf( + op("<<", " << ", "shift left"), + op(">>", " >> ", "shift right"), + op(">>>", " >>> ", "logical shift right"), + op("ROL", " rol ", "rotate left"), + op("ROR", " ror ", "rotate right"), + ), +) + +private val numberRows: List> = listOf( + "ABCDEF".map(::digit), + listOf(digit('7'), digit('8'), digit('9'), Key("CLR", Action.Clear, Tone.Clear, "clear"), Key(BACKSPACE, Action.Backspace, Tone.Clear, "backspace")), + listOf(digit('4'), digit('5'), digit('6'), op(TIMES, " * ", "times", Tone.Operator), op(DIVIDE, " / ", "divided by", Tone.Operator)), + listOf(digit('1'), digit('2'), digit('3'), op("+", " + ", "plus", Tone.Operator), op(MINUS, " - ", "minus", Tone.Operator)), + listOf( + Key("0", Action.Digit('0'), Tone.Digit, weight = 2f), + Key("(", Action.Insert("("), Tone.Digit, "open parenthesis"), + Key(")", Action.Insert(")"), Tone.Digit, "close parenthesis"), + Key("=", Action.Equals, Tone.Equals, "equals"), + ), +) + +// -- The strip -- + +@Composable +private fun Strip(model: ProgrammerViewModel) { + val s = model.state + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 8.dp), + ) { + for (bits in Programmer.WIDTHS) { + Pill( + text = "$bits", + selected = s.width == bits, + spoken = "$bits bits", + onClick = { model.chooseWidth(bits) }, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.width(8.dp)) + // Toggles, each showing its state; fixed widths, so the label changing moves nothing. + Pill( + text = if (s.signed) "signed" else "unsigned", + selected = false, + onClick = model::toggleSigned, + modifier = Modifier.weight(1.9f), + ) + Pill( + text = if (s.bigEndian) "BE" else "LE", + selected = false, + spoken = if (s.bigEndian) "big-endian" else "little-endian", + onClick = model::toggleEndian, + modifier = Modifier.weight(1f), + ) + } +} + +/** A small choice: a width, a toggle, or a row's base. Highlighted in place when selected. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Pill( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + spoken: String? = null, + height: Dp = 32.dp, + /** A row's label copies its value on a long-press. */ + onLongClick: (() -> Unit)? = null, + longClickLabel: String? = null, +) { + val colors = MaterialTheme.colorScheme + val view = LocalView.current + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .height(height) + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) colors.secondary else colors.surfaceContainerHighest) + .semantics { + spoken?.let { contentDescription = it } + this.selected = selected + } + .combinedClickable( + role = Role.Button, + onLongClickLabel = longClickLabel, + onLongClick = onLongClick?.let { action -> + { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + action() + } + }, + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + onClick() + }, + ), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (selected) colors.onSecondary else colors.onSurfaceVariant, + maxLines = 1, + ) + } +} + +// -- The expression -- + +@Composable +private fun Expression(model: ProgrammerViewModel, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val s = model.state + val focus = remember { FocusRequester() } + // Focused for its cursor; the gate keeps the system keyboard away, so the pad is the input. + LaunchedEffect(Unit) { focus.requestFocus() } + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End, + modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) { + KeyboardGate(allow = false) { + BasicTextField( + value = TextFieldValue(s.text, TextRange(s.selectionStart, s.selectionEnd)), + // Only the cursor can change from here: there is no keyboard to type with. + onValueChange = { model.select(it.selection.start, it.selection.end) }, + textStyle = TextStyle( + fontSize = 24.sp, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.End, + color = colors.onSurface, + ), + cursorBrush = SolidColor(colors.primary), + maxLines = 2, + modifier = Modifier.fillMaxWidth().focusRequester(focus), + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxWidth()) { + // With nothing typed: the expression that produced the register, or the register. + val line = s.line + if (line != null) { + Text( + text = line, + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + color = colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + } + }, + ) + } + model.error?.let { + Text(text = it, color = colors.error, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + } + } +} + +// -- The rows -- + +private const val LABEL_WIDTH = 60 + +/** The longest each row can be at [width], so its size is chosen once per width, not per value. */ +private fun longestRows(width: Int): List { + fun grouped(digits: Int) = digits + (digits - 1) / 3 + val bytes = width / 8 + val unsignedMax = Programmer.mask(width).toString().length + val signedMin = BigInteger.ONE.shiftLeft(width - 1).toString().length + return listOf( + grouped(signedMin) + 1, // DEC(s), with its minus + grouped(unsignedMax), // DEC(u) + bytes * 3 - 1, // HEX: bytes, a space between + grouped((width + 2) / 3), // OCT: digits in threes + bytes * 3 - 1, // ASCII: aligned under the hex + ) +} + +/** + * The largest size, up to a comfortable one, at which [chars] monospace characters fit + * [room]. Measured rather than assumed: the face's advance and the user's font scale both + * vary by device, and a guess that is slightly wide clips the last digits. + */ +@Composable +private fun fitting(room: Dp, chars: Int): TextUnit { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val probe = remember(chars, measurer) { + measurer.measure("0".repeat(chars), TextStyle(fontFamily = FontFamily.Monospace, fontSize = 100.sp, letterSpacing = 0.sp)).size.width + } + val roomPx = with(density) { room.toPx() } + return minOf(100f * roomPx / probe, 17f).sp +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ValueRows(model: ProgrammerViewModel) { + val s = model.state + val rows = model.shown?.rows + val longest = longestRows(s.width) + val labels = listOf("DEC(s)", "DEC(u)", "HEX", "OCT", "ASCII") + val bases = listOf(Base.Dec, Base.Dec, Base.Hex, Base.Oct, null) + val texts = listOf(rows?.decSigned, rows?.decUnsigned, rows?.hex, rows?.oct, rows?.ascii) + val clipboard = LocalClipboardManager.current + val view = LocalView.current + + BoxWithConstraints(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) { + val room = maxWidth - LABEL_WIDTH.dp - 8.dp + Column { + for (i in labels.indices) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { + val base = bases[i] + // DEC(s) and DEC(u) are one base, read two ways; each copies its own. + val copy: (() -> Unit)? = base?.let { + { + model.copied(base, signedDecimal = i == 0)?.let { clipboard.setText(AnnotatedString(it)) } + } + } + if (base != null) { + Pill( + text = labels[i], + selected = s.base == base, + onClick = { model.chooseBase(base) }, + onLongClick = copy, + longClickLabel = "copy the ${labels[i]} value", + height = 24.dp, + modifier = Modifier.width(LABEL_WIDTH.dp), + ) + } else { + Text( + text = labels[i], + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.width(LABEL_WIDTH.dp), + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = texts[i] ?: "", + fontFamily = FontFamily.Monospace, + fontSize = fitting(room, longest[i]), + // The theme's body style spaces its letters, which is not in the + // measurement and, over 57 octal digits, is what clipped them. + letterSpacing = 0.sp, + color = MaterialTheme.colorScheme.onSurface, + // Only the values dim: the labels are still the base switch. + modifier = Modifier + .weight(1f) + .alpha(if (model.dimmed) 0.5f else 1f) + .then( + if (copy == null) { + Modifier + } else { + // The value itself copies too: it is the bigger target. + Modifier.combinedClickable( + onLongClickLabel = "copy the ${labels[i]} value", + onLongClick = { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + copy() + }, + onClick = {}, + ) + }, + ), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + } + } + } + } +} + +// -- The grid -- + +private const val BITS_PER_ROW = 16 + +/** + * The binary row, as a grid you can tap: 16 bits a row, nibble-grouped, each row's top bit + * index on its left. The area is the same at every width and filled from the bottom, so + * bit N is in one place at every width that has it; 128 bits fit by halving the rows. + */ +@Composable +private fun BitGrid(model: ProgrammerViewModel, modifier: Modifier = Modifier) { + val s = model.state + val colors = MaterialTheme.colorScheme + val view = LocalView.current + val clipboard = LocalClipboardManager.current + val pattern = model.shownPattern ?: BigInteger.ZERO + val dimmed = model.dimmed + val needed = (s.width + BITS_PER_ROW - 1) / BITS_PER_ROW + val slots = maxOf(4, needed) + + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Pill( + text = "BIN", + selected = s.base == Base.Bin, + onClick = { model.chooseBase(Base.Bin) }, + onLongClick = { model.copied(Base.Bin, signedDecimal = false)?.let { clipboard.setText(AnnotatedString(it)) } }, + longClickLabel = "copy the BIN value", + height = 24.dp, + modifier = Modifier.width(LABEL_WIDTH.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + // The width is a lens: say so when it is hiding something. + if (s.text.isBlank() && s.hiddenBits) { + Text( + text = "bits above ${s.width} hidden - widen to restore", + style = MaterialTheme.typography.labelMedium, + color = colors.error, + maxLines = 1, + ) + } + } + Column(modifier = Modifier.fillMaxWidth().weight(1f).alpha(if (dimmed) 0.5f else 1f)) { + for (slot in slots - 1 downTo 0) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().weight(1f)) { + if (slot >= needed) return@Row + val low = slot * BITS_PER_ROW + val top = minOf(low + BITS_PER_ROW, s.width) - 1 + Text( + text = "$top", + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colors.onSurfaceVariant, + textAlign = TextAlign.End, + modifier = Modifier.width(28.dp).padding(end = 4.dp), + ) + for (column in 0 until BITS_PER_ROW) { + val bit = low + BITS_PER_ROW - 1 - column + // A gap between nibbles, which is what makes a row readable as hex. + if (column > 0 && column % 4 == 0) Spacer(modifier = Modifier.width(5.dp)) + if (bit >= s.width) { + Spacer(modifier = Modifier.weight(1f)) + } else { + BitCell(bit, pattern.testBit(bit), slots > 4, enabled = !dimmed) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + model.toggleBit(bit) + } + } + } + } + } + } + } +} + +@Composable +private fun RowScope.BitCell( + bit: Int, + on: Boolean, + small: Boolean, + enabled: Boolean, + onToggle: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(1.dp) + .clip(RoundedCornerShape(3.dp)) + .background(if (on) colors.primary else colors.surfaceContainerHighest) + .semantics { + contentDescription = "bit $bit" + stateDescription = if (on) "set" else "clear" + } + .clickable(enabled = enabled, role = Role.Switch, onClick = onToggle), + ) { + Text( + text = if (on) "1" else "0", + fontSize = if (small) 11.sp else 14.sp, + fontFamily = FontFamily.Monospace, + color = if (on) colors.onPrimary else colors.onSurfaceVariant, + ) + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/ProgrammerViewModel.kt b/android/app/src/main/java/dev/lerch/tally/ProgrammerViewModel.kt new file mode 100644 index 0000000..dc1c442 --- /dev/null +++ b/android/app/src/main/java/dev/lerch/tally/ProgrammerViewModel.kt @@ -0,0 +1,186 @@ +package dev.lerch.tally + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigInteger +import java.util.concurrent.Executors + +/** + * The programmer screen (design 9.3): holds a [Programmer], saves it on every change, and + * asks the engine to evaluate and render. The rules are all in [Programmer], where they + * are unit tested; this connects them to the engine, the disk and the screen. + * + * Its own session on its own thread, for the reasons [TallyViewModel] gives. Programmer + * evaluation reads no environment, so nothing here needs the calculator's session. + */ +internal class ProgrammerViewModel(app: Application) : AndroidViewModel(app) { + private val prefs = AppPrefs(app) + private val engineThread = Executors.newSingleThreadExecutor { task -> Thread(task, "tally-programmer") } + private val engine = engineThread.asCoroutineDispatcher() + private val session = TallySession() + + var state by mutableStateOf(Programmer.restore(prefs.programmer) ?: Programmer()) + private set + + /** Why the last `=` failed, shown in place of the expression's answer until the next edit. */ + var error by mutableStateOf(null) + private set + + /** Everything an answer depends on. An answer for any other key is stale. */ + private data class Key(val text: String, val width: Int, val signed: Boolean, val bigEndian: Boolean, val register: BigInteger) + + private fun keyOf(s: Programmer) = Key(s.text, s.width, s.signed, s.bigEndian, s.shown) + + /** The register, rendered. */ + private var register by mutableStateOf(null) + + /** The expression's value, and the key it was computed for. */ + private var preview by mutableStateOf(null) + private var previewFor by mutableStateOf(null) + + /** + * The expression's last value while it was being typed. Kept while the expression + * does not evaluate, and while the engine has not yet answered for the latest key, + * so the grid neither flashes back to the register nor blinks between keystrokes. + */ + private var lastGood by mutableStateOf(null) + + /** The engine's latest answer for the expression was a failure. Held while the next is pending. */ + private var previewFailed by mutableStateOf(false) + + private var requested: Key? = null + private var job: Job? = null + + init { + TallyEngine.checkAbi() + refresh() + } + + // -- What the screen reads -- + + /** The expression's value, if it evaluates as it stands now. */ + private val livePreview: TallyResult? + get() = preview?.takeIf { it.ok && previewFor == keyOf(state) } + + /** What every row and the grid show: the expression's value, or the register. */ + val shown: TallyResult? + get() = if (state.text.isBlank()) register else lastGood ?: register + + /** True while what is shown is not the current value of anything: the grid ignores taps. */ + val dimmed: Boolean + get() = state.text.isNotBlank() && previewFailed + + val shownPattern: BigInteger? + get() = shown?.pattern?.let(::patternOf) + + /** What a long-press on a row copies: the value on screen, as you would type it. */ + fun copied(base: Base, signedDecimal: Boolean): String? = + shownPattern?.let { state.copied(it, base, signedDecimal) } + + // -- Actions -- + + fun onAction(action: Action) { + when (action) { + is Action.Digit -> update { it.digit(action.digit) } + is Action.Insert -> update { it.insert(action.text, action.continues) } + is Action.Prefix -> update { it.prefix(action.text) } + Action.Backspace -> update { it.backspace() } + Action.Clear -> update { it.clear() } + Action.Equals -> commit() + // The calculator's and the converter's keys; this pad has none of them. + is Action.Variable, Action.Keyboard, Action.Sign -> {} + } + } + + fun clearAll() = update { it.clearAll() } + + fun select(start: Int, end: Int) = update { it.select(start, end) } + + fun chooseWidth(bits: Int) = update { it.withWidth(bits) } + + fun toggleSigned() = update { it.withSigned(!it.signed) } + + fun toggleEndian() = update { it.withBigEndian(!it.bigEndian) } + + fun chooseBase(base: Base) = update { it.withBase(base) } + + fun toggleBit(bit: Int) = update { it.toggleBit(bit, livePreview?.pattern?.let(::patternOf)) } + + /** `=`: evaluate for real and put the answer in the register. */ + private fun commit() { + val s = state + if (s.text.isBlank()) return + viewModelScope.launch { + val result = withContext(engine) { + session.configureProgrammer(s.width, s.signed, s.bigEndian) + session.eval(s.text, TallyEngine.MODE_PROGRAMMER) + } + // Edited while the engine was answering: the answer is for text that has gone. + if (state != s) return@launch + val pattern = result.pattern?.let(::patternOf) + if (result.ok && pattern != null) { + set(s.commit(pattern)) + } else { + error = result.error ?: "evaluation failed" + } + } + } + + private inline fun update(change: (Programmer) -> Programmer) = set(change(state)) + + private fun set(next: Programmer) { + val before = state + if (next == before) return + state = next + prefs.programmer = next.serialize() + if (next.text != before.text) error = null + if (next.text.isBlank()) { + lastGood = null + previewFailed = false + } + refresh() + } + + /** + * Ask for the register's rows and the expression's value, in one trip. Newest wins: a + * request queued behind a newer one is dropped, and an answer for a key that has + * changed since is thrown away. + */ + private fun refresh() { + val s = state + val key = keyOf(s) + if (key == requested) return + requested = key + job?.cancel() + job = viewModelScope.launch { + val (reg, pre) = withContext(engine) { + session.configureProgrammer(s.width, s.signed, s.bigEndian) + val reg = session.preview("0x" + s.shown.toString(16), TallyEngine.MODE_PROGRAMMER) + val pre = if (s.text.isBlank()) null else session.preview(s.text, TallyEngine.MODE_PROGRAMMER) + reg to pre + } + if (keyOf(state) != key) return@launch + register = reg.takeIf { it.ok } + preview = pre + previewFor = key + previewFailed = pre != null && !pre.ok + if (pre?.ok == true) lastGood = pre + } + } + + private fun patternOf(hex: String): BigInteger? = runCatching { BigInteger(hex, 16) }.getOrNull() + + override fun onCleared() { + engineThread.execute { session.close() } + engineThread.shutdown() + super.onCleared() + } +} diff --git a/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt b/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt index 150d58e..21f43ae 100644 --- a/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt +++ b/android/app/src/main/java/dev/lerch/tally/TallyEngine.kt @@ -23,7 +23,7 @@ internal object TallyEngine { const val MODE_PROGRAMMER = 1 /** The ABI this Kotlin was written against; see [checkAbi]. */ - const val EXPECTED_ABI_VERSION = 5 + const val EXPECTED_ABI_VERSION = 6 external fun sessionNew(): Long @@ -43,6 +43,12 @@ internal object TallyEngine { /** Narrow or widen a session's rendering. Returns the `tally_status`, 0 for ok. */ external fun configureDisplay(handle: Long, fractionDigits: Int, significantDigits: Int): Int + /** + * Programmer mode's width (8, 16, 32, 64 or 128), signedness and byte order, leaving + * the display budget alone. Returns the `tally_status`; a refused width changes nothing. + */ + external fun configureProgrammer(handle: Long, bits: Int, signed: Boolean, bigEndian: Boolean): Int + /** The session's variables and `Ans`, exactly, as JSON; null if it could not be made. */ external fun sessionSave(handle: Long): String? @@ -124,6 +130,14 @@ internal class TallySession : AutoCloseable { } } + /** How programmer mode reads and shows a value: width, two's complement, byte order. */ + fun configureProgrammer(bits: Int, signed: Boolean, bigEndian: Boolean) { + check(handle != 0L) { "session used after close" } + check(TallyEngine.configureProgrammer(handle, bits, signed, bigEndian) == 0) { + "programmer configuration refused: $bits bits" + } + } + /** The variables and `Ans`, exactly, for keeping across a restart (design 6.7). */ fun save(): String? { check(handle != 0L) { "session used after close" } @@ -166,6 +180,11 @@ internal data class TallyResult( val bases: Bases? = null, val rows: Rows? = null, val bits: Int? = null, + /** + * Programmer mode's value to hold: the pattern masked to the width, as lowercase hex. + * The [rows] are for reading; this is what goes in a register. + */ + val pattern: String? = null, /** The engine's JSON exactly as it came back, which is what history saves. */ val raw: String = "", ) { @@ -223,6 +242,7 @@ internal data class TallyResult( ) }, bits = if (root.has("bits")) root.optInt("bits") else null, + pattern = if (root.has("pattern")) root.optString("pattern") else null, ) } } diff --git a/android/app/src/test/java/dev/lerch/tally/ProgrammerTest.kt b/android/app/src/test/java/dev/lerch/tally/ProgrammerTest.kt new file mode 100644 index 0000000..42e1f64 --- /dev/null +++ b/android/app/src/test/java/dev/lerch/tally/ProgrammerTest.kt @@ -0,0 +1,242 @@ +package dev.lerch.tally + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigInteger + +/** The programmer screen's rules (design 9.3), on the JVM. */ +class ProgrammerTest { + private fun hex(s: String) = BigInteger(s, 16) + + private fun Programmer.typed(keys: String): Programmer = keys.fold(this) { p, c -> p.digit(c) } + + @Test + fun aDigitThatStartsANumberGetsTheBasePrefix() { + assertEquals("0xFF", Programmer(base = Base.Hex).typed("FF").text) + assertEquals("0b101", Programmer(base = Base.Bin).typed("101").text) + assertEquals("0o17", Programmer(base = Base.Oct).typed("17").text) + assertEquals("42", Programmer(base = Base.Dec).typed("42").text) + + // After an operator, a new number; next to one, the same number whatever the base. + val p = Programmer(base = Base.Hex).typed("F").insert(" and ", continues = true).typed("1") + assertEquals("0xF and 0x1", p.text) + assertEquals("0xF and 0x15", p.withBase(Base.Dec).digit('5').text) + assertEquals(p.text.length, p.selectionStart) + } + + @Test + fun theLineIsBlankOnlyWhenThereIsNothingToSay() { + // The state the first build left on a phone: a register, and nothing else. It + // came back blank, which read as the value having been lost. + val saved = "programmer v1\t64\t1\t1\tHex\t9ce6226c829a13b0\t0\t0\t\t0\t" + val restored = Programmer.restore(saved)!! + assertEquals("0x9CE6226C829A13B0", restored.line) + assertEquals("0x9CE6226C829A13B0", restored.backspace().text) + + // After `=`, the expression that produced the register, rather than its value. + val done = Programmer(base = Base.Hex).typed("F").insert(" + ", continues = true).typed("1").commit(hex("10")) + assertEquals("0xF + 0x1", done.line) + // The input base is the line's base, so it follows a switch. + assertEquals("16", restored.copy(value = hex("10")).withBase(Base.Dec).line) + // CLR takes the expression and leaves the register on the line; again, zero. + val typing = done.typed("2") + assertNull(typing.line) + assertEquals("0x10", typing.clear().line) + assertNull(typing.clear().clear().line) + assertNull(Programmer().line) + // A digit starts fresh from the line, NOT wraps what it shows. + assertEquals("0x2", restored.digit('2').text) + assertEquals("~0x9CE6226C829A13B0", restored.prefix("~").text) + } + + @Test + fun aDigitTheBaseDoesNotHaveDoesNothing() { + val bin = Programmer(base = Base.Bin) + assertSame(bin, bin.digit('2')) + val oct = Programmer(base = Base.Oct) + assertSame(oct, oct.digit('8')) + val dec = Programmer(base = Base.Dec) + assertSame(dec, dec.digit('A')) + assertEquals("0xA", Programmer(base = Base.Hex).digit('A').text) + } + + @Test + fun digitsGoInAtTheCursorAndReplaceASelection() { + val p = Programmer(base = Base.Hex, text = "0xF and 0x1", selectionStart = 3, selectionEnd = 3) + assertEquals("0xFA and 0x1", p.digit('A').text) + assertEquals(4, p.digit('A').selectionStart) + val selected = p.copy(selectionStart = 8, selectionEnd = 11) + assertEquals("0xF and 0x7", selected.digit('7').text) + // Clamped: a position from a stale save cannot land outside the text. + assertEquals(11, p.select(-5, 99).selectionEnd) + assertEquals(0, p.select(-5, 99).selectionStart) + } + + @Test + fun backspaceTakesOperatorsWholeAndABarePrefixWithItsDigit() { + val p = Programmer(base = Base.Hex).typed("F").insert(" >>> ", continues = true) + assertEquals("0xF", p.backspace().text) + assertEquals("0xF", Programmer(text = "0xF >> ", selectionStart = 7, selectionEnd = 7).backspace().text) + assertEquals("0xF", Programmer(text = "0xF and ", selectionStart = 8, selectionEnd = 8).backspace().text) + // `0xF` less its digit is not a number, so the prefix goes too. + assertEquals("1 + ", Programmer(text = "1 + 0xF", selectionStart = 7, selectionEnd = 7).backspace().text) + assertEquals("0xF", Programmer(text = "0xF0", selectionStart = 4, selectionEnd = 4).backspace().text) + assertEquals("~", Programmer(text = "~0b1", selectionStart = 4, selectionEnd = 4).backspace().text) + // Nothing before the cursor, nothing to delete. + val start = Programmer(text = "12", selectionStart = 0, selectionEnd = 0) + assertSame(start, start.backspace()) + } + + @Test + fun equalsPutsTheAnswerInTheRegisterAndOperatorsStartFromIt() { + val typed = Programmer(base = Base.Hex).typed("F0") + val done = typed.commit(hex("f0")) + assertEquals(hex("f0"), done.value) + assertEquals("", done.text) + assertEquals("0xF0", done.committed) + + // No `Ans` in programmer evaluation: the pad writes the value in instead. + assertEquals("0xF0 and ", done.insert(" and ", continues = true).text) + assertEquals("~0xF0", done.prefix("~").text) + assertNull(done.insert(" and ", continues = true).committed) + // A digit, or a parenthesis, starts fresh. + assertEquals("0x1", done.digit('1').text) + assertEquals("(", done.insert("(", continues = false).text) + // Backspace brings the expression back for editing. + val back = done.backspace() + assertEquals("0xF0", back.text) + assertEquals(4, back.selectionStart) + assertNull(back.committed) + // An empty expression commits nothing. + val empty = Programmer() + assertSame(empty, empty.commit(BigInteger.ONE)) + } + + @Test + fun theRegisterReentersAsALiteralInTheInputBase() { + val minus16 = Programmer(width = 8, value = hex("f0")) + assertEquals("0xF0", minus16.literal()) + assertEquals("0o360", minus16.withBase(Base.Oct).literal()) + assertEquals("0b11110000", minus16.withBase(Base.Bin).literal()) + assertEquals("-16", minus16.withBase(Base.Dec).literal()) + assertEquals("240", minus16.withBase(Base.Dec).withSigned(false).literal()) + // NOT of a negative decimal needs its parentheses to read as intended. + assertEquals("~(-16)", minus16.withBase(Base.Dec).copy(committed = "x").prefix("~").text) + // At the width on screen, not the register's full 128 bits. + assertEquals("0xFF", Programmer(width = 8, value = hex("1ff")).literal()) + } + + @Test + fun aLongPressCopiesTheValueAsSomethingYouCouldType() { + val p = Programmer(width = 32, bigEndian = false) + val v = hex("deadbeef") + // Prefixed, ungrouped, most significant first even with little-endian on display. + assertEquals("0xDEADBEEF", p.copied(v, Base.Hex, signedDecimal = false)) + assertEquals("0o33653337357", p.copied(v, Base.Oct, signedDecimal = false)) + assertEquals("0b" + "11011110101011011011111011101111", p.copied(v, Base.Bin, signedDecimal = false)) + assertEquals("3735928559", p.copied(v, Base.Dec, signedDecimal = false)) + assertEquals("-559038737", p.copied(v, Base.Dec, signedDecimal = true)) + // What is on screen, at the width on screen. + assertEquals("0xEF", p.withWidth(8).copied(v, Base.Hex, signedDecimal = false)) + assertEquals("0x0", p.copied(BigInteger.ZERO, Base.Hex, signedDecimal = false)) + } + + @Test + fun widthIsALensUntilSomethingIsEdited() { + val wide = Programmer(width = 32, value = hex("deadbeef")) + val narrow = wide.withWidth(16) + assertEquals(hex("deadbeef"), narrow.value) + assertEquals(hex("beef"), narrow.shown) + assertTrue(narrow.hiddenBits) + // Widening brings the hidden bits back. + assertEquals(hex("deadbeef"), narrow.withWidth(32).shown) + assertFalse(narrow.withWidth(32).hiddenBits) + // An edit commits to the width on screen. + val flipped = narrow.toggleBit(0, preview = null) + assertEquals(hex("beee"), flipped.value) + assertFalse(flipped.hiddenBits) + assertEquals(hex("deadbeef"), wide.withWidth(24).value) + assertEquals(32, wide.withWidth(24).width) + } + + @Test + fun aBitTapEditsWhatTheGridShows() { + // Nothing typed: the register. The line then shows the new value as a literal. + val p = Programmer(width = 8, value = hex("0f")) + assertEquals(hex("8f"), p.toggleBit(7, preview = null).value) + assertNull(p.copy(committed = "0xF").toggleBit(7, preview = null).committed) + assertEquals("0x8F", p.toggleBit(7, preview = null).line) + assertEquals("-113", p.withBase(Base.Dec).toggleBit(7, preview = null).line) + // It is the expression on display, so backspace brings it back to edit, and an + // operator continues from it. + assertEquals("0x8F", p.toggleBit(7, preview = null).backspace().text) + assertEquals("0x8F and ", p.toggleBit(7, preview = null).insert(" and ", continues = true).text) + // A bit the width does not have is not on the grid. + assertSame(p, p.toggleBit(8, preview = null)) + assertSame(p, p.toggleBit(-1, preview = null)) + + // An expression showing its value: `=`, then the flip. + val typing = Programmer(width = 8, base = Base.Hex).typed("F0") + val tapped = typing.toggleBit(0, preview = hex("f0")) + assertEquals(hex("f1"), tapped.value) + assertEquals("", tapped.text) + assertEquals("0xF1", tapped.line) + // An expression that does not evaluate: the grid is dimmed and the tap does nothing. + assertSame(typing, typing.toggleBit(0, preview = null)) + } + + @Test + fun clearTakesTheExpressionThenTheRegister() { + val p = Programmer(value = hex("ff"), text = "0x1", selectionStart = 3, selectionEnd = 3) + val once = p.clear() + assertEquals("", once.text) + assertEquals(hex("ff"), once.value) + assertEquals(BigInteger.ZERO, once.clear().value) + // After `=`, the answer on display is what CLR clears. + val done = p.commit(hex("1")) + assertEquals(BigInteger.ZERO, done.clear().value) + assertNull(done.clear().committed) + val all = p.clearAll() + assertEquals("", all.text) + assertEquals(BigInteger.ZERO, all.value) + } + + @Test + fun everythingSavedComesBack() { + val p = Programmer( + width = 16, + signed = false, + bigEndian = false, + base = Base.Oct, + value = BigInteger.ONE.shiftLeft(128).subtract(BigInteger.ONE), + text = "0o17 << \t odd", + selectionStart = 4, + selectionEnd = 6, + ) + assertEquals(p, Programmer.restore(p.serialize())) + val done = Programmer(value = hex("f0"), committed = "0xF0") + assertEquals(done, Programmer.restore(done.serialize())) + assertEquals(Programmer(), Programmer.restore(Programmer().serialize())) + } + + @Test + fun anythingThatIsNotASavedStateIsAFreshStart() { + assertNull(Programmer.restore(null)) + assertNull(Programmer.restore("")) + val good = Programmer(value = hex("ff")).serialize().split('\t') + fun with(i: Int, v: String) = good.toMutableList().also { it[i] = v }.joinToString("\t") + assertNull(Programmer.restore(with(0, "programmer v0"))) + assertNull(Programmer.restore(with(1, "24"))) + assertNull(Programmer.restore(with(4, "Base64"))) + assertNull(Programmer.restore(with(5, "-1"))) + assertNull(Programmer.restore(with(5, "1" + "0".repeat(32)))) + assertNull(Programmer.restore(with(5, "xyz"))) + assertNull(Programmer.restore(with(8, "bad\\q"))) + // A cursor past the text is clamped, not refused. + assertEquals(0, Programmer.restore(with(7, "99"))?.selectionEnd) + } +} diff --git a/android/audit-libs.sh b/android/audit-libs.sh new file mode 100755 index 0000000..fff50d2 --- /dev/null +++ b/android/audit-libs.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Check the Android libraries `zig build android` produced will load on a device. +# +# android/audit-libs.sh [zig-out/android] +# +# libtally.so is linked against nothing (design 6.2): no libc, no NDK. So every symbol it +# references has to be one it defines, it may not have a TLS segment (only Bionic can +# satisfy `__tls_get_addr`), and it may not name a library it needs. Each of those +# links on a desktop and fails in `dlopen` on a phone, before any code here runs - which +# is how the first build failed, and why this runs on every build rather than once. +set -eu + +dir="${1:-zig-out/android}" +fail=0 +for abi in arm64-v8a x86_64 armeabi-v7a; do + lib="$dir/$abi/libtally.so" + if [ ! -f "$lib" ]; then + echo "$abi: missing $lib" + fail=1 + continue + fi + undefined="$(readelf --dyn-syms -W "$lib" | awk '$7 == "UND" && $8 != ""' | wc -l)" + tls="$(readelf -l -W "$lib" | grep -c ' TLS ' || true)" + needed="$(readelf -d "$lib" | grep -c NEEDED || true)" + exports="$(readelf --dyn-syms -W "$lib" | grep -c 'Java_dev_lerch_tally_TallyEngine_' || true)" + printf '%-12s undefined=%s tls=%s needed=%s jni-exports=%s\n' "$abi" "$undefined" "$tls" "$needed" "$exports" + if [ "$undefined" -ne 0 ] || [ "$tls" -ne 0 ] || [ "$needed" -ne 0 ] || [ "$exports" -eq 0 ]; then + fail=1 + fi +done +[ "$fail" -eq 0 ] && echo "all Android libraries are self-contained" +exit "$fail" diff --git a/engine/src/c_api.zig b/engine/src/c_api.zig index 3af7d07..8be885d 100644 --- a/engine/src/c_api.zig +++ b/engine/src/c_api.zig @@ -36,7 +36,7 @@ const Rational = @import("Rational.zig"); /// changes. Android ships a prebuilt library that can fall out of step with the code /// calling it, and a silently changed layout is a crash in the field rather than an /// error, so the version is readable separately from the product version. -const abi_version: u32 = 5; +const abi_version: u32 = 6; /// How a call ended. The evaluation itself failing is a normal outcome with a message /// in the JSON, not an exceptional one. @@ -297,6 +297,9 @@ pub const Session = struct { return self.writeError(err); }; const rows = rowsOf(scratch, int, config) catch |err| return self.writeError(err); + const pattern = std.fmt.allocPrint(scratch, "{x}", .{int.unsignedValue()}) catch { + return .out_of_memory; + }; return self.writeJson(.{ .ok = true, @@ -304,6 +307,10 @@ pub const Session = struct { .exact = true, .truncated = false, .bits = config.width.bits(), + // The value itself, for a caller that keeps it: the bit pattern masked to the + // width, as lowercase hex with no prefix and no grouping. The rows are for + // reading; this is for holding, so nobody parses a display string to get it. + .pattern = pattern, .rows = rows, }); } @@ -926,6 +933,27 @@ test "programmer mode returns every row the screen shows, at the configured widt try testing.expect(std.mem.indexOf(u8, json, "\"dec_unsigned\":\"255\"") != null); try testing.expect(std.mem.indexOf(u8, json, "\"bin\":\"1111 1111\"") != null); try testing.expect(std.mem.indexOf(u8, json, "\"bits\":8") != null); + // The pattern is the value to hold, not to read: unsigned, masked, ungrouped. + try testing.expect(std.mem.indexOf(u8, json, "\"pattern\":\"ff\"") != null); + + // A literal wider than the width is masked, and so is its pattern; a negative value + // is its two's complement at the width, never sign-extended past it. + for ([_]struct { []const u8, []const u8 }{ + .{ "0x1FF", "\"pattern\":\"ff\"" }, + .{ "-16", "\"pattern\":\"f0\"" }, + .{ "0", "\"pattern\":\"0\"" }, + }) |case| { + const case_status, const case_json = try evalJson(&session, case[0], .programmer); + try testing.expectEqual(Status.ok, case_status); + try testing.expect(std.mem.indexOf(u8, case_json, case[1]) != null); + } + + // Every bit of the widest width survives, with nothing above it. + config = .{ .bits = 128 }; + try testing.expectEqual(Status.ok, tally_session_configure(&session, &config)); + const wide_status, const wide = try evalJson(&session, "-1", .programmer); + try testing.expectEqual(Status.ok, wide_status); + try testing.expect(std.mem.indexOf(u8, wide, "\"pattern\":\"" ++ "f" ** 32 ++ "\"") != null); // Byte order is a display choice and it reaches the rows. config = .{ .bits = 32, .big_endian = 0 }; @@ -1080,9 +1108,10 @@ test "version and ABI version are separate facts" { // A caller that does not want the length may pass null. _ = tally_version(null); // 2 added `tally_preview`, 3 `tally_convert`, 4 the catalogue's `label`, 5 save and - // load. A library without what the app calls must be refused at startup, not - // discovered missing in use. - try testing.expectEqual(@as(u32, 5), tally_abi_version()); + // load, 6 the programmer result's `pattern` (and JNI's `configureProgrammer`). A + // library without what the app calls must be refused at startup, not discovered + // missing in use. + try testing.expectEqual(@as(u32, 6), tally_abi_version()); } /// Save a session and hand back the JSON. diff --git a/engine/src/jni.zig b/engine/src/jni.zig index d39ac0d..09fbb7f 100644 --- a/engine/src/jni.zig +++ b/engine/src/jni.zig @@ -424,6 +424,32 @@ export fn Java_dev_lerch_tally_TallyEngine_configureDisplay( return @intFromEnum(c_api.tally_session_configure(session, &config)); } +/// Set how programmer mode reads and shows a value - its width, whether it is two's +/// complement, and the byte order of the hex and ASCII rows - leaving the rest of the +/// configuration as it was. Returns the `tally_status`: a width other than 8, 16, 32, 64 +/// or 128 is refused and changes nothing. Added in ABI version 6. +export fn Java_dev_lerch_tally_TallyEngine_configureProgrammer( + env: *Env, + this: jobject, + handle: jlong, + bits: jint, + is_signed: jboolean, + big_endian: jboolean, +) callconv(.c) jint { + _ = env; + _ = this; + const session = sessionFromHandle(handle) orelse return @intFromEnum(c_api.Status.invalid_argument); + // Range-checked before the narrowing cast; `tally_session_configure` then refuses any + // width in range that is not one of the five. + if (bits < 0 or bits > std.math.maxInt(u16)) return @intFromEnum(c_api.Status.invalid_argument); + var config: c_api.Config = .{}; + _ = c_api.tally_session_config(session, &config); + config.bits = @intCast(bits); + config.is_signed = @intFromBool(is_signed != 0); + config.big_endian = @intFromBool(big_endian != 0); + return @intFromEnum(c_api.tally_session_configure(session, &config)); +} + /// Called by the runtime when the library loads. /// /// This is where the hand-written table is proved against the JVM that is actually @@ -622,7 +648,7 @@ test "the catalogue and the version strings cross too" { _ = Java_dev_lerch_tally_TallyEngine_versionString(&jvm.env, null); try testing.expectEqualStrings("0.1.0", jvm.madeString()); - try testing.expectEqual(@as(jint, 5), Java_dev_lerch_tally_TallyEngine_abiVersion(&jvm.env, null)); + try testing.expectEqual(@as(jint, 6), Java_dev_lerch_tally_TallyEngine_abiVersion(&jvm.env, null)); } test "a preview crosses JNI and leaves the session as it found it" { @@ -754,6 +780,49 @@ test "a session's display budget can be narrowed, and a nonsense one is refused" try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, 0, 4, 17)); } +test "programmer mode's width, signedness and byte order cross JNI, and keep the display budget" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + const ok = @intFromEnum(c_api.Status.ok); + const refused = @intFromEnum(c_api.Status.invalid_argument); + try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 4, 17)); + + // 16-bit, unsigned, little-endian: each of the three is visible in one result. + try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_configureProgrammer(&jvm.env, null, handle, 16, 0, 0)); + jvm.supplied = "0x8001"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 1); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"bits\":16") != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"hex\":\"01 80\"") != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"pattern\":\"8001\"") != null); + // Unsigned: `>>` fills with zeros. + jvm.supplied = "0x8000 >> 15"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 1); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"pattern\":\"1\"") != null); + + // Signed: the same shift copies the sign bit. Any nonzero jboolean is true. + try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_configureProgrammer(&jvm.env, null, handle, 16, 2, 1)); + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 1); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"pattern\":\"ffff\"") != null); + + // The display budget set before is still there. + jvm.supplied = "2 / 3"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"0.6667\"") != null); + + // Widths the engine has no name for, and no session, change nothing. + for ([_]jint{ 24, 0, -8, 70000 }) |bits| { + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureProgrammer(&jvm.env, null, handle, bits, 1, 1)); + } + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureProgrammer(&jvm.env, null, 0, 64, 1, 1)); + jvm.supplied = "0x8000 >> 15"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 1); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"pattern\":\"ffff\"") != null); +} + test "a call with no session is refused instead of dereferencing zero" { var jvm: FakeJvm = undefined; jvm.init(); diff --git a/engine/test/c_abi_test.c b/engine/test/c_abi_test.c index 45c49c4..0c2c880 100644 --- a/engine/test/c_abi_test.c +++ b/engine/test/c_abi_test.c @@ -108,6 +108,7 @@ int main(void) { check_contains(json, len, "\"dec_signed\":\"-1\"", "0xFF read as 8-bit signed is -1, so bits and is_signed both landed"); check_contains(json, len, "\"bits\":8", "the width reached the result"); + check_contains(json, len, "\"pattern\":\"ff\"", "the pattern to hold, masked and unsigned"); config.bits = 24; check(tally_session_configure(session, &config) == TALLY_INVALID_ARGUMENT, diff --git a/engine/test/java/dev/lerch/tally/TallyEngine.java b/engine/test/java/dev/lerch/tally/TallyEngine.java index 4505d73..98aaeb4 100644 --- a/engine/test/java/dev/lerch/tally/TallyEngine.java +++ b/engine/test/java/dev/lerch/tally/TallyEngine.java @@ -39,6 +39,8 @@ public final class TallyEngine { private static native int configureDisplay(long handle, int fractionDigits, int significantDigits); + private static native int configureProgrammer(long handle, int bits, boolean signed, boolean bigEndian); + private static native String sessionSave(long handle); private static native int sessionLoad(long handle, String state); @@ -64,7 +66,7 @@ public final class TallyEngine { } public static void main(String[] args) { - check(abiVersion() == 5, "ABI version"); + check(abiVersion() == 6, "ABI version"); check("0.1.0".equals(versionString()), "version string crosses as a Java string"); long session = sessionNew(); @@ -107,6 +109,14 @@ public final class TallyEngine { checkContains(eval(session, "2 / 3", 0), "\"display\":\"0.6667\"", "the display budget took"); check(configureDisplay(session, -1, 17) == 3, "a negative budget is refused"); + // Programmer mode's configuration: an int and two booleans in. 8-bit unsigned + // little-endian reads 0x1FF as 0xFF, and the shift fills with zeros. + check(configureProgrammer(session, 8, false, false) == 0, "configureProgrammer"); + checkContains(eval(session, "0x1FF >> 4", 1), "\"pattern\":\"f\"", "width and signedness took"); + check(configureProgrammer(session, 32, true, false) == 0, "configureProgrammer, little-endian"); + checkContains(eval(session, "0xDEADBEEF", 1), "\"hex\":\"EF BE AD DE\"", "byte order took"); + check(configureProgrammer(session, 24, true, true) == 3, "a width with no name is refused"); + // Save one session, load it into another: the variable comes across exactly. checkContains(eval(session, "saved = 1/3", 0), "\"ok\":true", "assign before save"); String state = sessionSave(session); diff --git a/include/tally.h b/include/tally.h index a5cfc65..5000313 100644 --- a/include/tally.h +++ b/include/tally.h @@ -30,7 +30,7 @@ extern "C" { /* Value of tally_abi_version() this header was written against. Compare at startup: * a prebuilt library can fall out of step with the code calling it. */ -#define TALLY_ABI_VERSION 5u +#define TALLY_ABI_VERSION 6u /* How a call ended. Evaluation failing is a normal outcome (TALLY_EVAL_ERROR) with the * reason in the JSON, not an exceptional one. @@ -91,7 +91,11 @@ tally_status tally_session_config(tally_session *session, tally_config *out); * On TALLY_OK and TALLY_EVAL_ERROR, *out_ptr and *out_len describe JSON borrowed from the * session until its next call; see tally_status for the other two. The length is * authoritative; the bytes also carry a NUL just past it, so a caller whose next step - * wants a C string (printf, JNI's NewStringUTF) does not have to copy them first. */ + * wants a C string (printf, JNI's NewStringUTF) does not have to copy them first. + * + * A programmer-mode result carries "rows" (every base, for reading) and, since ABI + * version 6, "pattern": the value masked to the width as lowercase hex with no prefix + * or grouping, for a caller that holds the value rather than displaying it. */ tally_status tally_eval(tally_session *session, const uint8_t *expr_ptr, size_t expr_len,