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
|
leaving the session as it was, and an allocation-failure sweep over save and load. ABI
|
||||||
version 5.
|
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
|
## 7. CLI Design
|
||||||
|
|
||||||
|
|
@ -2204,9 +2224,10 @@ need.
|
||||||
|
|
||||||
## 9. Android UI Design
|
## 9. Android UI Design
|
||||||
|
|
||||||
The shell (9.1) and every screen but standard (9.3-9.7) are still DESIGN ONLY. 9.2 was
|
The shell (9.1), standard (9.2), programmer (9.3) and convert (9.6) are built; struct
|
||||||
rewritten after the first build ran on a device, which is the input the rest of this
|
layout, financial and tablet (9.4, 9.5, 9.7) are still DESIGN ONLY. 9.2 was rewritten
|
||||||
section was waiting for.
|
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
|
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
|
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
|
### 9.1 Navigation & Shell
|
||||||
|
|
||||||
**Built: a hamburger drawer, as in NCalc.** Calculator, Convert, and Settings below a
|
**Built: a hamburger drawer, as in NCalc.** Calculator, Programmer, Convert, and
|
||||||
divider, under the app name and the engine version (which crossed JNI to be there).
|
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
|
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,
|
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
|
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.
|
keypad's accent bands light containers in dark mode.
|
||||||
|
|
||||||
The original plan, kept for the record: a bottom navigation bar with 4 destinations,
|
The original plan, kept for the record: a bottom navigation bar with 4 destinations,
|
||||||
**Standard**, **Programmer**, **Financial**, **Convert**. Programmer and Financial are
|
**Standard**, **Programmer**, **Financial**, **Convert**. Financial is not built, and
|
||||||
not built, and will be drawer entries when they are.
|
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
|
### 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 ────────────────────┐ │
|
| = Programmer |
|
||||||
│ │ [ 8 ] [ 16 ] [ 32 ] [●64] │ │ ← segmented control
|
| [8][16][32][64][128] signed BE | <- settings strip, fixed positions
|
||||||
│ └──────────────────────────────────┘ │
|
|-----------------------------------------|
|
||||||
│ │
|
| 0xDEAD << 4 | 3| | <- expression, cursor, no keyboard
|
||||||
│ ┌─── Value Display ────────────────┐ │
|
| = 0xDEAD3 (dim) | <- live preview, in the input base
|
||||||
│ │ DEC 4,294,967,295 │ │ ← tappable to switch primary
|
| DEC(s) 912,083 |
|
||||||
│ │ HEX 0xFFFF_FFFF │ │
|
| DEC(u) 912,083 | <- every row is the shown value
|
||||||
│ │ OCT 0o37_777_777_777 │ │
|
| [HEX] 00 00 00 00 00 0D EA D3 | <- the input base, highlighted in place
|
||||||
│ │ BIN 1111 1111 1111 1111 11.. │ │
|
| OCT 0 000 000 000 000 003 365 323 |
|
||||||
│ │ SIGN -1 (signed) │ │
|
| ASCII . . . . . . . . |
|
||||||
│ └──────────────────────────────────┘ │
|
| BIN (warning, if any) |
|
||||||
│ │
|
| 63 0000 0000 0000 0000 | <- bit grid: 16 per row, bit 0 always
|
||||||
│ ┌─── Bit Grid ─────────────────────┐ │
|
| 47 0000 0000 0000 0000 | bottom right; tap toggles
|
||||||
│ │ 31 30 29 28 │ 27 26 25 24 │ │ ← bit index labels
|
| 31 0000 0000 0000 1101 |
|
||||||
│ │ 1 1 1 1 │ 1 1 1 1 │ │ ← tappable cells
|
| 15 1110 1010 1101 0011 |
|
||||||
│ │ 23 22 21 20 │ 19 18 17 16 │ │
|
|-----------------------------------------|
|
||||||
│ │ 1 1 1 1 │ 1 1 1 1 │ │
|
| AND OR XOR NOT mod | <- operator band
|
||||||
│ │ 15 14 13 12 │ 11 10 9 8 │ │
|
| << >> >>> ROL ROR |
|
||||||
│ │ 1 1 1 1 │ 1 1 1 1 │ │
|
|-----------------------------------------|
|
||||||
│ │ 7 6 5 4 │ 3 2 1 0 │ │
|
| A B C D E F | <- dimmed where the base has no such digit
|
||||||
│ │ 1 1 1 1 │ 1 1 1 1 │ │
|
| 7 8 9 CLR <x |
|
||||||
│ └──────────────────────────────────┘ │
|
| 4 5 6 * / |
|
||||||
│ │
|
| 1 2 3 + - |
|
||||||
│ ┌─── Operators ────────────────────┐ │
|
| 0 ( ) = |
|
||||||
│ │ 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 │ × │ ÷ │ ⌫ │ │ │
|
|
||||||
│ └────┴────┴────┴────┴────┴────┘ │ │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key interactions:**
|
**Two things on screen: an expression and a value.** The value is a register - a
|
||||||
- Tap any bit in the grid to toggle it - all displays update instantly
|
128-bit pattern, as the TUI's `prog_value` is - and every row and the grid show it.
|
||||||
- Haptic pulse on bit toggle
|
The expression is engine syntax, written by the pad, exactly as on the standard screen.
|
||||||
- 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)
|
- **Live, as on the standard screen.** While the expression is not empty, the rows and
|
||||||
- FAB or toolbar button for "Struct Layout" sub-screen
|
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)
|
### 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
|
- Decided: clearing history keeps variables and `Ans`. Replaying history on launch was
|
||||||
the alternative, and rejected - see design 6.7.
|
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)
|
- 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)
|
- 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
|
- 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)
|
- TUI smoke tests (script key sequences, verify screen output via libvaxis test mode if available)
|
||||||
- Android instrumented tests for JNI bridge
|
- 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`):
|
- Forgejo Actions workflow (at `git.lerch.org/lobo/tally`):
|
||||||
- Use mise to provision toolchains (same `.mise.toml` as local dev)
|
- Use mise to provision toolchains (same `.mise.toml` as local dev)
|
||||||
- Build + test on Linux, macOS, Windows
|
- Build + test on Linux, macOS, Windows
|
||||||
|
|
@ -2261,7 +2324,9 @@ mi, backspace gave `98.` miles.
|
||||||
- Android: produce signed APK/AAB
|
- Android: produce signed APK/AAB
|
||||||
|
|
||||||
### Task 7.4: Documentation
|
### 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
|
- `CONTRIBUTING.md`: dev setup, architecture overview, how to add an ABI profile
|
||||||
- CLI `--help` text finalized
|
- CLI `--help` text finalized
|
||||||
- Struct DSL syntax reference (in README or separate doc)
|
- Struct DSL syntax reference (in README or separate doc)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
# Tally for Android
|
# Tally for Android
|
||||||
|
|
||||||
Two screens, reached from a hamburger drawer: the standard calculator (design 9.2), an
|
Three 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
|
NCalc-style pad with a live answer as you type; programmer mode (design 9.3), every base
|
||||||
reopens where it was left and shows a value in every unit at once. Settings has the
|
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
|
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
|
`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
|
## 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
|
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.
|
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:
|
The rules the screens follow - the converter's memory, programmer mode's input and
|
||||||
it is the ABI phones actually use, and the only one where the page-size bug in design 6.3
|
register, the saved calculator state - are plain Kotlin with JVM tests that need no
|
||||||
showed up.
|
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
|
## What is deliberately not here
|
||||||
|
|
||||||
|
|
@ -67,4 +80,4 @@ showed up.
|
||||||
is the point of having one engine.
|
is the point of having one engine.
|
||||||
- **No error table.** Messages come from `engine.phrase` through the JSON.
|
- **No error table.** Messages come from `engine.phrase` through the JSON.
|
||||||
- **No unit list.** `TallySession.unitCatalog()` returns the engine's own tables, aliases
|
- **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
|
@Test
|
||||||
fun aPreviewAnswersAndStoresNothing() {
|
fun aPreviewAnswersAndStoresNothing() {
|
||||||
TallySession().use { session ->
|
TallySession().use { session ->
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import android.content.Context
|
||||||
internal enum class ThemeChoice { System, Light, Dark }
|
internal enum class ThemeChoice { System, Light, Dark }
|
||||||
|
|
||||||
/** The screens the app opens on. Settings is reachable but never reopened at launch. */
|
/** 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
|
* What survives the app being closed: the theme, the screen it was on, and each screen
|
||||||
* converter exactly as it was left.
|
* exactly as it was left.
|
||||||
*
|
*
|
||||||
* `SharedPreferences` rather than DataStore: a handful of small values, written on the
|
* `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.
|
* 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)
|
get() = prefs.getString(KEY_CONVERTER, null)
|
||||||
set(value) = prefs.edit().putString(KEY_CONVERTER, value).apply()
|
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 calculator (design 9.2), each part as [CalcCodec] writes it --
|
||||||
|
|
||||||
/** The engine's variables and `Ans`, as `tally_session_save` wrote them. */
|
/** 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_THEME = "theme"
|
||||||
const val KEY_SCREEN = "screen"
|
const val KEY_SCREEN = "screen"
|
||||||
const val KEY_CONVERTER = "converter"
|
const val KEY_CONVERTER = "converter"
|
||||||
|
const val KEY_PROGRAMMER = "programmer"
|
||||||
const val KEY_CALC_ENGINE = "calc.engine"
|
const val KEY_CALC_ENGINE = "calc.engine"
|
||||||
const val KEY_CALC_HISTORY = "calc.history"
|
const val KEY_CALC_HISTORY = "calc.history"
|
||||||
const val KEY_CALC_COMMITTED = "calc.committed"
|
const val KEY_CALC_COMMITTED = "calc.committed"
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,8 @@ private fun handle(model: TallyViewModel, action: Action) {
|
||||||
Action.Clear -> model.clear()
|
Action.Clear -> model.clear()
|
||||||
Action.Equals -> model.commit()
|
Action.Equals -> model.commit()
|
||||||
Action.Keyboard -> model.startTyping()
|
Action.Keyboard -> model.startTyping()
|
||||||
// The converter's key; the calculator has `-` for that.
|
// The converter's key, and programmer mode's; the calculator has `-` for a sign.
|
||||||
Action.Sign -> {}
|
Action.Sign, is Action.Digit, is Action.Prefix -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ private fun handle(model: TallyViewModel, action: Action) {
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun KeyboardGate(allow: Boolean, content: @Composable () -> Unit) {
|
internal fun KeyboardGate(allow: Boolean, content: @Composable () -> Unit) {
|
||||||
InterceptPlatformTextInput(
|
InterceptPlatformTextInput(
|
||||||
interceptor = { request, next ->
|
interceptor = { request, next ->
|
||||||
if (allow) next.startInputMethod(request) else awaitCancellation()
|
if (allow) next.startInputMethod(request) else awaitCancellation()
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ private fun handle(model: ConvertViewModel, action: Action) {
|
||||||
Action.Backspace -> model.backspace()
|
Action.Backspace -> model.backspace()
|
||||||
Action.Clear -> model.clear()
|
Action.Clear -> model.clear()
|
||||||
Action.Sign -> model.toggleSign()
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
|
@ -59,6 +60,12 @@ internal sealed interface Action {
|
||||||
|
|
||||||
/** The converter's `+/-`: flip the sign of the whole value. */
|
/** The converter's `+/-`: flip the sign of the whole value. */
|
||||||
data object Sign : Action
|
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 }
|
internal enum class Tone { Digit, Operator, Function, Clear, Equals }
|
||||||
|
|
@ -233,6 +240,8 @@ internal fun KeyBlock(
|
||||||
onStore: (String) -> Unit,
|
onStore: (String) -> Unit,
|
||||||
onClearAll: () -> Unit,
|
onClearAll: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/** Keys that stay in place but do nothing, dimmed: digits a base does not have. */
|
||||||
|
enabled: (Key) -> Boolean = { true },
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -248,6 +257,7 @@ internal fun KeyBlock(
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onStore = onStore,
|
onStore = onStore,
|
||||||
onClearAll = onClearAll,
|
onClearAll = onClearAll,
|
||||||
|
enabled = enabled(key),
|
||||||
modifier = Modifier.weight(key.weight).fillMaxHeight(),
|
modifier = Modifier.weight(key.weight).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +273,7 @@ private fun KeyButton(
|
||||||
onAction: (Action) -> Unit,
|
onAction: (Action) -> Unit,
|
||||||
onStore: (String) -> Unit,
|
onStore: (String) -> Unit,
|
||||||
onClearAll: () -> Unit,
|
onClearAll: () -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
|
@ -274,6 +285,8 @@ private fun KeyButton(
|
||||||
Tone.Clear -> colors.errorContainer to colors.onErrorContainer
|
Tone.Clear -> colors.errorContainer to colors.onErrorContainer
|
||||||
Tone.Equals -> colors.primary to colors.onPrimary
|
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:
|
// Long-press does something only where there is something worth hiding behind it:
|
||||||
// storing into a variable, and clearing everything from backspace.
|
// storing into a variable, and clearing everything from backspace.
|
||||||
|
|
@ -292,10 +305,12 @@ private fun KeyButton(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(3.dp)
|
.padding(3.dp)
|
||||||
|
.alpha(alpha)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(container)
|
.background(container)
|
||||||
.semantics { key.spoken?.let { contentDescription = it } }
|
.semantics { key.spoken?.let { contentDescription = it } }
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
|
enabled = enabled,
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
onLongClickLabel = longPressLabel,
|
onLongClickLabel = longPressLabel,
|
||||||
onLongClick = longPress?.let { action ->
|
onLongClick = longPress?.let { action ->
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,11 @@ private fun AppShell(app: AppViewModel) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, bottom = 16.dp),
|
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(
|
NavigationDrawerItem(
|
||||||
label = { Text(label) },
|
label = { Text(label) },
|
||||||
selected = app.screen == target,
|
selected = app.screen == target,
|
||||||
|
|
@ -165,6 +169,7 @@ private fun AppShell(app: AppViewModel) {
|
||||||
onMenu = openMenu,
|
onMenu = openMenu,
|
||||||
onConvert = { app.open(Screen.Convert) },
|
onConvert = { app.open(Screen.Convert) },
|
||||||
)
|
)
|
||||||
|
Screen.Programmer -> ProgrammerScreen(onMenu = openMenu)
|
||||||
Screen.Convert -> ConvertScreen(
|
Screen.Convert -> ConvertScreen(
|
||||||
onMenu = openMenu,
|
onMenu = openMenu,
|
||||||
onCalculator = { app.open(Screen.Calculator) },
|
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
|
const val MODE_PROGRAMMER = 1
|
||||||
|
|
||||||
/** The ABI this Kotlin was written against; see [checkAbi]. */
|
/** 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
|
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. */
|
/** Narrow or widen a session's rendering. Returns the `tally_status`, 0 for ok. */
|
||||||
external fun configureDisplay(handle: Long, fractionDigits: Int, significantDigits: Int): Int
|
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. */
|
/** The session's variables and `Ans`, exactly, as JSON; null if it could not be made. */
|
||||||
external fun sessionSave(handle: Long): String?
|
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). */
|
/** The variables and `Ans`, exactly, for keeping across a restart (design 6.7). */
|
||||||
fun save(): String? {
|
fun save(): String? {
|
||||||
check(handle != 0L) { "session used after close" }
|
check(handle != 0L) { "session used after close" }
|
||||||
|
|
@ -166,6 +180,11 @@ internal data class TallyResult(
|
||||||
val bases: Bases? = null,
|
val bases: Bases? = null,
|
||||||
val rows: Rows? = null,
|
val rows: Rows? = null,
|
||||||
val bits: Int? = 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. */
|
/** The engine's JSON exactly as it came back, which is what history saves. */
|
||||||
val raw: String = "",
|
val raw: String = "",
|
||||||
) {
|
) {
|
||||||
|
|
@ -223,6 +242,7 @@ internal data class TallyResult(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bits = if (root.has("bits")) root.optInt("bits") else null,
|
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
|
/// 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
|
/// 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.
|
/// 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
|
/// How a call ended. The evaluation itself failing is a normal outcome with a message
|
||||||
/// in the JSON, not an exceptional one.
|
/// in the JSON, not an exceptional one.
|
||||||
|
|
@ -297,6 +297,9 @@ pub const Session = struct {
|
||||||
return self.writeError(err);
|
return self.writeError(err);
|
||||||
};
|
};
|
||||||
const rows = rowsOf(scratch, int, config) catch |err| 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(.{
|
return self.writeJson(.{
|
||||||
.ok = true,
|
.ok = true,
|
||||||
|
|
@ -304,6 +307,10 @@ pub const Session = struct {
|
||||||
.exact = true,
|
.exact = true,
|
||||||
.truncated = false,
|
.truncated = false,
|
||||||
.bits = config.width.bits(),
|
.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,
|
.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, "\"dec_unsigned\":\"255\"") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, json, "\"bin\":\"1111 1111\"") != null);
|
try testing.expect(std.mem.indexOf(u8, json, "\"bin\":\"1111 1111\"") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, json, "\"bits\":8") != 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.
|
// Byte order is a display choice and it reaches the rows.
|
||||||
config = .{ .bits = 32, .big_endian = 0 };
|
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.
|
// A caller that does not want the length may pass null.
|
||||||
_ = tally_version(null);
|
_ = tally_version(null);
|
||||||
// 2 added `tally_preview`, 3 `tally_convert`, 4 the catalogue's `label`, 5 save and
|
// 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
|
// load, 6 the programmer result's `pattern` (and JNI's `configureProgrammer`). A
|
||||||
// discovered missing in use.
|
// library without what the app calls must be refused at startup, not discovered
|
||||||
try testing.expectEqual(@as(u32, 5), tally_abi_version());
|
// missing in use.
|
||||||
|
try testing.expectEqual(@as(u32, 6), tally_abi_version());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a session and hand back the JSON.
|
/// 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));
|
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.
|
/// Called by the runtime when the library loads.
|
||||||
///
|
///
|
||||||
/// This is where the hand-written table is proved against the JVM that is actually
|
/// 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);
|
_ = Java_dev_lerch_tally_TallyEngine_versionString(&jvm.env, null);
|
||||||
try testing.expectEqualStrings("0.1.0", jvm.madeString());
|
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" {
|
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));
|
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" {
|
test "a call with no session is refused instead of dereferencing zero" {
|
||||||
var jvm: FakeJvm = undefined;
|
var jvm: FakeJvm = undefined;
|
||||||
jvm.init();
|
jvm.init();
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ int main(void) {
|
||||||
check_contains(json, len, "\"dec_signed\":\"-1\"",
|
check_contains(json, len, "\"dec_signed\":\"-1\"",
|
||||||
"0xFF read as 8-bit signed is -1, so bits and is_signed both landed");
|
"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, "\"bits\":8", "the width reached the result");
|
||||||
|
check_contains(json, len, "\"pattern\":\"ff\"", "the pattern to hold, masked and unsigned");
|
||||||
|
|
||||||
config.bits = 24;
|
config.bits = 24;
|
||||||
check(tally_session_configure(session, &config) == TALLY_INVALID_ARGUMENT,
|
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 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 String sessionSave(long handle);
|
||||||
|
|
||||||
private static native int sessionLoad(long handle, String state);
|
private static native int sessionLoad(long handle, String state);
|
||||||
|
|
@ -64,7 +66,7 @@ public final class TallyEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
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");
|
check("0.1.0".equals(versionString()), "version string crosses as a Java string");
|
||||||
|
|
||||||
long session = sessionNew();
|
long session = sessionNew();
|
||||||
|
|
@ -107,6 +109,14 @@ public final class TallyEngine {
|
||||||
checkContains(eval(session, "2 / 3", 0), "\"display\":\"0.6667\"", "the display budget took");
|
checkContains(eval(session, "2 / 3", 0), "\"display\":\"0.6667\"", "the display budget took");
|
||||||
check(configureDisplay(session, -1, 17) == 3, "a negative budget is refused");
|
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.
|
// Save one session, load it into another: the variable comes across exactly.
|
||||||
checkContains(eval(session, "saved = 1/3", 0), "\"ok\":true", "assign before save");
|
checkContains(eval(session, "saved = 1/3", 0), "\"ok\":true", "assign before save");
|
||||||
String state = sessionSave(session);
|
String state = sessionSave(session);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ extern "C" {
|
||||||
|
|
||||||
/* Value of tally_abi_version() this header was written against. Compare at startup:
|
/* 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. */
|
* 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
|
/* How a call ended. Evaluation failing is a normal outcome (TALLY_EVAL_ERROR) with the
|
||||||
* reason in the JSON, not an exceptional one.
|
* 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
|
* 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
|
* 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
|
* 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,
|
tally_status tally_eval(tally_session *session,
|
||||||
const uint8_t *expr_ptr,
|
const uint8_t *expr_ptr,
|
||||||
size_t expr_len,
|
size_t expr_len,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue