programmer mode in Android
This commit is contained in:
parent
a9b7e0fb1d
commit
1f7cc9226e
20 changed files with 1670 additions and 78 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
258
android/app/src/main/java/dev/lerch/tally/Programmer.kt
Normal file
258
android/app/src/main/java/dev/lerch/tally/Programmer.kt
Normal 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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
508
android/app/src/main/java/dev/lerch/tally/ProgrammerScreen.kt
Normal file
508
android/app/src/main/java/dev/lerch/tally/ProgrammerScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
186
android/app/src/main/java/dev/lerch/tally/ProgrammerViewModel.kt
Normal file
186
android/app/src/main/java/dev/lerch/tally/ProgrammerViewModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
android/app/src/test/java/dev/lerch/tally/ProgrammerTest.kt
Normal file
242
android/app/src/test/java/dev/lerch/tally/ProgrammerTest.kt
Normal 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
32
android/audit-libs.sh
Executable 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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue