programmer mode in Android

This commit is contained in:
Emil Lerch 2026-10-04 08:29:53 -07:00
parent a9b7e0fb1d
commit 1f7cc9226e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
20 changed files with 1670 additions and 78 deletions

View file

@ -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 <x |
| 4 5 6 * / |
| 1 2 3 + - |
| 0 ( ) = |
+-----------------------------------------+
```
**Key interactions:**
- Tap any bit in the grid to toggle it - all displays update instantly
- Haptic pulse on bit toggle
- Tap DEC/HEX/OCT/BIN label to make that the primary input base
- Bit grid scrolls horizontally for 64-bit (or collapses to 4 rows of 16)
- FAB or toolbar button for "Struct Layout" sub-screen
**Two things on screen: an expression and a value.** The value is a register - a
128-bit pattern, as the TUI's `prog_value` is - and every row and the grid show it.
The expression is engine syntax, written by the pad, exactly as on the standard screen.
- **Live, as on the standard screen.** While the expression is not empty, the rows and
the grid show its preview. Programmer evaluation reads no environment, so a preview
is the evaluation. When the expression stops evaluating mid-edit (`0xDEAD <<`), they
keep the last value it had, dimmed, rather than flashing back to the register: the
user is still typing and nothing they did changed the value. With nothing typed they
show the register.
- **`=` puts the answer in the register.** The expression is kept above the answer,
dimmed, until the next key, as on the standard screen. An error on `=` is shown in
place and the expression is kept.
- **The expression line is never blank while the register holds a value.** With
nothing typed it shows, dimmed, the expression `=` last put there or, failing that -
after a bit tap, after CLR took a half-typed expression, or after a restart that only
had the value - the register itself as a literal in the input base (`0x9CE6...`). It
is blank only when the register is zero. The first build left it blank in those
cases, and on the phone that read as the value not having been saved, though it had.
- **There is no `Ans`** in programmer evaluation (it takes no environment). The pad
writes the register in instead: whenever that line is showing, an operator starts
from the value as a literal in the input base (`0xF0 and `), NOT wraps it (`~0xF0`), a
digit starts fresh, and backspace brings the line back for editing - the standard
screen's rules after `=`, with a literal where that screen has `Ans`.
- **A tap on the grid edits what is shown.** With nothing typed it flips a bit of the
register. With an expression showing its preview, the tap is `=` and then the flip,
so the flip lands on the value the user was looking at; the line then shows the new
value. While the grid is dimmed it does not answer taps: what it shows is not a value
anything holds.
- **CLR** clears the expression; with the expression already empty, it zeroes the
register (a calculator's CE then C). Long-press on backspace clears both.
- **Backspace removes an operator whole.** Operators are written with their spaces
(` and `, ` << `) because the keyword forms need them; backspace after one deletes all
of it rather than costing five taps.
**The input base is chosen by tapping a row's label** - HEX, DEC(s) or DEC(u) (both
mean decimal), OCT, or BIN above the grid - and is highlighted in place; nothing moves.
A digit that starts a number is written with the base's prefix (`0x`, `0o`, `0b`; none
for decimal), so typing `F F` in hex gives `0xFF`. A digit next to a number continues it,
whatever the base, so editing an existing literal works. Digits the base does not have
stay where they are and are dimmed. Everything typed is visible engine syntax, so a
shift distance typed in hex reads `<< 0x10`, not a silent 16.
**Width is a lens, as in the TUI.** Narrowing never masks the register: its rows and
grid show the low bits, a warning on the BIN line says bits are hidden, and widening
brings them back. Any edit - a bit, `=`, CLR - commits to the width on screen. The
engine renders every value: Kotlin sends the register as a masked hex literal and the
configuration (`configureProgrammer`, ABI 6), and reads the rows and the pattern back.
The pattern is a new `pattern` field in the programmer JSON - lowercase hex, unsigned,
masked, no grouping - so Kotlin never parses a display string to learn a value.
**Signedness and byte order** are toggles in the strip, as in the TUI: signedness
decides `>>` 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)

View file

@ -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)

View file

@ -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.

View file

@ -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 ->

View file

@ -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"

View file

@ -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()

View file

@ -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 -> {}
}
}

View file

@ -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 ->

View file

@ -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) },

View file

@ -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() },
)
}
}
}

View file

@ -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<List<Key>> = 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<List<Key>> = 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<Int> {
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,
)
}
}

View file

@ -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<String?>(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<TallyResult?>(null)
/** The expression's value, and the key it was computed for. */
private var preview by mutableStateOf<TallyResult?>(null)
private var previewFor by mutableStateOf<Key?>(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<TallyResult?>(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()
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

32
android/audit-libs.sh Executable file
View file

@ -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"

View file

@ -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.

View file

@ -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();

View file

@ -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,

View file

@ -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);

View file

@ -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,