Compare commits
228
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599d33287c | ||
|
|
b53a17c436 | ||
|
|
de92fccba5 | ||
|
|
25370731d0 | ||
|
|
5428cd75c9 | ||
|
|
8c6e2ed9cf | ||
|
|
4bc23172fd | ||
|
|
e5fee03da8 | ||
|
|
5ece49b8d9 | ||
|
|
e212ed8d02 | ||
|
|
f5183af306 | ||
|
|
fffed42f9e | ||
|
|
0aa03cf621 | ||
|
|
ae0af8f5e3 | ||
|
|
18c5f9aaac | ||
|
|
a428cba41a | ||
|
|
3c7d3db370 | ||
|
|
77cee6a8fa | ||
|
|
43a3a345e4 | ||
|
|
9bf714fa2e | ||
|
|
42d54eec95 | ||
|
|
4ccfda6b8e | ||
|
|
09778346a0 | ||
|
|
6d5a231f5c | ||
|
|
e9a6562dc6 | ||
|
|
afbc2ad132 | ||
|
|
1318e149f5 | ||
|
|
4fdabc39d0 | ||
|
|
bf8658c404 | ||
|
|
00e0a63887 | ||
|
|
b7474f61b0 | ||
|
|
8e5928cc6a | ||
|
|
76fcbdccb9 | ||
|
|
a00376994e | ||
|
|
ba57086361 | ||
|
|
1a9655414e | ||
|
|
5e34dba2fd | ||
|
|
cc8148cbec | ||
|
|
a2e5e5881c | ||
|
|
c121bc0725 | ||
|
|
fe7dc9c728 | ||
|
|
02b277e7ad | ||
|
|
fc82d9d7e8 | ||
|
|
9e301f30c6 | ||
|
|
341b7a5922 | ||
|
|
c8785b6091 | ||
|
|
9c560e3492 | ||
|
|
e5a90c6135 | ||
|
|
af1b0c5ab2 | ||
|
|
cce4b28324 | ||
|
|
94d8373289 | ||
|
|
8310431497 | ||
|
|
b863f9f3df | ||
|
|
cdeb7b0857 | ||
|
|
476609e1d3 | ||
|
|
2756087e1c | ||
|
|
af7d5f3782 | ||
|
|
db0a41a7cd | ||
|
|
b9924e7617 | ||
|
|
f014e8d9cf | ||
|
|
0ccc444246 | ||
|
|
c6da735134 | ||
|
|
4f6ec3a900 | ||
|
|
38bf6309cb | ||
|
|
3eb0e033d5 | ||
|
|
b8ea723718 | ||
|
|
7485d78d50 | ||
|
|
b87f5a597e | ||
|
|
80a75c128e | ||
|
|
50e69995b6 | ||
|
|
85869d02f8 | ||
|
|
f99ae4c366 | ||
|
|
203f53470c | ||
|
|
b38e797db3 | ||
|
|
92985ba8e3 | ||
|
|
181ba64606 | ||
|
|
a6a100edc6 | ||
|
|
ff1d6ea932 | ||
|
|
2b20bb2c91 | ||
|
|
3c80d9d696 | ||
|
|
06b8a1f4b0 | ||
|
|
e10582a2cd | ||
|
|
551c01398f | ||
|
|
7e79ec11e0 | ||
|
|
2ec0fee84c | ||
|
|
992c472975 | ||
|
|
729098756d | ||
|
|
d8562d96a3 | ||
|
|
22210a42f5 | ||
|
|
ade572973a | ||
|
|
9b27e858b5 | ||
|
|
7e4e26a335 | ||
|
|
84a13e806b | ||
|
|
452c44249f | ||
|
|
238057ad5e | ||
|
|
896c93a59a | ||
|
|
690161e5e9 | ||
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 | ||
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 | ||
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 | ||
|
|
03c6be80a3 | ||
|
|
4afc453faa | ||
|
|
1aab61bf26 | ||
|
|
dc01f88d75 | ||
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 | ||
|
|
560a74caf8 | ||
|
|
fd7e17523d | ||
|
|
c7682297fa | ||
|
|
27511302f2 | ||
|
|
184a6c5b33 | ||
|
|
5b2ca039f1 | ||
|
|
a8d24553d5 | ||
|
|
3b80a88f3b | ||
|
|
d8e6bc6e9b | ||
|
|
b887a96765 | ||
|
|
2aaa3733c3 | ||
|
|
46246ea511 | ||
|
|
a27fbdb029 | ||
|
|
c07d544aeb | ||
|
|
46d3a6fd41 | ||
|
|
5655fa8093 | ||
|
|
b3b1d47dd6 | ||
|
|
50fe4828a2 | ||
|
|
800da46188 | ||
|
|
00767eed4d | ||
|
|
683db4908a | ||
|
|
8d23a20792 | ||
|
|
d01c105037 | ||
|
|
88631f5e8b | ||
|
|
e6924298bc | ||
|
|
68b48cfd14 | ||
|
|
e6c884a0cd | ||
|
|
a6cb9a9082 | ||
|
|
0be6a571c4 | ||
|
|
bfe93c4188 | ||
|
|
5b7dc0e4e2 | ||
|
|
621f08d725 | ||
|
|
e49d0e606f | ||
|
|
e2a1fadbec | ||
|
|
0e4629361b | ||
|
|
1e7b1cddb7 | ||
|
|
470f8e5019 | ||
|
|
cf10b17c5b | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
9fa09b0af1 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
6bdec6e785 | ||
|
|
4821a02bd3 | ||
|
|
1ff662c7c3 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
127b25e60a |
No files matched your search
+7
-3
@@ -1,6 +1,10 @@
|
|||||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||||
# every crate in this repo into one workspace -- they are deliberately
|
# every crate in this repo into one workspace -- they are deliberately
|
||||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
# independent (see scripts/run-tests.sh, which cds into each).
|
||||||
# from the repo root runs xtask/src/main.rs directly.
|
#
|
||||||
|
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
|
||||||
|
# The manifest path is relative to the working directory cargo is run from,
|
||||||
|
# so the root is where it works; this file is found from any directory
|
||||||
|
# inside the checkout, but the path inside it is not.
|
||||||
[alias]
|
[alias]
|
||||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
---
|
||||||
|
name: ai-app-rigs
|
||||||
|
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ai-app: rigs, harnesses and measurements
|
||||||
|
|
||||||
|
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
|
||||||
|
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
|
||||||
|
that file cost on every one. Unchanged in the move, and still the only copy.
|
||||||
|
|
||||||
|
## The rigs
|
||||||
|
|
||||||
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||||
|
and data directory, holding eight invented Claude Code transcripts and a
|
||||||
|
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||||
|
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||||||
|
real agent transcripts, so exercising *delete* against the ordinary server
|
||||||
|
deletes somebody's conversation and exercising *import* starts a real
|
||||||
|
`--resume` on the owner's account.
|
||||||
|
Its port and root derive from the checkout's name, so two checkouts'
|
||||||
|
sandboxes cannot reach each other, and its token is generated once into
|
||||||
|
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||||||
|
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||||||
|
start banner prints the command) and stays enrolled. It shares the real TLS
|
||||||
|
certificates, because the installed APK pins that CA.
|
||||||
|
Driving verbs, so none of this is re-derived per session:
|
||||||
|
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||||
|
`./ui-sandbox.sh send SID text|@file`, and
|
||||||
|
`./ui-sandbox.sh api /path [curl args]`.
|
||||||
|
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||||||
|
enrolment already there — for when the fixture under test was expensive to
|
||||||
|
build; plain `start` wipes them, which is right for the list-screen
|
||||||
|
fixtures and wrong for that.
|
||||||
|
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||||||
|
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||||||
|
fake CLI slow to start. Both exist because operations that finish in
|
||||||
|
milliseconds have states on the way that nothing can observe, and an
|
||||||
|
unobservable state is one where broken and working look identical.
|
||||||
|
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||||||
|
explorer, holding the states otherwise only reachable by finding a real
|
||||||
|
machine in one: an empty directory, a name with a tab and one with an
|
||||||
|
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||||||
|
symlink to a directory and a broken one, a source file per language, and
|
||||||
|
the three sizes the limits were measured against (`edit-32k.rs`,
|
||||||
|
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||||||
|
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||||
|
The explorer's 409 is produced by editing the file on the machine
|
||||||
|
(`printf … > file`) between pressing the pencil and pressing save.
|
||||||
|
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||||||
|
echo driver is the right rig for most things and the wrong one for anything
|
||||||
|
whose cost scales with what was actually written: a real reply is longer,
|
||||||
|
is real markdown, and carries tool calls whose input and output are
|
||||||
|
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||||||
|
page of history landing mid-fling threw the reader back to the newest end,
|
||||||
|
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||||||
|
`-b` takes the biggest conversation on the machine rather than the newest,
|
||||||
|
which is what a scrolling test wants; `--stop` takes it down.
|
||||||
|
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||||||
|
own, so the import can only see the copy — importing spawns `claude
|
||||||
|
--resume`, and against the real file that is a second CLI writing to a
|
||||||
|
conversation somebody may still be in. **A transcript never goes in this
|
||||||
|
repository**: they hold whatever was said, read and written in that
|
||||||
|
session, and `~/repos` is shared with the host besides.
|
||||||
|
- **`/usage` in an echo session puts up an invented meter**, which is how the
|
||||||
|
rate-limit screens' states are reached without spending quota: `/usage 42`,
|
||||||
|
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
|
||||||
|
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
|
||||||
|
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
|
||||||
|
`usage::Fixture`'s, since those are its states. With none set an echo
|
||||||
|
session meters nothing, which is the ordinary case and draws no bar.
|
||||||
|
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||||
|
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||||
|
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||||
|
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||||
|
signal. So adopt, stop, restart and start are all drivable without a real
|
||||||
|
`--resume` and without spending a turn on somebody's account. Reach for
|
||||||
|
this when what is under test is *whether a process is running*, and for
|
||||||
|
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||||
|
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||||
|
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||||
|
gesture loop, and prints the app's render report — the same one the in-app
|
||||||
|
copy button produces, whose `on screen:` line names what the viewport was
|
||||||
|
holding. Compare two runs with the same gestures; the emulator's absolute
|
||||||
|
frame times transfer nothing, the report's accounting does. Run it either
|
||||||
|
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||||||
|
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||||||
|
that move first are the worst `record: one block`, the reparse mean while
|
||||||
|
streaming, and the draw phase's accounting line.
|
||||||
|
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||||||
|
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||||||
|
resets the report, sends FILE, waits for the transcript to stop growing,
|
||||||
|
and prints. Both of those are corrections to a first version that measured
|
||||||
|
nothing: a transcript parked further back never redraws while a reply
|
||||||
|
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||||||
|
for idle answers before the turn has started.
|
||||||
|
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||||||
|
framework, from `atrace` text output with no trace processor needed. It is
|
||||||
|
how the cost of a layout node per link was attributed to the framework
|
||||||
|
rather than guessed at.
|
||||||
|
|
||||||
|
### Driving the UI
|
||||||
|
|
||||||
|
**No script that drives this app's UI presses a coordinate.** Every control
|
||||||
|
is found by the name it already carries for assistive technology —
|
||||||
|
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
|
||||||
|
against the screen at the moment of the gesture and fails the whole run when
|
||||||
|
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
|
||||||
|
coordinate is a position measured once by hand, and anything that moves the
|
||||||
|
control makes the tap land on whatever now sits there — the bench then
|
||||||
|
reports a number that was never measured, which reads exactly like a result.
|
||||||
|
Both bench scripts pressed the render report at `tap 723 205` until that
|
||||||
|
button moved into the session settings dialog on 2026-09-03. The check that
|
||||||
|
none has crept back:
|
||||||
|
|
||||||
|
grep -n "tap [0-9]" app/*.sh
|
||||||
|
|
||||||
|
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||||
|
is a distance rather than a control.
|
||||||
|
|
||||||
|
**Two traps in the emulator bench loop**, each of which cost a run.
|
||||||
|
`adb shell pm clear` removes the enrolment and the notification permission
|
||||||
|
along with the saved anchors, so the next run measures a permission dialog —
|
||||||
|
re-enrol with the command `ui-sandbox.sh` prints, and
|
||||||
|
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
|
||||||
|
so the only way two builds start a scroll from the same place is a *fresh
|
||||||
|
session for each*.
|
||||||
|
|
||||||
|
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||||||
|
`emu up` creates and boots the AVD named after this checkout — whatever `emu
|
||||||
|
name` prints, never a name typed out here, since this file is the same in
|
||||||
|
every clone. `run-android.sh` is that plus a build and an install. The `adb`
|
||||||
|
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
|
||||||
|
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
|
||||||
|
script from `emulator-tools` runs `emu check` before `installDebug`,
|
||||||
|
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
|
||||||
|
to every attached device; when it refuses, say which device you mean at the
|
||||||
|
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
||||||
|
|
||||||
|
### Testing llama.cpp and ssh here
|
||||||
|
|
||||||
|
**Both are set up here as of 2026-09-04** and need nothing typed. The
|
||||||
|
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
|
||||||
|
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
|
||||||
|
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
|
||||||
|
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
|
||||||
|
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
|
||||||
|
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
|
||||||
|
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
|
||||||
|
this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||||
|
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||||
|
broken driver — `llama-cli` produces the same from the file directly, which
|
||||||
|
is how to tell the two apart in a hurry.
|
||||||
|
|
||||||
|
There is no second machine, so **ssh this VM to itself**. That is set up
|
||||||
|
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||||
|
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||||
|
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||||
|
`identityFile` plus
|
||||||
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||||
|
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
||||||
|
whole rig for "does a remote llama session work", since the far machine is
|
||||||
|
this one and the model file is the same file. For a throwaway setup of your
|
||||||
|
own, point a provider's `command` at something harmless like `/bin/echo`
|
||||||
|
rather than at `claude`: the transport is what is under test, the process
|
||||||
|
exiting immediately is the signal, and it costs no tokens. The remote login
|
||||||
|
shell here is **fish**; the
|
||||||
|
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||||||
|
both, but that is luck rather than design, and a shell that is neither is the
|
||||||
|
thing to suspect first if a remote spawn ever mangles an argument.
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
The import list reports each session's **size as well as its line count**,
|
||||||
|
because the two disagree in the way that matters: these transcripts embed
|
||||||
|
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
||||||
|
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
|
||||||
|
count tells you what continuing a session will cost. Shown, not warned about;
|
||||||
|
importing a large session is a choice somebody is entitled to make.
|
||||||
|
|
||||||
|
**Never import a Claude Code session that is open in a terminal.** The app
|
||||||
|
refuses it — see PLAN.md for the incident that made that a refusal rather
|
||||||
|
than a warning.
|
||||||
|
|
||||||
|
**One Claude Code session id can name two files, and the listing offers it
|
||||||
|
once.** Resuming from a different working directory makes the CLI write a
|
||||||
|
second transcript with the same id under that directory's project folder — an
|
||||||
|
ordinary state of a machine, not corruption. Everything downstream addresses
|
||||||
|
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||||||
|
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||||||
|
the copy with the most lines, because the other is usually a few-hundred-byte
|
||||||
|
stub and is often the *newer* of the two, so recency is the wrong key.
|
||||||
|
Deleting removes every copy rather than the first, or the row came back after
|
||||||
|
a delete that reported success. The phone's half is `uniqueItems`, which
|
||||||
|
every list keyed on a server-chosen id goes through: a repeat there must
|
||||||
|
never be able to close the app, whatever produced it.
|
||||||
|
|
||||||
|
**Deleting a session offers to take the machine's own transcript with it** —
|
||||||
|
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||||
|
confirmation, and only where the driver keeps a record of its own
|
||||||
|
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||||||
|
because leaving that copy is what makes an ordinary delete recoverable — and
|
||||||
|
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||||||
|
since the sentence promising the conversation "should still be there to
|
||||||
|
import again" is exactly the one the switch makes false. The server deletes
|
||||||
|
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||||||
|
where it was instead of half-deleted.
|
||||||
|
|
||||||
|
## Measurements worth not re-taking
|
||||||
|
|
||||||
|
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||||
|
emulator against a real imported transcript with the server at
|
||||||
|
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||||
|
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||||
|
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||||
|
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||||
|
is *not* at the floor is the first few seconds after opening a session,
|
||||||
|
where every row on the way is being composed for the first time; that is
|
||||||
|
inherent to a lazy list and it is why a measurement taken before the screen
|
||||||
|
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||||
|
- **The reset path is not reachable by reopening a session.** Measured
|
||||||
|
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||||
|
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||||
|
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||||
|
opening page, then one span covering the whole distance. To exercise the
|
||||||
|
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||||
|
the app takes the reset on a live connection, clears, refills and carries
|
||||||
|
on without reconnecting.
|
||||||
|
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||||
|
the launcher while 415 events were produced brought no reconnect at all,
|
||||||
|
which is not what the comment above that loop expects, and is most likely
|
||||||
|
this emulator being headless rather than the phone's behaviour.
|
||||||
|
- **Reopening a cached session costs one request for one event** (the probe),
|
||||||
|
and scrolling the whole conversation back costs nothing more; a cold open
|
||||||
|
of the same 500-event session is two pages, 100 events. Measured
|
||||||
|
2026-09-04 on the emulator against the sandbox.
|
||||||
|
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||||
|
28,000-line file because it draws one row per line; the editor is one
|
||||||
|
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||||
|
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||||
|
screen. If you make the editor faster, that number is what to move.
|
||||||
|
EXPLORER.md's "What the measurements said" has the rest.
|
||||||
+5
-3
@@ -64,9 +64,11 @@ components: [
|
|||||||
// defaults to this checkout's root, which both the `cargo xtask`
|
// defaults to this checkout's root, which both the `cargo xtask`
|
||||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
|
||||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
|
||||||
// both need.
|
// module doc) both need. The publish directory is `scripts/build`
|
||||||
|
// rather than `scripts/xtask/build` for exactly that reason: the
|
||||||
|
// pattern is one directory deep, and the tool moved two on 2026-09-09.
|
||||||
Apk(
|
Apk(
|
||||||
name: "shell",
|
name: "shell",
|
||||||
modes: ["release", "debug"],
|
modes: ["release", "debug"],
|
||||||
|
|||||||
+6
-9
@@ -9,8 +9,7 @@ local.properties
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
server/target/
|
||||||
event-model/target/
|
event-model/target/
|
||||||
client-core/target/
|
app-rust/target/
|
||||||
android-shell/target/
|
|
||||||
|
|
||||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||||
@@ -32,11 +31,9 @@ sessions/
|
|||||||
|
|
||||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||||
iris/target/
|
iris/target/
|
||||||
iris/android-app/target/
|
|
||||||
|
|
||||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
|
||||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
|
||||||
# The repo root has no Cargo workspace, so this is xtask's own
|
# publishes for Dev Updater to find.
|
||||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
scripts/xtask/target/
|
||||||
xtask/target/
|
scripts/rigs/gpu-probe/target/
|
||||||
/target/
|
|
||||||
@@ -5,32 +5,92 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
|||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||||
between them.
|
between them.
|
||||||
|
|
||||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||||||
its rationale, and what was rejected. Read it before changing anything
|
date, its rationale, and what was rejected. Read it before changing anything
|
||||||
structural, and update it in place when a decision changes rather than
|
structural, and update it in place when a decision changes rather than
|
||||||
letting this file and the plan become two versions of the truth. This file is
|
letting this file and the plan become two versions of the truth. This file is
|
||||||
the working notes layer: layout, commands, rigs, and things that have bitten.
|
the working notes layer: layout, commands, rigs, and things that have bitten.
|
||||||
|
The design and working documents live under `docs/` — everything except this
|
||||||
|
file and `CLAUDE.md`, which stay at the root because that is where Claude
|
||||||
|
Code and other agent harnesses look for them.
|
||||||
|
|
||||||
The central design point, worth not undoing by accident: **a session is a
|
The central design point, worth not undoing by accident: **a session is a
|
||||||
child process, translated into one common event model.** A new session type
|
child process, translated into one common event model.** A new session type
|
||||||
is a new driver — never a session-type branch in shared code (routes,
|
is a new driver — never a session-type branch in shared code (routes,
|
||||||
transcript, app screens).
|
transcript, app screens).
|
||||||
|
|
||||||
|
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||||||
|
and a planned desktop app share almost all of their code.** Screens,
|
||||||
|
widgets, folding, paging, config and the network client live in
|
||||||
|
`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
|
||||||
|
and `src/desktop` are thin entry points that own only what the platform
|
||||||
|
forces (JNI and the IME on one side, winit and argv on the other). The two
|
||||||
|
*layouts* will differ, to suit a phone's screen and a finger against a
|
||||||
|
desktop's screen and a mouse -- but the widgets a layout is made of (a
|
||||||
|
button, a text field, a list, a card) and the styling (colours, spacing,
|
||||||
|
type) are one implementation with no per-platform copy. Anything that could
|
||||||
|
work on both goes in `ui` the first time it is written, and a platform
|
||||||
|
module growing a widget or a colour is a defect to move, not a convenience
|
||||||
|
to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details.
|
||||||
|
|
||||||
|
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
|
||||||
|
nothing else.** Nothing in it may know about a session, a transcript, a
|
||||||
|
setup or a server; anything that does belongs in `app-rust/`, and the
|
||||||
|
dependency runs one way only. The port's project code is **one crate**
|
||||||
|
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
|
||||||
|
"One app crate" for what forced each of the splits that were removed and
|
||||||
|
the two that remain.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||||||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
||||||
Module-by-module intent is in PLAN.md's "Backend layout".
|
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
|
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
|
||||||
|
which is the whole reason it is a crate of its own rather than part of
|
||||||
|
either: it is the contract between them, so the two agree by construction.
|
||||||
|
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
|
||||||
|
client` is everything with no UI in it (the REST and SSE clients, the
|
||||||
|
transcript cache and fold, the highlighter, the ANSI parser, config and
|
||||||
|
the enrolment link); `src/ui` is the screens as iris widget trees;
|
||||||
|
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
|
||||||
|
is the `android-view` entry point and `android-project/` its Gradle app;
|
||||||
|
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
|
||||||
|
Features pick which face a build is: `screens` (default) for anything
|
||||||
|
that draws, `shell` for the Compose app's bridge, `bench` for P0's
|
||||||
|
fixture build. See its `Cargo.toml` header.
|
||||||
|
- `iris/` — the UI framework, and **only** the UI framework: `core`,
|
||||||
|
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
|
||||||
|
and `rig-input`. It must not mention anything this product is about.
|
||||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||||
the Keystore-sealed token.
|
the Keystore-sealed token.
|
||||||
|
- `scripts/` — everything at the root that was neither a program nor a
|
||||||
|
document: the three repo-wide shell scripts (`run-tests.sh`,
|
||||||
|
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
|
||||||
|
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
|
||||||
|
profiling rigs), and `xtask/`. **A project's own scripts
|
||||||
|
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
|
||||||
|
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
|
||||||
|
top level sh files").
|
||||||
|
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
|
||||||
|
convention: an ordinary Rust binary that does build work a shell script
|
||||||
|
would otherwise do, run as `cargo xtask apk` **from the repo root**
|
||||||
|
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
|
||||||
|
the working directory). It packages `app/shellApp` without Gradle
|
||||||
|
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
|
||||||
|
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
|
||||||
|
which is where Dev Updater looks. There is deliberately **no `target/`
|
||||||
|
at the repo root** any more: there is no workspace there, and what used
|
||||||
|
to be one was only xtask's own scratch space, now in
|
||||||
|
`scripts/xtask/target/`.
|
||||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||||
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
||||||
@@ -40,19 +100,61 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
projects version-locked to the commit this repo pins. What deliberately did
|
projects version-locked to the commit this repo pins. What deliberately did
|
||||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||||
sessions and setups are what makes this project itself.
|
sessions and setups are what makes this project itself.
|
||||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
- `docs/` — every design and working document except this file and
|
||||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
`CLAUDE.md`:
|
||||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||||
and stream effects in `SessionScreen.kt`.
|
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||||||
- `TODO.md` — the working list.
|
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||||
- `RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
the opening and stream effects in `SessionScreen.kt`.
|
||||||
branch of the `ai-app-2` clone): what has to be reproduced, the
|
- `docs/TODO.md` — the working list.
|
||||||
framework decision, and the ordered experiments with their pass
|
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
|
||||||
conditions. Read it before touching anything under that branch.
|
phone's view, and the choices behind the shape.
|
||||||
|
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||||||
|
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||||
|
framework decision, and the ordered experiments with their pass
|
||||||
|
conditions. Read it before touching anything under that branch.
|
||||||
|
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
|
||||||
|
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
|
||||||
|
design, its texture-atlas design, and the design of `app-rust`'s
|
||||||
|
`client` module, respectively.
|
||||||
|
|
||||||
|
**These documents are pruned as the work lands, not appended to
|
||||||
|
forever** (Iris, 2026-09-08: *"remove everything that's already done and
|
||||||
|
decided… many with checkboxes already ticked off that just fill up
|
||||||
|
context"*). A ticked box, a finished experiment and a completed review
|
||||||
|
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
|
||||||
|
a finished document is removed rather than archived in place. What
|
||||||
|
survives is what cannot be cheaply re-derived — measurements, dead ends
|
||||||
|
and failed hypotheses, invariants and their reasons, and the design of
|
||||||
|
what exists now rather than the route to it.
|
||||||
|
|
||||||
|
**There is no decisions log and no design log, and one should not be
|
||||||
|
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
|
||||||
|
2026-09-09 at Iris's instruction: *"I've decided to instead make
|
||||||
|
decisions when planning with agents rather than after they do things,
|
||||||
|
and they're both too long for me to wanna read, + don't cover all the
|
||||||
|
decisions I'll wanna make about the code anyways. I'll just naturally
|
||||||
|
run into things for now."* So raise a choice **while planning it with
|
||||||
|
her**, when the direction is still cheap to change; otherwise decide it,
|
||||||
|
put the reasoning at the code it governs, and carry on. TODO lists are
|
||||||
|
still wanted — a list of open work is useful, a list of finished work
|
||||||
|
is not.
|
||||||
|
- `docs/SCROLL.md` — how anything in iris scrolls: one
|
||||||
|
`ScrollController` holds the position, the gesture, the fling and the
|
||||||
|
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
|
||||||
|
one each through the `Scrollable` trait. Read it before touching
|
||||||
|
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
|
||||||
|
pans, flings or lays out a long list.
|
||||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here), the Compose app and the shell APK, in
|
||||||
|
parallel. **It does not publish either benchmark APK.** Phone benchmark
|
||||||
|
builds live in the separate `~/repos/ai-app-bench` repository: build here,
|
||||||
|
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
|
||||||
|
path, then commit and push that repo. Dev Updater pulls the committed APK
|
||||||
|
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
|
||||||
|
file points at
|
||||||
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
||||||
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
||||||
offer them. Note what deleting the config directory takes with it: the CA
|
offer them. Note what deleting the config directory takes with it: the CA
|
||||||
@@ -75,18 +177,43 @@ the **Mono** face, where every glyph is one em square, which is what makes
|
|||||||
two icon buttons the same width without either being given one — and why
|
two icon buttons the same width without either being given one — and why
|
||||||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
`GLYPH_SIZE` is smaller than it looks like it should be.
|
||||||
|
|
||||||
|
**The Rust app does the same, from its own subset**:
|
||||||
|
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
|
||||||
|
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
|
||||||
|
with `Family::Icons`. Same rule about the two lists agreeing (there is a
|
||||||
|
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
|
||||||
|
Design family so an icon means the same thing in both apps. Its subset is
|
||||||
|
separate rather than shared because subsetting only what one app draws is
|
||||||
|
the point. This is the **only** font iris bundles — body and monospace
|
||||||
|
text come from the platform (decided 2026-09-07), and an icon
|
||||||
|
is the opposite case: a small closed set of codepoints no system font is
|
||||||
|
guaranteed to have.
|
||||||
|
|
||||||
## Checking your work
|
## Checking your work
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
- **Commit completed work.** Once a coherent piece of work has passed its
|
||||||
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
|
relevant checks and has no known major issue or unresolved design decision,
|
||||||
stays warning-clean and rustfmt-clean at the defaults — there is no
|
commit it rather than leaving it in the worktree. Keep independently
|
||||||
`rustfmt.toml` and there should not be one.
|
completed slices in separate commits.
|
||||||
|
|
||||||
|
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
|
||||||
|
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
|
||||||
|
own suite, which is slower and not about this product. Each workspace
|
||||||
|
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
|
||||||
|
warning-clean and rustfmt-clean at the defaults — there is no
|
||||||
|
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
|
||||||
|
UI profiling rig use the rolling nightly channel through per-directory
|
||||||
|
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
|
||||||
- **App**: from `app/`,
|
- **App**: from `app/`,
|
||||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||||||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
||||||
pure logic with no Android in it.
|
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
|
||||||
|
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
|
||||||
|
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
|
||||||
|
type compiles separately and lint has caught real bugs debug alone never
|
||||||
|
would (see "Android Lint" below).
|
||||||
- **Android Lint is not optional and is not run by a build.** It found a
|
- **Android Lint is not optional and is not run by a build.** It found a
|
||||||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||||||
desugaring off) and later a permission check that silently dropped every
|
desugaring off) and later a permission check that silently dropped every
|
||||||
@@ -137,7 +264,7 @@ two icon buttons the same width without either being given one — and why
|
|||||||
whether it was continued or reset (`stream backlog:`). That is the only
|
whether it was continued or reset (`stream backlog:`). That is the only
|
||||||
place "how far had this phone fallen behind" is answerable — the app sees a
|
place "how far had this phone fallen behind" is answerable — the app sees a
|
||||||
window arrive and cannot tell.
|
window arrive and cannot tell.
|
||||||
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
||||||
network namespaces inside one machine and drives the server through it — a
|
network namespaces inside one machine and drives the server through it — a
|
||||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||||
involved. That is how to verify the wg0-only posture.
|
involved. That is how to verify the wg0-only posture.
|
||||||
@@ -146,6 +273,27 @@ two icon buttons the same width without either being given one — and why
|
|||||||
|
|
||||||
Each exists because something was invisible without it.
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||||||
|
and the 2026-09-05 decisions), the phone benchmark gate Iris
|
||||||
|
asked for before porting continues: a deterministic, checked-in synthetic
|
||||||
|
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
||||||
|
this app and iris open with no server, so a frame-time comparison
|
||||||
|
measures the renderer rather than the data. `./build-apk.sh bench` builds
|
||||||
|
it — own application id (`com.example.aiapp.bench`) and label ("AI
|
||||||
|
Sessions bench") so it installs beside a real enrollment rather than
|
||||||
|
replacing it. Opening it goes straight to a session screen holding the
|
||||||
|
fixture (no enrollment, no permission prompts) with a "Run benchmark"
|
||||||
|
control beside "Copy" in session settings: it drives the same scroll loop
|
||||||
|
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
|
||||||
|
`ui-trace`, but in-process, since a real phone has no usable system
|
||||||
|
tracing and no agent can drive one (this-machine-android's skill).
|
||||||
|
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
|
||||||
|
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
|
||||||
|
requests from an in-memory copy of the fixture instead of opening a
|
||||||
|
socket — so the fold, the paging and `uniqueItems` under test are the
|
||||||
|
screen's real ones, never a shortcut built just for this. The report
|
||||||
|
gains a `bench:` section (process CPU time, peak RSS, battery current) on
|
||||||
|
every build, empty except when `BenchRun.kt` filled it in.
|
||||||
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||||
and data directory, holding eight invented Claude Code transcripts and a
|
and data directory, holding eight invented Claude Code transcripts and a
|
||||||
`claude` that is two lines of shell. **That isolation is the point**: the
|
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||||
@@ -226,6 +374,113 @@ Each exists because something was invisible without it.
|
|||||||
framework, from `atrace` text output with no trace processor needed. It is
|
framework, from `atrace` text output with no trace processor needed. It is
|
||||||
how the cost of a layout node per link was attributed to the framework
|
how the cost of a layout node per link was attributed to the framework
|
||||||
rather than guessed at.
|
rather than guessed at.
|
||||||
|
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
|
||||||
|
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
|
||||||
|
straight into `android-project/app/src/main/jniLibs/`) and its APK
|
||||||
|
(Gradle, from `android-project/`) in one step and verifies the result
|
||||||
|
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
|
||||||
|
installs it on this checkout's own emulator, taps "Run benchmark" by
|
||||||
|
label, and prints the report -- written so the P0
|
||||||
|
build/install/tap/read-report cycle stops being retyped by hand each
|
||||||
|
time (docs/RUST.md's P0 box). It passes `--no-default-features`, so
|
||||||
|
`--features` alone decides what is in the `.so`; that is what keeps the
|
||||||
|
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
|
||||||
|
build is published only by replacing
|
||||||
|
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
|
||||||
|
and pushing the **bench repository**, not this checkout.
|
||||||
|
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
|
||||||
|
the commands and what each cannot answer): test at the cheapest one
|
||||||
|
that can answer the question. `cd app-rust && cargo test` runs the real
|
||||||
|
transcript screen over the bench fixture with **no window, no
|
||||||
|
compositor and no GPU** (`iris::harness`), on a clock the test owns and
|
||||||
|
a gesture replayed from a `t_ms action x y` file under
|
||||||
|
`app-rust/touch/` -- which is how the batched 120Hz
|
||||||
|
flick a finger actually makes is testable at all, since a `ui-trace`
|
||||||
|
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
|
||||||
|
--dir ../app-rust --shot …` opens the same screen in a window at the
|
||||||
|
phone's own size and density for looking at, and `--replay FILE` drives
|
||||||
|
the same recording into it (`--dir` names the workspace to build in,
|
||||||
|
since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface
|
||||||
|
lifecycle and one verification run before a build goes to the phone --
|
||||||
|
not for iterating on layout.
|
||||||
|
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
|
||||||
|
a crate of their own so a rig's dependencies stay out of the app's
|
||||||
|
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
|
||||||
|
dependencies and such don't get mixed"*). Run either from that
|
||||||
|
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
|
||||||
|
neither runs them nor can fail on them, and both need **release or the
|
||||||
|
numbers mean nothing**.
|
||||||
|
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
|
||||||
|
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
|
||||||
|
--nocapture`. Two runs: a fling over the bench
|
||||||
|
fixture eight times out and back, and a reply streaming into it one
|
||||||
|
event at a time. Text shaping dominates, which is why the profile is
|
||||||
|
meaningless unoptimised. It cannot answer anything about the GPU, the
|
||||||
|
swapchain or the phone's own clock.
|
||||||
|
|
||||||
|
What it established on 2026-09-09, worth not re-deriving. A **fling**
|
||||||
|
is not CPU-bound: only about one frame in six lays anything out (the
|
||||||
|
rest are moved on the GPU through `move_offsets`), and the
|
||||||
|
multi-millisecond spikes are all in the *first* pass over a stretch of
|
||||||
|
transcript -- every later pass over the same rows is p99 0.26ms. A
|
||||||
|
**streamed event** is, and it is not where it looks: folding the event
|
||||||
|
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
|
||||||
|
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
|
||||||
|
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
|
||||||
|
bitten"; measuring it is what ruled it out.) That frame is one
|
||||||
|
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
|
||||||
|
is 14,888 characters in a single block** -- against a largest-ever
|
||||||
|
1,580 across 7,706 blocks of real replies. So the stream phase's number
|
||||||
|
is a property of the fixture, not of streaming; docs/RUST.md's
|
||||||
|
"Incremental text" has the measurements and why parley cannot help.
|
||||||
|
|
||||||
|
The last two runs (`where_a_streamed_deltas_cost_is`,
|
||||||
|
`what_the_fixture_streams`) exist to keep that answerable: what a delta
|
||||||
|
costs to re-split and re-compare, and what the fixture actually
|
||||||
|
streams.
|
||||||
|
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
|
||||||
|
of a frame layer 1 builds and never performs, and so the half
|
||||||
|
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
|
||||||
|
array per frame, and the point of the rig is that no two of them alone
|
||||||
|
are honest: **floor** (entries whose bytes actually differ, found by
|
||||||
|
diffing), **uploaded** (what iris really writes, read from the same
|
||||||
|
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
|
||||||
|
old code wrote whenever anything changed). A gap between the first two
|
||||||
|
is over-marking; one was 122x and invisible until both were printed
|
||||||
|
side by side.
|
||||||
|
|
||||||
|
What it established on 2026-09-09, and what the three optimisations it
|
||||||
|
drove were. Uploading the whole arena on any change cost **758 MB over
|
||||||
|
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
|
||||||
|
frame. Three things were wrong and each is now guarded by this rig:
|
||||||
|
`ArrBuf` reallocated on every length change, so adding one glyph made
|
||||||
|
the buffer's contents undefined and forced a full rewrite; a redraw
|
||||||
|
freed its primitives and pushed new ones, which -- since freed slots
|
||||||
|
are only reusable next frame and provisional layout nested -- grew
|
||||||
|
the arena to **127,443 slots for 11,569 live primitives**; and nothing
|
||||||
|
tracked *which* entries changed. Now: the stream arena is 11,569 slots
|
||||||
|
for 11,569 live, and every array uploads within a hair of its floor.
|
||||||
|
The CPU half improved with it, since the freeing and renumbering
|
||||||
|
went away: a streamed frame is p50 1.39ms, from 2.20ms.
|
||||||
|
|
||||||
|
The remaining layout cost was then removed at the framework boundary:
|
||||||
|
`Painter::set_child_offset` gives a container one retained coordinate slot
|
||||||
|
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
|
||||||
|
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
|
||||||
|
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
|
||||||
|
`Primitives` also cancels dirty marks for provisional writes restored before
|
||||||
|
upload, so CPU-only layout states never become GPU work.
|
||||||
|
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
|
||||||
|
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
|
||||||
|
in software -- while its GLES *is* the host's real GPU through virgl at
|
||||||
|
ES 3.1, so an ordinary build's runtime fallback lands there by itself
|
||||||
|
and nothing should pass `force-gles` to arrange it. The Vulkan path is
|
||||||
|
verified on the desktop build and on Iris's phone. Do not boot the
|
||||||
|
emulator with SwiftShader Vulkan to "test the Vulkan path": that
|
||||||
|
measures a software rasteriser and steers iris away from the one
|
||||||
|
hardware-accelerated backend it has there. Every run says which adapter
|
||||||
|
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
|
||||||
|
that line before reading a number.
|
||||||
|
|
||||||
### Driving the UI
|
### Driving the UI
|
||||||
|
|
||||||
@@ -291,13 +546,11 @@ thing to suspect first if a remote spawn ever mangles an argument.
|
|||||||
|
|
||||||
## Where things run (host vs this VM)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
This checkout runs in a VM while production runs on its host:
|
||||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
|
||||||
means here:
|
|
||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
address the phone can reach is, and where WireGuard terminates.
|
||||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
`scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
- **The tunnel and the real phone can never terminate in the VM**, because
|
||||||
nothing outside can open a connection into it. Phone bring-up is host work.
|
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||||
@@ -312,7 +565,7 @@ means here:
|
|||||||
## Sessions outlive the backend
|
## Sessions outlive the backend
|
||||||
|
|
||||||
Since 2026-08-29 a session's process is deliberately left running when
|
Since 2026-08-29 a session's process is deliberately left running when
|
||||||
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
|
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
|
||||||
day to day:
|
day to day:
|
||||||
|
|
||||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
- **Stopping the server no longer stops the sessions.** After `pkill
|
||||||
@@ -345,7 +598,7 @@ count tells you what continuing a session will cost. Shown, not warned about;
|
|||||||
importing a large session is a choice somebody is entitled to make.
|
importing a large session is a choice somebody is entitled to make.
|
||||||
|
|
||||||
**Never import a Claude Code session that is open in a terminal.** The app
|
**Never import a Claude Code session that is open in a terminal.** The app
|
||||||
refuses it — see PLAN.md for the incident that made that a refusal rather
|
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
|
||||||
than a warning.
|
than a warning.
|
||||||
|
|
||||||
**One Claude Code session id can name two files, and the listing offers it
|
**One Claude Code session id can name two files, and the listing offers it
|
||||||
@@ -385,8 +638,7 @@ where it was instead of half-deleted.
|
|||||||
|
|
||||||
## Things that have bitten
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
Project-specific only; keep cross-project machine notes out of this file.
|
||||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||||
@@ -521,4 +773,4 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|||||||
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||||
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||||
screen. If you make the editor faster, that number is what to move.
|
screen. If you make the editor faster, that number is what to move.
|
||||||
EXPLORER.md's "What the measurements said" has the rest.
|
docs/EXPLORER.md's "What the measurements said" has the rest.
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
# client-core
|
|
||||||
|
|
||||||
`client-core/` is the app's pure logic held once instead of twice, per
|
|
||||||
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
|
|
||||||
framework dependency of any kind, so it can outlive whichever one the app
|
|
||||||
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
|
|
||||||
`event-model/` is its sibling: the wire shape both this crate and `server/`
|
|
||||||
share, extracted from `server/src/session/driver.rs` and
|
|
||||||
`session/transcript.rs` on 2026-09-04.
|
|
||||||
|
|
||||||
Neither crate is wired into anything yet. `server/` re-exports `event-model`
|
|
||||||
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
|
|
||||||
has no caller -- it exists for whichever experiment in RUST.md picks it up
|
|
||||||
next (a Masonry or iris transcript screen, most likely).
|
|
||||||
|
|
||||||
## What's here, and what Kotlin file it replaces
|
|
||||||
|
|
||||||
| `client-core/src/…` | Kotlin original | Status |
|
|
||||||
|------------------------------------------|-------------------------------------------|--------|
|
|
||||||
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
|
||||||
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
|
||||||
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
|
||||||
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
|
||||||
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
|
||||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
|
||||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
|
||||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
|
||||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
|
||||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
|
||||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
|
||||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
|
||||||
|
|
||||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
|
||||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
|
||||||
cases ported alongside it, plus new tests for the pieces that had none
|
|
||||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
|
||||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
|
||||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
|
||||||
them via `session::transcript`'s round-trip coverage).
|
|
||||||
|
|
||||||
## Correspondence notes worth knowing before touching either side
|
|
||||||
|
|
||||||
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
|
||||||
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
|
||||||
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
|
||||||
non-overlapping spans. Whatever UI framework ends up consuming this
|
|
||||||
crate maps `Style` onto its own text-styling type; nothing here should
|
|
||||||
change to accommodate a particular one.
|
|
||||||
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
|
||||||
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
|
||||||
strings. `highlight::span_text` turns a `Span` back into text for a
|
|
||||||
caller working the same way; a caller that wants byte offsets into a
|
|
||||||
`&str` has to convert.
|
|
||||||
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
|
||||||
translation bug while it was being written: an early draft let a
|
|
||||||
*damaged* chunk (one file unreadable, discard just this session) and a
|
|
||||||
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
|
||||||
the same `Err` from one closure, which would have disabled every
|
|
||||||
session's cache over a single corrupt chunk. Fixed by checking a
|
|
||||||
thread-local "was this damage" flag before deciding which failure mode
|
|
||||||
it was -- see the comment on `guard` and the commit message for
|
|
||||||
`transcript_cache.rs`.
|
|
||||||
|
|
||||||
## What `api.rs` covers, and what it does not yet
|
|
||||||
|
|
||||||
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
|
||||||
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
|
||||||
fake transport and no server). `UreqTransport` is the only real
|
|
||||||
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
|
||||||
(blocking, already a project dependency, no extra TLS crate needed since
|
|
||||||
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
|
||||||
|
|
||||||
Covered: session list/read, message send, unqueue, answer, interrupt,
|
|
||||||
stop, start, rename, cwd, model, permission-mode, notify, command,
|
|
||||||
compact, delete, and one transcript page.
|
|
||||||
|
|
||||||
**Not covered, and each is real work rather than a stub to fill in:**
|
|
||||||
setups (`/setups*`, machine and provider discovery), the file explorer
|
|
||||||
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
|
||||||
(`/models*`, HuggingFace browsing and downloads), attachments
|
|
||||||
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
|
||||||
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
|
||||||
the full table to work from when one of these is next.
|
|
||||||
|
|
||||||
## What `transcript_fold.rs` covers, and what it does not yet
|
|
||||||
|
|
||||||
`fold_event` covers every `Event` variant server/ can produce today,
|
|
||||||
including tool-call/question/image attachment and peer-message placement.
|
|
||||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
|
||||||
|
|
||||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
|
||||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
|
||||||
merges a tool call split across two fetched pages and re-merges a run a
|
|
||||||
boundary cut through. This matters the moment paging backward through
|
|
||||||
history is exercised; it is deliberately left rather than rushed, since
|
|
||||||
it is exactly the kind of boundary logic this project's own "things that
|
|
||||||
have bitten" section warns reads fine and is wrong at the edges.
|
|
||||||
|
|
||||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
|
||||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
|
||||||
A server newer than this build that adds an event type will fail to parse
|
|
||||||
that line rather than degrading to a placeholder row. Closing this means
|
|
||||||
deciding how `event_model` itself represents "a shape I don't recognise"
|
|
||||||
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
|
||||||
fix, so it is recorded here rather than silently worked around.
|
|
||||||
|
|
||||||
## `config.rs`: `EnrolledServer`
|
|
||||||
|
|
||||||
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
|
||||||
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
|
||||||
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
|
||||||
parses on the phone -- so any Rust client enrols from the same text a
|
|
||||||
phone would scan as a QR, with no second format invented for it (RUST.md's
|
|
||||||
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
|
||||||
persisted or under what file permissions -- a phone seals its token in the
|
|
||||||
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
|
||||||
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
|
||||||
caller-specific (the code rules' "ask for the least you need"). Its only
|
|
||||||
caller today is `desktop-app`; a future Android build of this crate would
|
|
||||||
be a second one, not a reason to move the type.
|
|
||||||
|
|
||||||
## What is not started at all
|
|
||||||
|
|
||||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
|
||||||
from the transcript cache or the server, and stitches the two. Needs
|
|
||||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
|
||||||
which exist now, so this is unblocked whenever picked up.
|
|
||||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
|
||||||
colours a `.md` file or fence for the highlighter, but does not build the
|
|
||||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
|
||||||
renderer walks to lay out prose versus code versus a table.
|
|
||||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
|
||||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
|
||||||
UI layer will want its own block parser or a crate for it, decided
|
|
||||||
alongside the framework choice in RUST.md.
|
|
||||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
|
||||||
it flattens a row into bounded units for a *specific* lazy-list
|
|
||||||
framework's composition cost, which is a fact about that framework
|
|
||||||
rather than about the transcript.
|
|
||||||
|
|
||||||
## Verifying
|
|
||||||
|
|
||||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
|
||||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
|
||||||
same way it always has). From `client-core/` directly: `cargo test`,
|
|
||||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Decisions taken for Iris to review
|
|
||||||
|
|
||||||
Short list of design choices made by the design agent without asking, so
|
|
||||||
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
|
||||||
for iris API changes); this file is only the summary. Newest first. Items
|
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
|
||||||
|
|
||||||
## 2026-09-05
|
|
||||||
|
|
||||||
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
|
||||||
drag pans the list immediately; a stationary press held 500 ms starts a
|
|
||||||
text selection which further dragging extends; a horizontal drag while
|
|
||||||
something is already selected extends that selection without the wait.
|
|
||||||
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
|
||||||
"text layer always wins" or "list always wins" rule because either loses
|
|
||||||
one of the two gestures a reader expects.
|
|
||||||
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
|
||||||
holding `transcript-ui`'s screen beside a session list, talking to a real
|
|
||||||
`ai-server` through `client-core`. It enrols by pasting the same
|
|
||||||
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
|
||||||
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
|
||||||
pinned CA is a path given on the command line, not baked in. Chosen so
|
|
||||||
the phone and desktop share one enrolment format and no second one is
|
|
||||||
invented.
|
|
||||||
- **Order of remaining work**: finish the two in-flight pieces above, then
|
|
||||||
the transcript screen's Android integration and the `transcript-bench.sh`
|
|
||||||
comparison against Compose — the numbers the recommendation still lacks.
|
|
||||||
- **DEFERRED — whether to commit to iris over Masonry for `ai-app`.** Waits
|
|
||||||
on the bench numbers above; RUST.md's recommendation says what the
|
|
||||||
measurements must show.
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
# iris: notable public API changes
|
|
||||||
|
|
||||||
For Iris to read on her own time. Each entry is a change to iris's public
|
|
||||||
surface that a widget author or app author would notice: a trait method
|
|
||||||
added, removed or re-shaped; a type that callers construct differently; a
|
|
||||||
capability that moved. Small and trivial changes do not go here.
|
|
||||||
|
|
||||||
An entry gives the date, what changed, why, and a short before/after where
|
|
||||||
it helps judge the change without the session that made it. Newest first.
|
|
||||||
|
|
||||||
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
|
|
||||||
|
|
||||||
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
|
|
||||||
as its last step, which is right for a window that *is* the transcript
|
|
||||||
screen (the winit example, an eventual Android cdylib) and wrong for the
|
|
||||||
desktop app, which puts a session list beside it. `build_tree` is `build`
|
|
||||||
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
|
|
||||||
of just `TranscriptScreen`, and the caller decides where the tree goes —
|
|
||||||
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
|
|
||||||
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
|
|
||||||
`build_tree` and doing the `set_root` itself, so existing callers are
|
|
||||||
unaffected.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before, and still available, for a caller that wants to *be* the window:
|
|
||||||
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
|
|
||||||
|
|
||||||
// new, for a caller embedding the screen beside something else:
|
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
|
||||||
some_widget_ptr(rsc).set(tree);
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
|
||||||
|
|
||||||
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
|
||||||
registers both a list-level pan and a row-level drag-to-select on the same
|
|
||||||
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
|
|
||||||
`run_sensors` always gives the innermost layer first refusal, so the inner
|
|
||||||
one wins every frame it is pressed, not just the frame the press started
|
|
||||||
(this is exactly what left transcript-ui's touch-drag panning unreachable
|
|
||||||
until now). `DragArbiter` is one small state machine, one instance per
|
|
||||||
gesture surface (a whole list, not per row), that a caller drives with its
|
|
||||||
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
|
|
||||||
(so it is unit-testable without a real clock or a render harness). It
|
|
||||||
decides the way Android itself does: an ordinary vertical drag pans
|
|
||||||
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
|
|
||||||
selection, which any further drag then extends; a horizontal drag while
|
|
||||||
something is already selected extends it immediately, skipping the wait.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// One per list, held alongside whatever state coordinates the rows:
|
|
||||||
let mut arbiter = DragArbiter::new();
|
|
||||||
|
|
||||||
// On press-down:
|
|
||||||
arbiter.press_start(pos, Instant::now(), already_selected);
|
|
||||||
// Every frame the button/finger stays down:
|
|
||||||
match arbiter.update(pos, Instant::now()) {
|
|
||||||
DragOutcome::Pan(dy) => list.scroll(-dy),
|
|
||||||
DragOutcome::SelectStart => selection.begin(...),
|
|
||||||
DragOutcome::SelectExtend => selection.extend(...),
|
|
||||||
DragOutcome::Undecided => {}
|
|
||||||
}
|
|
||||||
// On release:
|
|
||||||
arbiter.release();
|
|
||||||
```
|
|
||||||
|
|
||||||
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
|
|
||||||
the reference caller: every row's `CursorSense::click_or_drag() |
|
|
||||||
CursorSense::unclick()` handler routes through one `Selection`-owned
|
|
||||||
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
|
|
||||||
on a row's own rendered text now pans the list correctly instead of
|
|
||||||
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
|
|
||||||
`drag_arbiter_tests` module.
|
|
||||||
|
|
||||||
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
|
|
||||||
|
|
||||||
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
|
|
||||||
family, ...) for its whole string, applied via `push_default` into parley's
|
|
||||||
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
|
|
||||||
whichever of colour/family/font size/bold/italic/underline it overrides,
|
|
||||||
pushed with parley's own `push(property, range)` instead. Why: a transcript
|
|
||||||
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
|
|
||||||
wrapped paragraph needs each to carry its own look while the paragraph
|
|
||||||
still wraps and selects as a single buffer — the thing `masonry`'s
|
|
||||||
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
|
|
||||||
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
|
|
||||||
existed at all.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
|
|
||||||
wtext(text)
|
|
||||||
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
|
|
||||||
.editable(EditMode::MultiLine)
|
|
||||||
.add(rsc);
|
|
||||||
```
|
|
||||||
|
|
||||||
Two things a widget author should know before reaching for it:
|
|
||||||
|
|
||||||
- **Call `.spans()` before or after `.editable()`, both work** — the field
|
|
||||||
lives on `TextBuilder` itself, not either output type, and both
|
|
||||||
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
|
|
||||||
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
|
|
||||||
third `TextBuilderOutput` impl without also calling `set_spans` there
|
|
||||||
reproduces the exact bug this box shipped once already (spans silently
|
|
||||||
dropped for `TextEdit`, found only by screenshotting, not by any test —
|
|
||||||
`markdown.rs`'s own unit tests check string/range logic, which is
|
|
||||||
correct in isolation and proves nothing about whether the render path
|
|
||||||
ever sees it).
|
|
||||||
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
|
|
||||||
`color: UiColor` field (from parley's own per-run `Style::brush`), and
|
|
||||||
`Painter::glyphs` draws each glyph in its own colour instead of
|
|
||||||
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
|
|
||||||
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
|
|
||||||
tint a cursor) but no longer drives what a glyph actually renders as.
|
|
||||||
|
|
||||||
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
|
|
||||||
|
|
||||||
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
|
|
||||||
is now load-bearing: it's the one thing that puts a widget in the AccessKit
|
|
||||||
tree `iris_core::ui::access::AccessTree` builds and both backends push
|
|
||||||
out. A widget author who wants a control to be findable by name (and
|
|
||||||
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
|
|
||||||
on it; nothing else is required, and a widget nobody labels is invisible
|
|
||||||
to this system at zero cost, not just zero UI.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let button = rect(Color::LIME)
|
|
||||||
.on(CursorSense::click(), move |_, rsc| { ... })
|
|
||||||
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
|
|
||||||
```
|
|
||||||
|
|
||||||
Two new things a widget author might touch directly:
|
|
||||||
|
|
||||||
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
|
|
||||||
Override it if your widget has a real platform equivalent —
|
|
||||||
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
|
|
||||||
Only consulted for a widget that also has a `.label()`; an unlabelled
|
|
||||||
widget's `access_role` is never called.
|
|
||||||
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
|
|
||||||
with an explicit label, for anything else that wants to walk the same
|
|
||||||
set `AccessTree` does.
|
|
||||||
|
|
||||||
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
|
|
||||||
this sits entirely beside them, reading `resolved_region`'s output rather
|
|
||||||
than participating in producing it.
|
|
||||||
|
|
||||||
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
|
|
||||||
|
|
||||||
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
|
|
||||||
module doc first), for the transcript's kind of screen: variable-height
|
|
||||||
rows, keyed by a `u64`, composed only while visible, moved rather than
|
|
||||||
re-laid-out on scroll, a scroll anchor that survives a row inserted above
|
|
||||||
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
|
|
||||||
a row's height changes (`note_tap`, resolved in the layout pass).
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let mut list = List::new(Axis::Y);
|
|
||||||
list.push_back(ListRow::new(key, row_widget)); // O(1)
|
|
||||||
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
|
|
||||||
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
|
|
||||||
list.note_tap(viewport_y); // before mutating a row's height
|
|
||||||
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
|
|
||||||
```
|
|
||||||
|
|
||||||
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
|
|
||||||
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
|
|
||||||
no new mechanism was added to the render core for it. One correctness
|
|
||||||
lesson worth reading even for other widgets: a row that fills whatever
|
|
||||||
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
|
|
||||||
a throwaway oversized region and then merely `reposition`ed into place --
|
|
||||||
`reposition` only ever writes an offset, never a size, so the oversized
|
|
||||||
primitive stays oversized. `List` fixes this by caching each row's real
|
|
||||||
height once measured and placing an already-known row directly at its
|
|
||||||
exact box; see `list.rs`'s `place` for the full reasoning and
|
|
||||||
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
|
|
||||||
|
|
||||||
## 2026-09-05: a second backend (android-view), and what moved to make room for it
|
|
||||||
|
|
||||||
RUST.md's I2. Three changes a widget or app author would notice, all in
|
|
||||||
service of the same thing: `default` (winit) and the new `android`
|
|
||||||
(android-view) backends sharing what does not depend on windowing.
|
|
||||||
|
|
||||||
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
|
|
||||||
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
|
|
||||||
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
|
|
||||||
implements `FocusHost` for anything that has it — so a winit app's
|
|
||||||
existing code is unaffected. An Android app implements `FocusHost` via
|
|
||||||
`HasAndroidUiState` instead. Affects only an app that referenced
|
|
||||||
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
|
|
||||||
rather than through `.attr::<Selectable>(())`, which nothing in-tree
|
|
||||||
does.
|
|
||||||
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
|
|
||||||
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
|
|
||||||
`fn request_redraw(&self)`; `winit::window::Window` implements it
|
|
||||||
(`default/render.rs`), so `Tasks::init(window)` at a call site is
|
|
||||||
unchanged by inference. Only matters if something constructed a `Tasks`
|
|
||||||
directly rather than through `DefaultRsc`/`AndroidRsc`.
|
|
||||||
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
|
|
||||||
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
|
|
||||||
exist on Android; `android/input.rs` drives the same primitives
|
|
||||||
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
|
|
||||||
`ndk::event::Keycode` directly instead. New unconditional getters on the
|
|
||||||
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
|
|
||||||
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
|
|
||||||
`android/ime.rs`'s `InputConnection` bridge needed and that were not
|
|
||||||
previously exposed publicly.
|
|
||||||
|
|
||||||
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
|
|
||||||
|
|
||||||
A widget used to implement three methods (`draw`, `desired_width`,
|
|
||||||
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
|
|
||||||
Painter) -> Size`, which draws into `painter.region()` and returns how much
|
|
||||||
of it was used. Why: the two extra methods routinely re-simulated what
|
|
||||||
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
|
|
||||||
loop to get cross-axis sizing right) — one visit per widget per frame
|
|
||||||
instead of up to three. A container that needs a child's size before
|
|
||||||
placing it (alignment, centering) draws the child once at a provisional
|
|
||||||
region, reads the returned `Size`, and calls the new `Painter::reposition`
|
|
||||||
to move it into its final spot — an O(1) offset write, not a second draw. A
|
|
||||||
widget whose drawn output never depends on the size it's given (a
|
|
||||||
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
|
|
||||||
is_size_independent(&self) -> bool { false }` to `true`, which skips
|
|
||||||
redrawing it when only its offered region changes shape.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
|
|
||||||
// after
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
|
||||||
design, the move-offset mechanism this shipped alongside, and the file
|
|
||||||
list.
|
|
||||||
|
|
||||||
## 2026-09-04: texture pipeline rebuilt off the binding array
|
|
||||||
|
|
||||||
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
|
|
||||||
changed shape. Why: the old pipeline bound every texture ever drawn in one
|
|
||||||
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
|
|
||||||
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
|
|
||||||
and it failed outright on the Android emulator's software Vulkan. See
|
|
||||||
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
|
|
||||||
|
|
||||||
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
|
|
||||||
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
|
|
||||||
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
|
|
||||||
&queue, &config)`. Nothing replaces it — there are no more
|
|
||||||
binding-array limits to size.
|
|
||||||
- **`src/default/render.rs`'s device request asks for no features and no
|
|
||||||
binding-array limits.** Before: `required_features:
|
|
||||||
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
|
|
||||||
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
|
|
||||||
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
|
|
||||||
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
|
|
||||||
never about the binding array.
|
|
||||||
- **`TextureHandle` has no `primitive()` method any more**; a caller
|
|
||||||
outside `iris` shouldn't have been calling it (it fed the old renderer's
|
|
||||||
internals), but if something did: use `image_index()` for a standalone
|
|
||||||
image's bind-group index. There is no equivalent for a page — a page has
|
|
||||||
no bind group of its own now, see below.
|
|
||||||
- **`GlyphPrimitive` has no public constructor from a struct literal.**
|
|
||||||
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
|
|
||||||
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
|
|
||||||
flags)` — one `layer` (the shared atlas array's layer) instead of a
|
|
||||||
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
|
|
||||||
texture rather than its own bound texture.
|
|
||||||
- **A widget author drawing images is unaffected**: `Painter::texture`/
|
|
||||||
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
|
|
||||||
What changed underneath is that each standalone image now gets its own
|
|
||||||
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
|
|
||||||
invisible from the widget API, visible only in `UiRenderNode`'s internals
|
|
||||||
and in `iris`'s device requirements.
|
|
||||||
-254
@@ -1,254 +0,0 @@
|
|||||||
# iris: known problems and things still to build
|
|
||||||
|
|
||||||
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
|
||||||
matters, so the agents working through RUST.md pick these up in a sensible
|
|
||||||
order rather than rediscovering them. Each item says where it sits in the
|
|
||||||
order and what "done" looks like. Tick and date them in place.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- [x] **Input does not fall through by input type (2026-09-04).**
|
|
||||||
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
|
||||||
stop checking lower layers" from mere hover — a widget registered for
|
|
||||||
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
|
||||||
it, since "the cursor is over this widget" and "this widget handled the
|
|
||||||
event" were the same check. Fixed by judging consumption per input
|
|
||||||
kind: with no button transition and no scroll happening this frame
|
|
||||||
("momentary" activity), the topmost hovered widget still wins, same as
|
|
||||||
before; when something momentary *is* happening, only a widget whose
|
|
||||||
registered senses actually include a matching non-hover one (checked
|
|
||||||
via a new `TypeEventManager::registered`, which lists what a widget
|
|
||||||
registered without running anything) consumes it, so a widget with only
|
|
||||||
`Hovering`/click handlers can no longer block a scroll from reaching a
|
|
||||||
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
|
||||||
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
|
||||||
directions: a scroll over the button reaches the list, and a real click
|
|
||||||
still reaches the button — confirmed to fail on the pre-fix code and
|
|
||||||
pass after.
|
|
||||||
|
|
||||||
- [x] **Appending one image to an already-loaded list rebuilds every other
|
|
||||||
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
|
||||||
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
|
||||||
triggered `rebuild_image_bind_groups` — a loop over *every live
|
|
||||||
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
|
||||||
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
|
||||||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
|
||||||
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
|
||||||
every widget gets one on first draw) could be exactly what grows that
|
|
||||||
buffer. So one new message with one new image, appended to a transcript
|
|
||||||
that already has N images loaded, did not cost O(1): it cost one
|
|
||||||
`create_image` for the new image plus one `make_image_bind_group` per
|
|
||||||
*existing* image, because the new widget's own move slot pushed the
|
|
||||||
arena past its capacity. Measured directly in
|
|
||||||
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
|
||||||
already-settled ones reported **1,001** bind-group creates for that one
|
|
||||||
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
|
||||||
|
|
||||||
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
|
||||||
bind group (group 2) in the first place — the group also holds that
|
|
||||||
image's own texture view, which is the only thing that is genuinely
|
|
||||||
per-image, so a buffer shared by *everything* forced a rebuild of
|
|
||||||
*every* group the moment it moved. Gave masks/move_offsets their own
|
|
||||||
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
|
||||||
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
|
||||||
once per draw call, instead of duplicating them into every per-image
|
|
||||||
group. `GpuTextures` and its image bind groups now know nothing about
|
|
||||||
either buffer — `rebuild_image_bind_groups` is called only from
|
|
||||||
`grow_array` (the atlas array texture growing, which genuinely does
|
|
||||||
change what every image's own bind group must reference) — so a
|
|
||||||
masks/move_offsets resize now touches exactly one bind group, ever,
|
|
||||||
regardless of how many images are live. Numbers after the fix, same
|
|
||||||
benchmark and command:
|
|
||||||
|
|
||||||
./run-bench.sh images
|
|
||||||
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
|
||||||
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
|
||||||
frame=3 bind_group_creates=0
|
|
||||||
frame=4 bind_group_creates=0
|
|
||||||
(append one image here)
|
|
||||||
frame=5 bind_group_creates=1 (was 1001)
|
|
||||||
frame=6 bind_group_creates=0
|
|
||||||
|
|
||||||
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
|
||||||
confirming the bind-group restructuring changed nothing about what is
|
|
||||||
drawn.
|
|
||||||
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
|
||||||
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
|
||||||
loading 1,000 images cold used to report 1,000 creates on frame 1
|
|
||||||
(expected — `create_image`, one per new image) *and again* 1,000 on
|
|
||||||
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
|
||||||
firing a second time for the same masks/move-offsets buffer-growth
|
|
||||||
reason as the item above, confirming the guess recorded here — the two
|
|
||||||
were exactly the same root cause measured two different ways. Frame 2
|
|
||||||
now reports 0 (see the numbers above); not a separate fix.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
|
||||||
`benches/` or a script under `iris/`, never in `cargo test`). The
|
|
||||||
scenario that matters most is a **message list** — chat apps and this
|
|
||||||
app's transcript alike — stressed with many messages and many images.
|
|
||||||
One case in particular: **resizing an input box** (typing enough text to
|
|
||||||
grow it) that pushes a long list of messages above it must stay very
|
|
||||||
fast and recalculate almost nothing — a move of everything above, not a
|
|
||||||
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
|
||||||
benchmark is what proves it. Done when the numbers are in this file with
|
|
||||||
the command, and the input-box case reports draws re-run, not just frame
|
|
||||||
time.
|
|
||||||
|
|
||||||
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
|
||||||
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
|
||||||
most of this runs as an ordinary binary — the same property
|
|
||||||
`layout_tests.rs` relies on):
|
|
||||||
|
|
||||||
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
|
||||||
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
|
||||||
the file's own header for why (short version — every scenario here
|
|
||||||
reduces to a *count* `UiRenderState::take_counters` already produces,
|
|
||||||
which criterion's statistical machinery adds nothing to and which a
|
|
||||||
new dependency is not worth pulling in for). Covers (a) first-frame
|
|
||||||
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
|
||||||
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
|
||||||
of scrolling that list, 200 ticks; (c) the input-box case — a
|
|
||||||
fixed-height field at the bottom of the screen growing by a line 40
|
|
||||||
times, with the message list above it filling the rest of the screen.
|
|
||||||
Run: `cd iris && cargo bench --bench message_list` (always release —
|
|
||||||
`cargo bench` builds the `bench` profile, which is optimized).
|
|
||||||
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
|
||||||
through `iris/run-headless.sh bench_images`, printing
|
|
||||||
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
|
||||||
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
|
||||||
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
|
||||||
image rows, checked both cold (does bind-group creation reach zero
|
|
||||||
once loaded) and after appending one more image once settled (does
|
|
||||||
*that* stay cheap) — the second question is what actually matters for
|
|
||||||
a live transcript and is what turned up the two Fix items above.
|
|
||||||
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
|
||||||
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
|
||||||
chain, or `GpuTextures`.
|
|
||||||
|
|
||||||
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
|
||||||
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
|
||||||
|
|
||||||
cd iris && cargo bench --bench message_list
|
|
||||||
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
|
||||||
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
|
||||||
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
|
||||||
(b) scroll, N=100/1000/10000, 200 ticks each:
|
|
||||||
draws=200 rewrites=0 moves=200 (identical at every N)
|
|
||||||
per-tick average: 0.0002ms (identical at every N)
|
|
||||||
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
|
||||||
draws=320 rewrites=40 moves=160 (identical at every N)
|
|
||||||
per-line average: 0.0012-0.0013ms (identical at every N)
|
|
||||||
|
|
||||||
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
|
||||||
frame=1 bind_group_creates=1000 (cold load)
|
|
||||||
frame=2 bind_group_creates=1000 (see Fix item above)
|
|
||||||
frame=3 bind_group_creates=0
|
|
||||||
frame=4 bind_group_creates=0
|
|
||||||
(append one image here)
|
|
||||||
frame=5 bind_group_creates=1001 (see Fix item above)
|
|
||||||
frame=6 bind_group_creates=0
|
|
||||||
|
|
||||||
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
|
||||||
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
|
||||||
frame=2 bind_group_creates=0
|
|
||||||
frame=3 bind_group_creates=0
|
|
||||||
frame=4 bind_group_creates=0
|
|
||||||
(append one image here)
|
|
||||||
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
|
||||||
frame=6 bind_group_creates=0
|
|
||||||
|
|
||||||
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
|
||||||
never-before-seen text rows — and scales with N as it must, ~10x cost
|
|
||||||
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
|
||||||
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
|
||||||
move chain holds for both scrolling and for a growing input box pushing
|
|
||||||
the message list — draws/moves per tick or per line do not grow with
|
|
||||||
list size, and the per-operation cost (a fraction of a microsecond) is
|
|
||||||
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
|
||||||
behave as designed; its *append* half did not, until the fix above moved
|
|
||||||
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
|
||||||
the same way (b) and (c) are.
|
|
||||||
|
|
||||||
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
|
|
||||||
left, each recorded at the point in the code it would go rather than
|
|
||||||
silently dropped. See RUST.md's I5 box for the full account of what
|
|
||||||
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
|
|
||||||
composer).**
|
|
||||||
- [ ] **Android integration for this screen does not exist yet.** No
|
|
||||||
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
|
|
||||||
so `transcript-bench.sh`'s render-number pass condition against the
|
|
||||||
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
|
|
||||||
`event_stream::follow_session_events` wiring against
|
|
||||||
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
|
|
||||||
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
|
|
||||||
module, then the bench script pointed at it.
|
|
||||||
- [x] **Touch-drag panning over a row's own rendered text — done,
|
|
||||||
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
|
|
||||||
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
|
|
||||||
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
|
|
||||||
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
|
||||||
arbitration on every frame it was pressed, not just the frame the
|
|
||||||
press started, so a list pan gesture registered on `List` itself never
|
|
||||||
got a turn while a row was under the finger. Fixed with
|
|
||||||
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
|
||||||
machine per list deciding pan vs. select the way Android does (a
|
|
||||||
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
|
||||||
(500ms) starts a selection which further drag extends; a horizontal
|
|
||||||
drag while something is already selected extends immediately) —
|
|
||||||
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
|
||||||
every row's drag now routes through. 8 new unit tests
|
|
||||||
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
|
||||||
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
|
||||||
clean; `run-headless.sh` screenshot byte-identical to before the
|
|
||||||
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
|
||||||
- [ ] **Row-level accessibility names.** The composer carries
|
|
||||||
`.label("Message")`; transcript rows do not carry a `.label()` of
|
|
||||||
their own yet, so `Widgets::named()` (I4) does not include them —
|
|
||||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
|
||||||
stable per row (its sender + a short excerpt, matching what a screen
|
|
||||||
reader announcing a chat message would say).
|
|
||||||
- [ ] **A tappable link and a background chip behind inline code.**
|
|
||||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
|
||||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
|
||||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
|
||||||
would take (the same primitive `TextEdit::draw`'s own selection
|
|
||||||
highlight already uses internally,
|
|
||||||
`iris/src/widget/text/edit.rs:99`).
|
|
||||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
|
||||||
is selected in full (`select_all`) the moment the drag leaves it,
|
|
||||||
rather than "from the click point to whichever edge points away from
|
|
||||||
the drag" — needs the same private `layout()` access as the item
|
|
||||||
above. `selection.rs`'s module doc has the exact reasoning.
|
|
||||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
|
||||||
`client_core::highlight` exists (built for the file explorer) and
|
|
||||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
|
||||||
in was not attempted this pass.
|
|
||||||
|
|
||||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
|
||||||
mask, the way the move chain references a parent offset. Today masks
|
|
||||||
are independent regions. Design it beside the move chain (same shape:
|
|
||||||
a parent index and a bounded walk in the shader); do it when a real
|
|
||||||
widget needs it, not before.
|
|
||||||
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
|
||||||
rejected, letting a scroll update one float rather than positions:
|
|
||||||
input handling cares about most elements in a list, so absolute
|
|
||||||
positions must be computed on the CPU anyway. LAYOUT.md's design
|
|
||||||
already lands here (GPU walks the chain, CPU resolves on demand for
|
|
||||||
hit tests). Keep the CPU resolution lazy and per query; do not
|
|
||||||
materialise every row's absolute position per frame.
|
|
||||||
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
|
||||||
**modular — a piece of the library rather than a core part forced into
|
|
||||||
everything, the same way input is**. Whatever the mechanism, a widget
|
|
||||||
that does not animate must pay nothing and import nothing for it.
|
|
||||||
|
|
||||||
## Reconsider
|
|
||||||
|
|
||||||
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
|
||||||
to compose a widget from others (a button is the main case). With
|
|
||||||
sizing folded into `draw`, composing may be easy enough that `View` is
|
|
||||||
redundant. Decide after the layout change lands, by writing a button
|
|
||||||
both ways and keeping the one that is shorter to explain; delete the
|
|
||||||
other rather than keeping two ways.
|
|
||||||
@@ -1,897 +0,0 @@
|
|||||||
# iris: one `draw` that reports a size
|
|
||||||
|
|
||||||
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
|
||||||
any design or code so that it survives a cleared session. **Status: implemented
|
|
||||||
2026-09-04, against every pass condition in §8** (measured, not assumed — see
|
|
||||||
that section). Every widget listed in §7 was migrated in one change; none
|
|
||||||
kept `desired_width`/`desired_height`. Five points needed correction or
|
|
||||||
refinement beyond what this file originally specified — see "Deviations
|
|
||||||
found during implementation" below, added right before "For IRIS.md" — read
|
|
||||||
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
|
|
||||||
move-slot lifecycle in `render_state.rs`, since each of those five is a real
|
|
||||||
bug this file's first draft would have reproduced if implemented literally.
|
|
||||||
|
|
||||||
## What Iris asked for
|
|
||||||
|
|
||||||
> I don't like that widgets need both a draw and size functions. I'd much
|
|
||||||
> rather them have a single draw that reports a size, and if it needs to be
|
|
||||||
> moved then that can be done after the fact efficiently, or resized just
|
|
||||||
> done after as well. This should be done efficiently like everything else
|
|
||||||
> tries to do right now.
|
|
||||||
|
|
||||||
She added, a few minutes later: "single draw is not a requirement. It
|
|
||||||
just seems more efficient from what I've heard. Feel free to override any
|
|
||||||
decision I've made if you can find a genuinely better & still clean
|
|
||||||
alternative." So the single-draw model is the default to design against,
|
|
||||||
and the design below may reject it, but only with a written comparison
|
|
||||||
showing the alternative does less work per frame and is no harder to use.
|
|
||||||
|
|
||||||
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
|
|
||||||
little processing as possible per frame, but the model must cover every
|
|
||||||
layout need a real app has (the transcript's virtualised list, wrapped
|
|
||||||
text whose height depends on width, rows and columns that size to their
|
|
||||||
children, overlays, masks).
|
|
||||||
|
|
||||||
## What exists today
|
|
||||||
|
|
||||||
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
|
|
||||||
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
|
|
||||||
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
|
|
||||||
is memoised per widget id and axis in `Cache.size` keyed on the outer
|
|
||||||
size, then places the child with `Painter::widget_within(region)`. So a
|
|
||||||
child is visited twice (sized, then drawn), every widget implements sizing
|
|
||||||
twice (one per axis), and a widget whose size depends on what it draws
|
|
||||||
(wrapped text, a laid-out paragraph) does the layout in the size pass and
|
|
||||||
again in the draw pass unless it caches by hand.
|
|
||||||
|
|
||||||
Primitives are already positioned by `UiRegion` values whose scalars have
|
|
||||||
a `rel` and an `abs` part, resolved against the window in the vertex
|
|
||||||
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
|
|
||||||
exists to rewrite one instance's region in place. That is the mechanism a
|
|
||||||
"move after the fact" can build on.
|
|
||||||
|
|
||||||
## What the design must answer
|
|
||||||
|
|
||||||
1. **Parent-before-child ordering.** A row has to know each child's width
|
|
||||||
to place the next one, but under "one draw" the child's size only
|
|
||||||
exists after it has drawn. The answer is meant to be: the child draws
|
|
||||||
at a provisional origin, reports its size, and the parent *moves* it.
|
|
||||||
The move must be O(1) per moved subtree, not O(primitives in the
|
|
||||||
subtree). One way: every instance carries an index into a small
|
|
||||||
per-widget offset buffer, so moving a widget writes one entry and the
|
|
||||||
vertex shader adds it. Other ways may be better; the design should say
|
|
||||||
what was considered.
|
|
||||||
2. **Move vs resize are different costs and must be kept apart.** A move
|
|
||||||
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
|
|
||||||
whose size input changed, and a widget whose output does not depend on
|
|
||||||
its size (an icon, a fixed rect) must be able to say so and be skipped.
|
|
||||||
3. **Size-dependent content.** Wrapped text is the hard case: its height
|
|
||||||
is a function of its width. A single `draw` receives the available
|
|
||||||
size (what `SizeCtx.outer` is today) and reports what it used, so the
|
|
||||||
two-pass "measure then draw" collapses into one for the common case.
|
|
||||||
The design must say what happens when a parent wants the child's
|
|
||||||
height *before* deciding the width it will offer (rare; say whether it
|
|
||||||
is supported, or is done by drawing twice as an explicit, opt-in cost).
|
|
||||||
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
|
|
||||||
replacement should memoise the whole draw result by (id, available
|
|
||||||
size) so that an unchanged subtree costs nothing on the next frame,
|
|
||||||
which is what makes a virtualised list cheap.
|
|
||||||
5. **Everything currently written against `desired_width`/`desired_height`
|
|
||||||
moves over in one change**, per the code rules: two names for one
|
|
||||||
concept is not an intermediate state to leave behind. The widgets are
|
|
||||||
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
|
|
||||||
whatever else is there when the change is made).
|
|
||||||
|
|
||||||
## Order relative to the texture work
|
|
||||||
|
|
||||||
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
|
|
||||||
`Primitives`, `Painter`'s texture calls). This change touches the widget
|
|
||||||
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
|
|
||||||
mechanism the vertex shader needs. They overlap in `Painter` and the
|
|
||||||
shader, so they are done **in sequence, textures first**, and the layout
|
|
||||||
design here is written (not implemented) while the texture work is in
|
|
||||||
progress, then implemented on top of it.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### 1. The new `Widget` trait
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait Widget: Any {
|
|
||||||
/// Draw within `painter.region()` (the space the parent offered) and
|
|
||||||
/// report how much of it was actually used, per axis.
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
|
||||||
|
|
||||||
/// True if `draw`'s output (both the primitives it writes and the
|
|
||||||
/// `Size` it returns) is the same for any `painter.region()` of the
|
|
||||||
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
|
||||||
/// image at its natural size. Default `false` (redraw on any change to
|
|
||||||
/// the offered region) because assuming independence wrongly produces
|
|
||||||
/// a stale draw; a widget must opt in.
|
|
||||||
fn is_size_independent(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No `available` parameter: `Painter` already carries the region the parent
|
|
||||||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
|
||||||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
|
||||||
size (`output_size()`, `:152`). Passing it again would be the same value
|
|
||||||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
|
||||||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
|
||||||
deprecated, not kept as a fallback — because a widget that implements both
|
|
||||||
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
|
||||||
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
|
||||||
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
|
||||||
own comment: "this literally copies draw so that the lengths are correctly
|
|
||||||
set in the context, which makes this slow and not cool." Folding sizing into
|
|
||||||
`draw` deletes that duplicate simulation, not just moves it.
|
|
||||||
|
|
||||||
**No single-draw alternative was found that does less work per frame.** The
|
|
||||||
two-method trait was checked against three properties a real screen needs —
|
|
||||||
a row placing children in sequence, a widget centering on its own content,
|
|
||||||
and wrapped text — and in every one, `draw` already has to visit the child
|
|
||||||
to get a size that is *this specific one's* answer, which today's
|
|
||||||
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
|
||||||
the same layout the draw pass will do again. So the two-method trait is not
|
|
||||||
"measure once, draw once" in the general case; it is "measure once per axis,
|
|
||||||
then draw once," i.e. up to three visits per widget per frame, against one
|
|
||||||
under the design here. The single-draw model is therefore adopted as
|
|
||||||
proposed, not merely accepted as a preference.
|
|
||||||
|
|
||||||
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
|
||||||
|
|
||||||
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
|
||||||
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
|
||||||
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
|
||||||
`active.region.size() == region.size()` after excluding the exact-match
|
|
||||||
case). It rewrites every primitive's `region` field via
|
|
||||||
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
|
||||||
widget's own primitives, then recurses into every child — O(primitives in
|
|
||||||
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
|
||||||
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
|
||||||
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
|
||||||
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
|
||||||
re-touches every glyph in every visible row, every frame of the drag, and
|
|
||||||
I3's target is 800 rows on screen.
|
|
||||||
|
|
||||||
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
|
||||||
resolved in the vertex shader.**
|
|
||||||
|
|
||||||
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
|
||||||
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
|
||||||
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
|
||||||
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
|
||||||
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
|
||||||
add nothing more"). A pure abs-pixel translation, not a general
|
|
||||||
`UiRegion` remap — sufficient for every existing call site (above).
|
|
||||||
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
|
||||||
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
|
||||||
kind of per-instance handle.
|
|
||||||
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
|
||||||
assigned **when the widget is first drawn** (`draw_inner`, beside
|
|
||||||
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
|
||||||
`Painter` threads a `move_slot` field down exactly as it already threads
|
|
||||||
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
|
||||||
correct from its first frame — nothing is ever retrofitted onto an
|
|
||||||
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
|
||||||
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
|
||||||
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
|
||||||
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
|
||||||
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
|
||||||
recursion, since descendants already reference this slot transitively.
|
|
||||||
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
|
||||||
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
|
||||||
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
|
||||||
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
|
||||||
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
|
||||||
paid every frame regardless of whether anything moved — negligible next
|
|
||||||
to the per-fragment texture sampling TEXTURES.md already measures this
|
|
||||||
GPU as not bound by.
|
|
||||||
|
|
||||||
**Why the chain, not the flatter thing first proposed.** Iris's own
|
|
||||||
phrasing — "every instance carries an index into a small per-widget offset
|
|
||||||
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
|
||||||
no parent link. It breaks the moment two such subtrees nest — a row inside
|
|
||||||
a scrolling list, itself later given its own animated offset (a
|
|
||||||
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
|
||||||
pick one slot and lose the other's contribution. The chain costs one extra
|
|
||||||
field and a bounded shader loop in exchange for no such gap, and since
|
|
||||||
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
|
||||||
no more at the common depth of one than the flat version would.
|
|
||||||
|
|
||||||
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
|
||||||
O(primitives in the subtree) — the cost this section removes — but kept
|
|
||||||
for a resize that changes a region's `rel` component (a genuine reflow,
|
|
||||||
§3) and for a size-independent widget's resize (§3), where the content's
|
|
||||||
shape doesn't change and one field write already suffices.
|
|
||||||
|
|
||||||
### 2b. Two more readers of "where is this widget," and masks
|
|
||||||
|
|
||||||
Moving the offset into the vertex shader means `ActiveData.region` is no
|
|
||||||
longer the on-screen truth once a widget has been moved — it is where the
|
|
||||||
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
|
||||||
if it still were, and both must move to a resolved query or they silently
|
|
||||||
answer with the pre-move position: a click landing on a scrolled row would
|
|
||||||
be routed to whatever used to be there, with nothing on screen to say so —
|
|
||||||
exactly the "wrong answer that looks like a right one" case the code rules
|
|
||||||
single out.
|
|
||||||
|
|
||||||
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
|
||||||
does the actual pointer routing, and line 170 is the read in question:
|
|
||||||
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
|
||||||
immediately turned into pixels and tested against the cursor at `:171-172`.
|
|
||||||
Under this design that region must be resolved through the same chain the
|
|
||||||
GPU walks before it means anything. Add to `UiRenderState`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
|
||||||
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
|
||||||
/// walk, over the same arena, so the two cannot disagree about where a
|
|
||||||
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
|
||||||
/// `move_offsets`, bounded by the same constant the shader loop uses
|
|
||||||
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
|
||||||
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
|
||||||
/// const across the language boundary).
|
|
||||||
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
|
||||||
```
|
|
||||||
|
|
||||||
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
|
||||||
coordinate query already used outside hit-testing
|
|
||||||
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
|
||||||
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
|
||||||
before `.to_px(...)` instead of reading `.region` directly — one change
|
|
||||||
covers both call sites listed there. `sense.rs:170` changes to
|
|
||||||
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
|
||||||
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
|
||||||
`resolved_region` costs one arena read to find that out, so there is no
|
|
||||||
version of this design where skipping the fix is a legitimate
|
|
||||||
optimization — it is a correctness gap, not a performance one.
|
|
||||||
|
|
||||||
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
|
||||||
painter's *current* region into a `Mask` pushed onto
|
|
||||||
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
|
||||||
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
|
||||||
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
|
||||||
widget that called `set_mask` — `Masked::draw`,
|
|
||||||
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
|
||||||
is itself later moved, its clip rectangle stays where it was drawn while
|
|
||||||
its content moves out from under it: a visibly wrong clip, immediately on
|
|
||||||
screen, not a latency question.
|
|
||||||
|
|
||||||
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
|
||||||
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
|
||||||
the mask-owning widget's own primitives already get (§2), not a second
|
|
||||||
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
|
||||||
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
|
||||||
currently computes the mask's `top_left`/`bot_right` inline from
|
|
||||||
`masks[in.mask_idx]`; that computation is extended to walk the same
|
|
||||||
move-offset chain §2 added, via one shared function —
|
|
||||||
|
|
||||||
```wgsl
|
|
||||||
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
|
||||||
for its mask's corners, so the walk is written once and the two stages
|
|
||||||
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
|
||||||
hand-copied second one in the other shader stage).
|
|
||||||
|
|
||||||
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
|
||||||
primitive's mask is frequently owned by a *different* widget than the
|
|
||||||
primitive itself — often several levels up a subtree, with its own,
|
|
||||||
independent move slot — so a primitive's resolved offset and its mask's
|
|
||||||
resolved offset are two different chain sums, both needed, and only the
|
|
||||||
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
|
||||||
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
|
||||||
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
|
||||||
mean, for every `mov()` call, walking forward to every mask instance the
|
|
||||||
moved widget's slot could affect and rewriting its raw region — exactly
|
|
||||||
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
|
||||||
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
|
||||||
frame (`:148`); one more arena read to resolve its chain costs nothing
|
|
||||||
extra in kind.
|
|
||||||
|
|
||||||
**The scroll-container case, checked rather than assumed.** A masked,
|
|
||||||
scrollable region is built as a `Masked` wrapping a `Scroll`
|
|
||||||
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
|
||||||
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
|
||||||
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
|
||||||
every frame the user drags. Because each widget's move slot is its own
|
|
||||||
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
|
||||||
its own, stationary slot, while the scrolled content underneath references
|
|
||||||
a separate, deeper slot whose `parent` chain passes through — but does not
|
|
||||||
write to — the viewport's slot. Moving the content therefore never touches
|
|
||||||
the mask's resolved position, and the mask staying still while its content
|
|
||||||
slides past it is what this design already produces with no special case,
|
|
||||||
not an extra rule that had to be added for it.
|
|
||||||
|
|
||||||
### 3. Resize scope
|
|
||||||
|
|
||||||
A resize is "the region a widget's parent offers it changes such that the
|
|
||||||
widget's draw might produce different output" — as opposed to a move, which
|
|
||||||
by construction cannot (§2 is scoped to pure translation). Two independent
|
|
||||||
narrowings apply, and both are real, measured properties of the code as it
|
|
||||||
stands rather than new machinery:
|
|
||||||
|
|
||||||
**(a) A window resize does not, by itself, require touching most widgets.**
|
|
||||||
`shader.wgsl:105-106` recomputes every primitive's pixel position from
|
|
||||||
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
|
||||||
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
|
||||||
call to `px_size()`, `output_size()`, or anything else that reads a
|
|
||||||
concrete pixel count) is therefore already correct after a resize with zero
|
|
||||||
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
|
||||||
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
|
||||||
tree on every `resized`, which was the safe default while sizing and
|
|
||||||
drawing were two passes; it should be narrowed to only the widgets that
|
|
||||||
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
|
||||||
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
|
||||||
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
|
||||||
pixel-dependent by calling through `Painter` methods that read
|
|
||||||
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
|
||||||
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
|
||||||
walks only that set instead of unconditionally setting `resized = true`
|
|
||||||
for a full `redraw_all`. This turns "every resize redraws everything" into
|
|
||||||
"every resize redraws what depends on pixels" — a real behavior change
|
|
||||||
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
|
||||||
resize regression (that fix depended on `redraw_all`'s completeness)
|
|
||||||
before narrowing this.
|
|
||||||
|
|
||||||
**(b) A widget's `available` (its parent's offered region) can change
|
|
||||||
without the widget's *content* changing — this is what
|
|
||||||
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
|
||||||
(a sibling grew or shrank, changing this widget's offered box), a widget
|
|
||||||
that returns `true` from `is_size_independent` is not redrawn: its
|
|
||||||
primitives are unaffected by size, only by placement, so the parent
|
|
||||||
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
|
||||||
the primitive's `region` fields directly via `region_mut` if the box
|
|
||||||
changed shape too (still O(primitives owned directly by this widget, not
|
|
||||||
its subtree, since a size-independent widget by definition has no
|
|
||||||
size-dependent descendants worth distinguishing — in practice this is
|
|
||||||
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
|
||||||
`false` (the default) is redrawn in full whenever `available` changes,
|
|
||||||
which is correct always, just not free.
|
|
||||||
|
|
||||||
**Ancestor propagation** (a resized child changing its own reported size,
|
|
||||||
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
|
||||||
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
|
||||||
ancestors whose cached size differs from the new one and stops as soon as
|
|
||||||
a size is unchanged (`:274-286`). That loop moves from consulting
|
|
||||||
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
|
||||||
|
|
||||||
### 4. Wrapped text, and "needs child height before choosing width"
|
|
||||||
|
|
||||||
**Wrapped text is not a special case any more; it already reads as one
|
|
||||||
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
|
||||||
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
|
||||||
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
|
||||||
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
|
||||||
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
|
||||||
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
|
||||||
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
|
||||||
`self.view.draw(painter)` once, which internally still calls `render`
|
|
||||||
once, hits its own cache, and returns the size it already computed. No
|
|
||||||
new caching is needed here; the two now-redundant call sites
|
|
||||||
(`desired_width`/`desired_height` each separately calling `render`) simply
|
|
||||||
disappear, which is a second `render` avoided per frame per text widget
|
|
||||||
that is being measured by a parent.
|
|
||||||
|
|
||||||
**"Parent wants the child's height before deciding the width it will
|
|
||||||
offer"** — the genuinely circular case named in the brief, e.g. a column
|
|
||||||
that sizes its own width to its widest child, where that child is wrapped
|
|
||||||
text whose height (which the column's *own* height depends on) depends on
|
|
||||||
the width the column has not yet decided. This is not solvable in one pass
|
|
||||||
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
|
||||||
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
|
||||||
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
|
||||||
by an explicit second, throwaway pass (its own comment: "this literally
|
|
||||||
copies draw ... which makes this slow and not cool"). The design keeps that
|
|
||||||
resolution, made explicit rather than accidental: `Painter` gets
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Draw `child` at a provisional region to learn its size under one
|
|
||||||
/// axis's worth of assumption, discard everything it wrote, then draw it
|
|
||||||
/// again at the region that assumption produced. For the rare parent that
|
|
||||||
/// cannot pick an offered size without already knowing the answer.
|
|
||||||
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
|
||||||
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
|
||||||
```
|
|
||||||
|
|
||||||
implemented as: draw at `first`, record `Size`, remove the widget and its
|
|
||||||
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
|
||||||
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
|
||||||
which already frees the old primitives before redrawing) — reusing that
|
|
||||||
path rather than adding a second one — draw again at `second(size)`, return
|
|
||||||
the final `Size`. It is opt-in and named for its cost, so a widget only
|
|
||||||
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
|
||||||
call site converted to it, replacing the hand-rolled duplicate loop.
|
|
||||||
|
|
||||||
### 5. Caching and invalidation
|
|
||||||
|
|
||||||
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
|
||||||
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
|
||||||
answer, independent of drawing) no longer exists as a separate query, so
|
|
||||||
there is nothing left to cache at that layer. What already provides "an
|
|
||||||
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
|
||||||
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
|
||||||
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
|
||||||
immediately — no `Painter` constructed, no primitive touched, no shader
|
|
||||||
work beyond what the GPU already redraws from the unchanged instance
|
|
||||||
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
|
||||||
and it already operates at (id, region) granularity, which subsumes "(id,
|
|
||||||
available size)" once size *is* what a region change means.
|
|
||||||
|
|
||||||
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
|
||||||
returned, stored the moment it is (`draw_inner`, alongside building the
|
|
||||||
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
|
||||||
widget for a second frame without redrawing it (because nothing changed)
|
|
||||||
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
|
||||||
size question without a full draw" with "read the size of the last actual
|
|
||||||
draw," which is always available because `draw_inner`'s skip path is only
|
|
||||||
reachable once the widget has been drawn at least once. `Cache::remove`/
|
|
||||||
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
|
||||||
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
|
||||||
`render_state.rs:171-198`, freed with the widget).
|
|
||||||
|
|
||||||
### 6. Before / after
|
|
||||||
|
|
||||||
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
impl Widget for Rect {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
|
||||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
|
||||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
|
||||||
}
|
|
||||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
|
||||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// after
|
|
||||||
impl Widget for Rect {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
||||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
|
||||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
|
||||||
Size::REST // fills whatever it was given -- used == available
|
|
||||||
}
|
|
||||||
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**A container that needs the child's size before placing it,
|
|
||||||
`iris/src/widget/position/align.rs`**:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// before
|
|
||||||
impl Widget for Aligned {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
|
||||||
let region = match self.align.tuple() {
|
|
||||||
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
|
||||||
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
|
||||||
UiRegion::new(x, UiSpan::FULL) }
|
|
||||||
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
|
||||||
UiRegion::new(UiSpan::FULL, y) }
|
|
||||||
(None, None) => UiRegion::FULL,
|
|
||||||
};
|
|
||||||
painter.widget_within(&self.inner, region);
|
|
||||||
}
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// after
|
|
||||||
impl Widget for Aligned {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
||||||
let full = painter.region();
|
|
||||||
// Draw once at the full region to learn the child's real size --
|
|
||||||
// this placement is provisional and corrected below without a
|
|
||||||
// second draw.
|
|
||||||
let used = painter.widget_within(&self.inner, full);
|
|
||||||
let region = match self.align.tuple() {
|
|
||||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
|
||||||
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
|
||||||
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
|
||||||
(None, None) => full,
|
|
||||||
};
|
|
||||||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
|
||||||
used
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
|
||||||
return type from `()` to `Size`, carrying the child's `draw` result back —
|
|
||||||
the only signature change needed to let a parent see what its child used.
|
|
||||||
`Painter::reposition` is new, computing the delta between where a child
|
|
||||||
was actually drawn and where it belongs and calling the O(1) `mov` from
|
|
||||||
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
|
||||||
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
|
||||||
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
|
||||||
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
|
||||||
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
|
||||||
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
|
||||||
|
|
||||||
### 7. Migration — every file and widget that changes
|
|
||||||
|
|
||||||
One change, in dependency order (rename-and-move-together, per the code
|
|
||||||
rules — no intermediate state with both trait shapes):
|
|
||||||
|
|
||||||
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
|
||||||
`WidgetAxisFns`, update `impl Widget for ()`.
|
|
||||||
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
|
||||||
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
|
||||||
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
|
||||||
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
|
||||||
`len_axis`; `primitive_at` writes `move_idx`.
|
|
||||||
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
|
||||||
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
|
||||||
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
|
||||||
instead of `Cache.size`.
|
|
||||||
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
|
||||||
`move_slot: MoveIdx`.
|
|
||||||
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
|
||||||
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
|
||||||
new `MoveOffset` struct.
|
|
||||||
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
|
||||||
and `Primitives::write`, matching `mask_idx`.
|
|
||||||
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
|
||||||
(group 2, beside `masks`) and its update path.
|
|
||||||
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
|
||||||
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
|
||||||
function (§2b) called from both `vs_main` (a primitive's own corners)
|
|
||||||
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
|
||||||
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
|
||||||
and `window_region` (`:264-267`) reimplemented on top of it.
|
|
||||||
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
|
||||||
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
|
||||||
(§2b) — the pointer-routing fix this design requires, not an optional
|
|
||||||
follow-up.
|
|
||||||
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
|
||||||
`move_idx: u32` (§2b).
|
|
||||||
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
|
||||||
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
|
||||||
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
|
||||||
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
|
||||||
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
|
||||||
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
|
||||||
never depends on the region it is offered, same as `Rect`),
|
|
||||||
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
|
||||||
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
|
||||||
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
|
||||||
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
|
||||||
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
|
||||||
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
|
||||||
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
|
||||||
`iris/src/widget/position/pad.rs` (`Pad`),
|
|
||||||
`iris/src/widget/position/stack.rs` (`Stack`),
|
|
||||||
`iris/src/widget/position/offset.rs` (`Offset`),
|
|
||||||
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
|
||||||
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
|
||||||
`iris/src/widget/position/sized.rs` (`Sized`).
|
|
||||||
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
|
||||||
across `core/` and `src/`; re-run it before starting, since it is the
|
|
||||||
authoritative check that nothing was missed, not this paragraph.
|
|
||||||
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
|
||||||
`impl Widget` found in any example (verified by the same grep); they use
|
|
||||||
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
|
||||||
source change, which is itself part of the pass condition below.
|
|
||||||
|
|
||||||
### 8. Pass conditions
|
|
||||||
|
|
||||||
1. **Every example under `iris/examples` renders identically.** Run
|
|
||||||
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
|
||||||
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
|
||||||
"looks right," since a subtle wrap or alignment regression is exactly
|
|
||||||
what a diff catches and a glance does not.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
|
|
||||||
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
|
|
||||||
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
|
|
||||||
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
|
|
||||||
file bytes (`cmp` alone is not conclusive across two separately-encoded
|
|
||||||
PNGs, though it happened to agree here for `minimal`). Before-shots were
|
|
||||||
taken with `git stash` at the pre-change commit; `tabs` needed two real
|
|
||||||
fixes (deviations 1 and 2 below) before it stopped differing — the other
|
|
||||||
three matched on the first try.
|
|
||||||
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
|
|
||||||
the existing `debug_layers`/`active_widgets` instrumentation
|
|
||||||
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
|
|
||||||
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
|
|
||||||
one example (`tabs`, since it already has multiple widgets and an
|
|
||||||
interactive element) through one frame with nothing changed and report
|
|
||||||
both counts — the pass condition is **0 draws and 0 primitive rewrites**
|
|
||||||
for a frame in which nothing was marked dirty, resized, or moved.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass, 0 and 0.** Implemented as
|
|
||||||
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
|
|
||||||
rewrites, `move_offsets` writes — a third counter, for condition 3
|
|
||||||
below), reset on read. Measured in
|
|
||||||
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
|
|
||||||
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
|
|
||||||
see the note on condition 3 for why this runs as a plain unit test
|
|
||||||
instead).
|
|
||||||
3. **Single-moved-child cost, measured.** Same counters, one frame in
|
|
||||||
which exactly one widget is moved (not resized) with N primitives in its
|
|
||||||
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
|
|
||||||
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
|
|
||||||
the case with a `tabs`-style example holding a deliberately large text
|
|
||||||
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
|
|
||||||
an O(N) regression would show up as a non-trivial write count rather
|
|
||||||
than being lost in noise.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
|
|
||||||
write, N = 500.** Built with rects rather than glyphs
|
|
||||||
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
|
|
||||||
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
|
|
||||||
this runs as a plain `cargo test`, not through `run-headless.sh` — a
|
|
||||||
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
|
|
||||||
faster and more precise than reading counters out of a real example's
|
|
||||||
stderr. Getting a clean single move took two follow-up fixes beyond the
|
|
||||||
design as written (deviation 3, the `parent_move_slot` threading; and
|
|
||||||
the `Scroll` design decision below about offering last frame's content
|
|
||||||
length) — without either, the count was in the thousands (every rect in
|
|
||||||
the subtree redrawing) rather than 1.
|
|
||||||
4. **Hit-testing follows the move, not just the render.** In the same
|
|
||||||
scrolled-`tabs` construction as condition 3, scroll the content, then
|
|
||||||
send a synthetic cursor position over a widget that moved and assert
|
|
||||||
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
|
|
||||||
id, not to whatever is now at its pre-scroll coordinates or to nothing.
|
|
||||||
This is a correctness check, not a timing one — §2b's fix is required
|
|
||||||
before §2 can ship at all, and this is what would fail silently
|
|
||||||
(nothing on screen indicates a missed or misrouted hit) if it were
|
|
||||||
skipped.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass**, but checked one level below
|
|
||||||
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
|
|
||||||
scrolls a widget and asserts `UiRenderState::resolved_region` (the
|
|
||||||
query `run_sensors`'s hit-test and `window_region` both now go through,
|
|
||||||
per §2b) reports the moved, not the pre-scroll, position — within
|
|
||||||
0.01px of the exact expected delta. `run_sensors` itself needs a
|
|
||||||
`HasEvents`/window/cursor-state harness this pass did not build; the
|
|
||||||
coverage that matters (does the position query the router uses reflect
|
|
||||||
the move) is exercised directly instead.
|
|
||||||
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
|
|
||||||
both before and after scrolling it (`iris/run-headless.sh` against a
|
|
||||||
small purpose-built example, or an addition to `tabs`), and diff the
|
|
||||||
two frames: the clipped edge of the content must have moved with the
|
|
||||||
scroll while the viewport's own border (drawn by `Masked`, not moved)
|
|
||||||
stays put — the specific case worked through in §2b. A mask rectangle
|
|
||||||
that stayed at its pre-scroll position while its content slid past it
|
|
||||||
is the regression this checks for, and it is visible in a single
|
|
||||||
screenshot, not just in a counter.
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass, checked numerically rather than by
|
|
||||||
screenshot.** No example in this repository builds a `Masked`-wrapped
|
|
||||||
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
|
|
||||||
scrolling, not this widget), so there was nothing to screenshot without
|
|
||||||
first authoring a new example. Checked instead in
|
|
||||||
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
|
|
||||||
on the exact data the fragment shader's `resolve_move` reads: the
|
|
||||||
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
|
|
||||||
and after scrolling its content, because `Masked` is never itself the
|
|
||||||
target of a move — only its child is, on a separate, deeper slot in the
|
|
||||||
chain (§2b's "scroll-container case, checked rather than assumed"). A
|
|
||||||
pixel-level screenshot check of this remains open; see RUST.md's next
|
|
||||||
step.
|
|
||||||
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
|
|
||||||
stay clean at the defaults (iris has no tests today per I0b, so this is
|
|
||||||
presently only clippy/fmt; add the first real widget-layer tests here if
|
|
||||||
the move-offset chain or `draw_twice` are non-trivial enough to want
|
|
||||||
one, per "match the codebase's testing posture" — judge that once the
|
|
||||||
code exists rather than pre-committing to a number of tests here).
|
|
||||||
|
|
||||||
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
|
|
||||||
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
|
|
||||||
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
|
|
||||||
`winit` future-incompatibility, from dependencies, not this change).
|
|
||||||
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
|
|
||||||
ones in `iris/src/layout_tests.rs` (conditions 2–5 above), 18 passed, 0
|
|
||||||
failed — the move-offset chain turned out non-trivial enough (three real
|
|
||||||
bugs found only by writing it) to clearly clear the "match the testing
|
|
||||||
posture" bar this section left open.
|
|
||||||
|
|
||||||
### 9. Rejected, and why
|
|
||||||
|
|
||||||
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
|
||||||
phrasing — rejected in §2 for breaking under nested independent moves
|
|
||||||
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
|
||||||
chain is the same mechanism with one more field.
|
|
||||||
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
|
|
||||||
as the steady-state path (O(primitives in subtree), exactly what a
|
|
||||||
transcript scroll must not pay every frame) but kept for resize-shaped
|
|
||||||
changes (§3) where the content's own region field, not an ancestor
|
|
||||||
chain, is what has to change.
|
|
||||||
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
|
||||||
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
|
||||||
implement to skip a draw when a cheap answer exists) — considered and
|
|
||||||
rejected: it reintroduces exactly the "two names for one concept" split
|
|
||||||
this change removes, for a saving `is_size_independent` (§1, §3b)
|
|
||||||
already covers for the cases where it would actually help (fixed-size
|
|
||||||
leaves). A widget whose size is cheap to compute but whose *drawing* is
|
|
||||||
not (unlikely in this codebase's widget set, but conceivable) is better
|
|
||||||
served by that widget caching its own draw output internally — exactly
|
|
||||||
the pattern `TextView::render` already uses (§4) — than by a second
|
|
||||||
trait method every implementor has to reason about.
|
|
||||||
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
|
||||||
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
|
||||||
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
|
||||||
which already carries the same information into every widget that needs
|
|
||||||
it; adding a parameter would just be a second route to a value already
|
|
||||||
reachable, and would invite the two drifting apart.
|
|
||||||
- **Eagerly propagating a moved widget's delta into every descendant's own
|
|
||||||
offset value** (rather than chaining and resolving in the shader) —
|
|
||||||
rejected as O(descendant widgets), which is smaller than O(primitives)
|
|
||||||
but still not O(1), and the shader-side chain costs nothing extra to get
|
|
||||||
the better bound.
|
|
||||||
|
|
||||||
## Deviations found during implementation (2026-09-04)
|
|
||||||
|
|
||||||
Five corrections this file's first draft did not anticipate, each found by
|
|
||||||
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
|
|
||||||
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
|
|
||||||
`draw_inner`/`reposition` — not by reasoning about the design in the
|
|
||||||
abstract. Recorded here rather than silently fixed in place, per the code
|
|
||||||
rules' escape-hatch requirement.
|
|
||||||
|
|
||||||
1. **`Aligned`'s provisional draw must call `painter.widget`, not
|
|
||||||
`widget_within(&self.inner, painter.region())`.** §6's original text drew
|
|
||||||
the sample as the latter. `widget_within` composes its `region` argument
|
|
||||||
as *local*, `UiRegion::FULL`-relative coordinates against
|
|
||||||
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
|
|
||||||
self.region` relies on); handing it `painter.region()` itself —
|
|
||||||
already-resolved, window-relative coordinates — composes that frame a
|
|
||||||
second time. For the root widget this is silently the identity (its
|
|
||||||
region already is `[0,1]`), which is why it can look correct in a
|
|
||||||
trivial case and only breaks once something is nested — i.e. always, in
|
|
||||||
practice. Symptom: a centered child rendered at a wildly wrong offset
|
|
||||||
nested more than one level deep. Fixed by using `painter.widget`, which
|
|
||||||
hands the child `self.region` unmodified, with no second composition.
|
|
||||||
|
|
||||||
2. **A widget that reports a size smaller than its offered region must
|
|
||||||
actually paint at that size, anchored top-left of what it was given —
|
|
||||||
not fill the full offered region while merely *reporting* a smaller
|
|
||||||
number.** `Sized` and `MaxSize` both had exactly this bug: their
|
|
||||||
`desired_width`/`desired_height` predecessors capped the *reported*
|
|
||||||
value but their `draw` bodies called `painter.widget(&self.inner)`
|
|
||||||
unconstrained, which was harmless under the old two-pass model (a parent
|
|
||||||
always queried the size *before* drawing, so by the time `draw` ran the
|
|
||||||
offered region already matched) but wrong under `Aligned`'s new
|
|
||||||
provisional-draw-then-reposition pattern, which offers the *whole*
|
|
||||||
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
|
|
||||||
rendered stretched to fill its whole row instead of a 100×100 square.
|
|
||||||
Fixed by having both widgets carve the declared sub-region (`UiSpan`
|
|
||||||
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
|
|
||||||
they were offered before drawing the child in it. `Image` needed the
|
|
||||||
same treatment from the start (`texture_within` at its own natural size,
|
|
||||||
not `texture()` at the full offered region) and was written that way in
|
|
||||||
the first pass, once this was understood; `Rect`'s "fill whatever I'm
|
|
||||||
given" is the one case where painting the *whole* offered region really
|
|
||||||
is the declared behavior, so it needed no change.
|
|
||||||
|
|
||||||
3. **The move-offset chain's `parent` link cannot be found by looking up
|
|
||||||
the parent's `ActiveData` in `draw_inner`, because the parent's
|
|
||||||
`ActiveData` does not exist yet while its own `Widget::draw` is still
|
|
||||||
running.** `ActiveData` is inserted only after `draw` returns
|
|
||||||
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
|
|
||||||
through its parent's `draw` body — the ordinary case, since every
|
|
||||||
composite widget draws its children from inside its own `draw` — would
|
|
||||||
always read "no parent" from `self.active`, silently orphaning it at the
|
|
||||||
root of the chain. Fixed by threading the parent's `move_slot` down
|
|
||||||
through `Painter` (it already carries `mask`/`layer` the same way) and
|
|
||||||
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
|
|
||||||
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
|
|
||||||
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
|
|
||||||
target's parent genuinely is already active at that call site — the
|
|
||||||
doc comment on it says which is which. Symptom: `reposition` computed
|
|
||||||
the right delta and wrote it to the right slot, but the shader never
|
|
||||||
saw it, because the primitive doing the actual painting chained to
|
|
||||||
`u32::MAX` one level too early.
|
|
||||||
|
|
||||||
4. **`Painter::reposition` cannot reuse `active.region` as "where the
|
|
||||||
widget currently is," because for a widget offered more room than it
|
|
||||||
used, `active.region` is the *offered* box, not the *painted* one.**
|
|
||||||
This only matters for `reposition` (used by `Aligned`); `mov` (used by
|
|
||||||
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
|
|
||||||
and `Offset`) has no such gap, because there the offered region *is*
|
|
||||||
the visual footprint — content is sized to fill exactly what it is
|
|
||||||
given. `reposition` instead reconstructs "from" as `active.size`
|
|
||||||
(already tracked, per §5) anchored at `AxisAlign::Neg` within
|
|
||||||
`active.region` — i.e. it assumes the child painted itself top-left of
|
|
||||||
whatever it was offered, per point 2's convention — and **overwrites**
|
|
||||||
the slot's delta rather than accumulating it the way `mov` does, since
|
|
||||||
"from" is recomputed fresh from stable inputs every call and repeating
|
|
||||||
the same `reposition` (an unrelated redraw elsewhere re-running this
|
|
||||||
widget's parent) must not drift further each time. The one shape this
|
|
||||||
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
|
|
||||||
`reposition` may have moved its content away from top-left already. No
|
|
||||||
widget or example in this codebase builds that today; if one needs to,
|
|
||||||
`reposition` would need the child to report *where* it painted, not
|
|
||||||
just how big, which is a larger change than this pass's scope.
|
|
||||||
|
|
||||||
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
|
|
||||||
draw, and reused in place — never reallocated — for every later redraw
|
|
||||||
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
|
|
||||||
spelled out in §2's original text, which only said slots are assigned
|
|
||||||
"when the widget is first drawn." Reallocating a fresh slot on every
|
|
||||||
redraw would leave any *retained* (not-redrawn) descendant's `parent`
|
|
||||||
link pointing at a now-orphaned old slot — a permanent leak, and worse,
|
|
||||||
a descendant that silently stops tracking its ancestor's future moves.
|
|
||||||
Resetting the delta on reuse (rather than carrying it forward) is
|
|
||||||
required because a full redraw bakes the widget's correct absolute
|
|
||||||
position into the fresh `region` argument directly; a stale delta left
|
|
||||||
over from before the redraw would double-offset it.
|
|
||||||
|
|
||||||
Two further points worth recording because they were *design decisions*
|
|
||||||
made while implementing, not bugs — `LAYOUT.md`'s own text left them
|
|
||||||
unspecified rather than getting them wrong:
|
|
||||||
|
|
||||||
- **`Scroll` offers its content a region sized by the *previous* frame's
|
|
||||||
measured content length, not a fresh one.** A fresh measurement would
|
|
||||||
require drawing the content once to learn its size and — since that
|
|
||||||
provisional size essentially never matches the previously active one —
|
|
||||||
redrawing it a second time at the real size, on every single scroll
|
|
||||||
tick, which is exactly the cost §2 exists to remove. Using the stale
|
|
||||||
length means an ordinary scroll (position changes, content does not)
|
|
||||||
offers the same *size* as last frame, only shifted, which is what makes
|
|
||||||
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
|
|
||||||
change lags one frame before the container's scroll range reflects it,
|
|
||||||
self-correcting the frame after (the content length itself, read from
|
|
||||||
what was actually drawn, is never stale — only the offered *region* used
|
|
||||||
for placement is). No example in this repository builds a `Scroll` yet,
|
|
||||||
so this could not be checked against a pixel diff; it is covered instead
|
|
||||||
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
|
|
||||||
build a tree and drive `UiRenderState` directly with no GPU or window
|
|
||||||
needed.
|
|
||||||
- **`redraw()`'s parent-relayout check draws the widget first, then
|
|
||||||
compares the fresh `ActiveData.size` the draw produced against the size
|
|
||||||
from before removal** — the mirror image of the old code's "query size,
|
|
||||||
compare, decide whether to draw," which no longer has a size query to
|
|
||||||
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
|
|
||||||
along with `desired_width`/`desired_height`). This can occasionally draw
|
|
||||||
a widget once more than the old code would have (if the parent it
|
|
||||||
bubbles up to ends up redrawing the same widget again as part of its own
|
|
||||||
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
|
|
||||||
redundancy for free, and this path is not one of §8's measured
|
|
||||||
conditions, so the remaining slack was accepted rather than chased
|
|
||||||
further.
|
|
||||||
|
|
||||||
## For IRIS.md
|
|
||||||
|
|
||||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
|
||||||
|
|
||||||
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
|
|
||||||
> `desired_height` are gone.** A widget used to implement three methods
|
|
||||||
> (`draw`, `desired_width`, `desired_height`); it now implements one,
|
|
||||||
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
|
|
||||||
> `painter.region()` and returns how much of it was used. Why: the two
|
|
||||||
> extra methods routinely re-simulated what `draw` was about to do anyway
|
|
||||||
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
|
|
||||||
> right) — one visit per widget per frame instead of up to three. A
|
|
||||||
> container that needs a child's size before placing it (alignment,
|
|
||||||
> centering) draws the child once at a provisional region, reads the
|
|
||||||
> returned `Size`, and calls the new `Painter::reposition` to move it into
|
|
||||||
> its final spot — an O(1) offset write, not a second draw. A widget whose
|
|
||||||
> drawn output never depends on the size it's given (a fixed-size `Rect`,
|
|
||||||
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
|
|
||||||
> bool { false }` to `true`, which skips redrawing it when only its
|
|
||||||
> offered region changes shape.
|
|
||||||
>
|
|
||||||
> ```rust
|
|
||||||
> // before
|
|
||||||
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
|
||||||
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
|
||||||
>
|
|
||||||
> // after
|
|
||||||
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
|
||||||
> design, the move-offset mechanism this shipped alongside, and the file
|
|
||||||
> list.
|
|
||||||
-496
@@ -1,496 +0,0 @@
|
|||||||
# How iris should render an unbounded number of images
|
|
||||||
|
|
||||||
## Status (2026-09-04)
|
|
||||||
|
|
||||||
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
|
|
||||||
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
|
|
||||||
what landed, what differs from the proposal below and why, and what was
|
|
||||||
verified versus merely reasoned about. The short version: the binding array
|
|
||||||
is gone, `request_device` asks for no features and no binding-array limits,
|
|
||||||
and that is now proven on the emulator's software Vulkan
|
|
||||||
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
|
|
||||||
is resolved.
|
|
||||||
|
|
||||||
Iris (the person) asked whether iris's (the library's) approach to
|
|
||||||
"draw however many images happen to be on screen" — relevant here because a
|
|
||||||
transcript can hold an unbounded number of attached screenshots — actually
|
|
||||||
works on mobile, her recollection being that it does not. Checked rather
|
|
||||||
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
|
|
||||||
file is that investigation and the resulting recommendation, written for a
|
|
||||||
second agent to review before anything in iris's render core changes — no
|
|
||||||
code has been written against this yet.
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
Every texture iris ever creates — every `Image` widget
|
|
||||||
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
|
||||||
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
|
||||||
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
|
||||||
array by index: `core/src/render/shader.wgsl:56` declares
|
|
||||||
`var views: binding_array<texture_2d<f32>>`, sized by
|
|
||||||
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
|
||||||
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
|
||||||
features — `TEXTURE_BINDING_ARRAY`,
|
|
||||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
|
||||||
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
|
||||||
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
|
||||||
|
|
||||||
A transcript with an unbounded number of image attachments is exactly the
|
|
||||||
case that grows this array without bound: each attachment becomes its own
|
|
||||||
`Image` widget, which takes its own permanent array slot until dropped.
|
|
||||||
|
|
||||||
## What was measured
|
|
||||||
|
|
||||||
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
|
|
||||||
and limits with no window and no APK — a plain executable pushed with
|
|
||||||
`adb push` and run from `/data/local/tmp`. It has two parts:
|
|
||||||
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
|
||||||
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
|
||||||
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
|
||||||
detect it."
|
|
||||||
|
|
||||||
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
|
||||||
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
|
||||||
discrete desktop GPU — but it is why the design was never checked before
|
|
||||||
now: it always worked in the one place it was tried.
|
|
||||||
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
|
||||||
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
|
||||||
`request_device` **fails** —
|
|
||||||
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
|
||||||
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
|
||||||
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
|
||||||
driver itself reporting all seven descriptor-indexing sub-features as
|
|
||||||
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
|
||||||
being more conservative than the driver here, for a reason not chased
|
|
||||||
further (a likely instance-version negotiation gap, since the extension
|
|
||||||
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
|
||||||
not the finding that matters, and is **not** why this design is rejected.
|
|
||||||
|
|
||||||
**The finding that matters is about real phones, sourced rather than
|
|
||||||
recalled:**
|
|
||||||
|
|
||||||
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
|
||||||
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
|
||||||
of October 2025
|
|
||||||
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
|
||||||
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
|
||||||
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
|
||||||
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
|
||||||
unrelated to bindless) and stops there; true of the 2021 and 2022
|
|
||||||
profiles as well.
|
|
||||||
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
|
||||||
is supported on all Valhall and 5th Gen GPUs"**
|
|
||||||
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
|
||||||
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
|
||||||
for Bifrost, Midgard or Utgard, which are still common in budget and
|
|
||||||
older Android phones that are still in daily use.
|
|
||||||
- A search engine's summarized claim of "1% support on Android" for this
|
|
||||||
extension was checked against its cited source (an Arm blog post from
|
|
||||||
2021) and **was not actually there** — that number does not appear in
|
|
||||||
any primary source found and should not be repeated. The baseline-
|
|
||||||
profile finding above is the one with an attributable source; use it
|
|
||||||
instead.
|
|
||||||
|
|
||||||
So this is not a software-renderer artifact. A real, currently-shipping
|
|
||||||
share of the Android fleet lacks the feature iris's texture pipeline asks
|
|
||||||
for unconditionally, and neither the emulator's failure nor the current
|
|
||||||
official hardware baseline gives any reason to expect that to change soon.
|
|
||||||
|
|
||||||
## What growth already costs today, before any redesign
|
|
||||||
|
|
||||||
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
|
|
||||||
because "does this redesign make things worse" needs the current baseline
|
|
||||||
first:
|
|
||||||
|
|
||||||
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
|
|
||||||
rebuilt for any reason related to texture count — its bind group
|
|
||||||
*layouts* declare fixed slot counts (`limits.max_textures`,
|
|
||||||
`limits.max_samplers`) up front and that never changes at runtime. Growth
|
|
||||||
was never at risk of recreating the pipeline, in the current design or
|
|
||||||
any redesign discussed below.
|
|
||||||
- What **does** get rebuilt: `UiRenderNode::update` calls
|
|
||||||
`self.textures.update(&mut ui.textures)`, and if that reports any change,
|
|
||||||
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
|
|
||||||
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
|
|
||||||
fresh over **every currently-live texture**, plus the sampler array and
|
|
||||||
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
|
|
||||||
— an image added anywhere in the whole app rebuilds one shared structure
|
|
||||||
referencing every other image too.
|
|
||||||
- The one path already excluded from this, on purpose, is a `Patch` —
|
|
||||||
writing into an existing texture's pixels without changing which
|
|
||||||
textures exist. The code says why directly
|
|
||||||
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
|
|
||||||
changes texture contents, not the binding array, so it must not report
|
|
||||||
`changed` — rebuilding the bind group per glyph is the cost this exists
|
|
||||||
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
|
|
||||||
growing an existing atlas page costs a `write_texture` into a sub-rect,
|
|
||||||
nothing else.
|
|
||||||
|
|
||||||
So today, growth that stays inside an existing texture (glyphs added to an
|
|
||||||
atlas page) is already free. Growth that adds a *new* texture — a new atlas
|
|
||||||
page, or any standalone image — already rebuilds the one shared array
|
|
||||||
regardless of how the array is populated, before any change discussed
|
|
||||||
below. That existing cost is O(live texture count) in CPU work to collect
|
|
||||||
the view list and in however expensive the driver finds a
|
|
||||||
descriptor-set-sized-for-N-descriptors to be.
|
|
||||||
|
|
||||||
## Prior art, checked rather than assumed
|
|
||||||
|
|
||||||
Two independent projects were checked to see whether "atlas for images"
|
|
||||||
is actually how this is normally done, rather than a guess:
|
|
||||||
|
|
||||||
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
|
|
||||||
closest prior art to iris — an immediate-mode wgpu-backed UI library that
|
|
||||||
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
|
|
||||||
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
|
|
||||||
no array, no descriptor indexing of any kind. Draw calls are batched by
|
|
||||||
texture id and the bind group is switched between batches within the
|
|
||||||
render pass.
|
|
||||||
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
|
|
||||||
through — hit the identical problem and wrote down why in their own
|
|
||||||
roadmap document
|
|
||||||
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
|
|
||||||
*"The number of images that may appear in a scene is not bounded, which
|
|
||||||
is not a good fit for the basic descriptor binding model... Until then,
|
|
||||||
we'll do a workaround of having a single atlas image containing all the
|
|
||||||
images in the scene."* Their reason is broader than Android — WebGPU 1.0
|
|
||||||
has no descriptor indexing at all — but it reaches the same conclusion
|
|
||||||
for the same shape of problem: atlas, not a bigger bindless array.
|
|
||||||
|
|
||||||
**This is also a live hazard, not a solved one.** Vello's own changelog
|
|
||||||
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
|
|
||||||
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
|
|
||||||
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
|
|
||||||
in the renderer Masonry is built on. The same release added
|
|
||||||
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
|
|
||||||
utilization, fragmentation) because growth needed instrumenting in
|
|
||||||
production, not because it turned out to be free.
|
|
||||||
|
|
||||||
## Recommendation (not yet implemented)
|
|
||||||
|
|
||||||
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
|
|
||||||
downscaled attachment previews, icons — go through a shared atlas, the
|
|
||||||
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
|
|
||||||
Adding one to an existing page is a `Patch`, already free per the
|
|
||||||
section above.
|
|
||||||
2. **Large or one-off images** — a photo attachment opened at full
|
|
||||||
resolution, anything that would fragment a shared page — get their
|
|
||||||
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
|
|
||||||
is O(1): it references only itself, and does not touch any other
|
|
||||||
texture's binding, unlike today's shared array where every push
|
|
||||||
rebuilds a structure listing everything.
|
|
||||||
3. **Opening a new atlas page** is the one case that still resembles
|
|
||||||
today's rebuild — infrequent (bounded by how many *pages* are needed,
|
|
||||||
not by how many images have ever been attached) but not free, and
|
|
||||||
Vello's Mali-G52 fix says this specifically deserves care: it should
|
|
||||||
never be allowed to block a frame, and it is worth having the
|
|
||||||
equivalent of Vello's atlas diagnostics before trusting it under load.
|
|
||||||
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
|
|
||||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
|
||||||
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
|
|
||||||
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
|
|
||||||
This is also what fixes the emulator failure measured above, regardless
|
|
||||||
of the unresolved wgpu-hal question: a device that never asks for the
|
|
||||||
feature cannot be refused for lacking it.
|
|
||||||
|
|
||||||
## What this touches, and what is still open
|
|
||||||
|
|
||||||
Implementing this reworks iris's rendering core: the shader's binding
|
|
||||||
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
|
|
||||||
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
|
|
||||||
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
|
|
||||||
batching (today one draw call can reference any texture by index; the
|
|
||||||
per-texture-bind-group path needs draws grouped by which bind group they
|
|
||||||
use). Nothing has been started.
|
|
||||||
|
|
||||||
Open questions a reviewer should weigh in on:
|
|
||||||
|
|
||||||
- **The size threshold** between "goes in an atlas page" and "gets its own
|
|
||||||
bind group." Too low and ordinary attachment thumbnails end up as
|
|
||||||
one-off bind groups, losing the batching benefit the atlas exists for;
|
|
||||||
too high and a page fragments on a handful of medium images.
|
|
||||||
- **Eviction policy** for atlas pages once the working set does not fit —
|
|
||||||
today's `GlyphAtlas` never evicts, because a font's glyph set is small
|
|
||||||
and bounded; images are not. An LRU at the page level, or at the
|
|
||||||
individual-image level within a page, has not been designed.
|
|
||||||
- **Whether iris should keep any binding array at all**, even a small
|
|
||||||
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
|
|
||||||
or whether every atlas page should also be its own ordinary bind group
|
|
||||||
like standalone images — the array's only remaining justification would
|
|
||||||
be avoiding a bind-group-per-draw-call switch cost that has not been
|
|
||||||
measured on this project's actual target hardware.
|
|
||||||
- **How this interacts with I2/E2's virtualised list** (I3): a
|
|
||||||
bottom-anchored transcript composes only visible rows, so the live
|
|
||||||
texture set should already be bounded by what is on screen rather than
|
|
||||||
by the whole conversation — worth confirming that invariant holds before
|
|
||||||
relying on it to keep atlas/bind-group churn small.
|
|
||||||
|
|
||||||
## Review, 2026-09-04
|
|
||||||
|
|
||||||
A second pass over the file above against the code, done before anything
|
|
||||||
is implemented. Iris's worry going in: a bind group per texture means a
|
|
||||||
draw call per image, and she wants this as efficient as it can be.
|
|
||||||
|
|
||||||
### What checked out
|
|
||||||
|
|
||||||
Every code reference above is accurate as of this commit: the 100,000 /
|
|
||||||
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
|
|
||||||
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
|
|
||||||
asks for the three features is `iris/src/default/render.rs:96`, which the
|
|
||||||
text above does not name. egui-wgpu and Vello are described correctly.
|
|
||||||
|
|
||||||
### The emulator refusal is a wgpu-hal gap, now located
|
|
||||||
|
|
||||||
The file guessed "a likely instance-version negotiation gap." It is
|
|
||||||
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
|
|
||||||
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
|
|
||||||
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
|
|
||||||
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
|
|
||||||
that has descriptor indexing as core need not list the extension, and
|
|
||||||
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
|
|
||||||
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
|
|
||||||
The properties query beside it (line 1486) correctly accepts
|
|
||||||
`device_api_version >= 1.2 || extension`; the features query does not.
|
|
||||||
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
|
|
||||||
1872 and 2036). Worth an upstream issue, but not a reason to keep the
|
|
||||||
design: on real phones the gate that matters is stricter still.
|
|
||||||
|
|
||||||
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
|
|
||||||
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
|
|
||||||
sampled images, storage images and storage buffers, all together, because
|
|
||||||
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
|
|
||||||
"the extension is supported on Valhall" is necessary but not sufficient;
|
|
||||||
a driver with sampled-image indexing and without storage-buffer
|
|
||||||
update-after-bind is refused too. That widens the excluded set beyond
|
|
||||||
what the Arm quote suggests and strengthens the conclusion.
|
|
||||||
|
|
||||||
### A live bug in the current code, found on the way
|
|
||||||
|
|
||||||
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
|
|
||||||
"a patch must not report changed" as `changed = false`, unconditionally,
|
|
||||||
which also **cancels a `Push` earlier in the same batch**. That ordering is
|
|
||||||
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
|
|
||||||
pushes the page and `insert` patches it in the same frame, so the bind
|
|
||||||
group is not rebuilt and the new page's view is not bound until some
|
|
||||||
unrelated texture change happens to rebuild it. It is hidden today only
|
|
||||||
because the masks path also sets `changed`. The fix is one line
|
|
||||||
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
|
|
||||||
the redesign since that code is being replaced, and it is recorded here
|
|
||||||
so it is not rediscovered.
|
|
||||||
|
|
||||||
### In-layer draw order is already undefined
|
|
||||||
|
|
||||||
Relevant to any batching redesign: `Primitives::apply_free`
|
|
||||||
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
|
|
||||||
order within a layer is permuted whenever anything is freed. Overlap order
|
|
||||||
inside one layer is therefore not something the renderer promises today;
|
|
||||||
ordering is done with layers. That means grouping a layer's draws by
|
|
||||||
texture, or drawing a layer's images after its rects and glyphs, loses
|
|
||||||
nothing that currently exists. It should be written down as an invariant
|
|
||||||
when the redesign lands, because the new code will depend on it.
|
|
||||||
|
|
||||||
### On "a draw call per image"
|
|
||||||
|
|
||||||
Two corrections to the worry. First, it is a draw per *distinct texture per
|
|
||||||
layer*, not per image primitive: every glyph quad in a layer shares the
|
|
||||||
atlas and stays one instanced draw, and a thumbnail atlas would do the same
|
|
||||||
for previews. Second, the count is bounded by what is on screen, which I3's
|
|
||||||
virtualised transcript already bounds, and a mobile GPU is not draw-call
|
|
||||||
bound at tens of draws per frame; egui ships exactly this on Android. What
|
|
||||||
does cost is per-frame *bind group creation* and per-frame *sorting*, and
|
|
||||||
the current code already creates a `primitive_group` bind group every time
|
|
||||||
a layer updates (`render/mod.rs:103`), so one more per new image is not a
|
|
||||||
regression in kind.
|
|
||||||
|
|
||||||
### Recommended shape (proposal, for Iris to accept or change)
|
|
||||||
|
|
||||||
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
|
|
||||||
|
|
||||||
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
|
|
||||||
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
|
|
||||||
constraint an array texture imposes. A layer index is an ordinary
|
|
||||||
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
|
|
||||||
(and any future atlased-image primitive) carries a layer instead of a
|
|
||||||
`view_idx` and all of a layer's text stays **one draw**. This answers
|
|
||||||
the open question above about keeping a small binding array: no. Cost
|
|
||||||
of opening a page: recreate the array with one more layer and
|
|
||||||
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
|
|
||||||
with headroom (double) so it is rare. wgpu's default
|
|
||||||
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
|
|
||||||
rather than the API.
|
|
||||||
2. **Every standalone image is its own texture with its own bind group**,
|
|
||||||
and its instances live in a **separate per-layer instance list**, not
|
|
||||||
the main one. Then the main instance buffer never contains an image,
|
|
||||||
there is nothing to sort, no handle remapping beyond what
|
|
||||||
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
|
|
||||||
its bind group set first. Group 2's layout becomes `{atlas array,
|
|
||||||
one image texture, sampler, masks}`; the main draw binds a 1x1 null
|
|
||||||
image in the image slot, each image draw binds its own. One pipeline,
|
|
||||||
one shader, one layout.
|
|
||||||
3. **No thumbnail atlas in the first version.** With images on their own
|
|
||||||
textures, the threshold and eviction questions above disappear: an
|
|
||||||
image is freed when the row that owns its `TextureHandle` scrolls out.
|
|
||||||
Add an image atlas only if a measured screen shows enough small images
|
|
||||||
to matter, which a transcript rarely does.
|
|
||||||
4. **Drop the three features and the two `max_binding_array_*` limits from
|
|
||||||
`src/default/render.rs`**, and the `UiLimits` counts with them.
|
|
||||||
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
|
|
||||||
downscaled attachment will alias. Either request a filtering sampler
|
|
||||||
for the image slot or downscale on the CPU before upload; decide when
|
|
||||||
the image widget is touched, not as part of this.
|
|
||||||
|
|
||||||
What this costs against the file's original recommendation: `Textures`
|
|
||||||
needs to know an image from a page (two kinds of handle, or a kind on
|
|
||||||
`TextureHandle`), and `Primitives` gets a second instance list per layer.
|
|
||||||
What it saves: the sort, the size threshold, the eviction policy, and any
|
|
||||||
per-page bind group switch.
|
|
||||||
|
|
||||||
## Implemented, 2026-09-04
|
|
||||||
|
|
||||||
The shape above, built as proposed with one structural addition the proposal
|
|
||||||
didn't need to spell out and one bug it predicted made moot rather than
|
|
||||||
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
|
||||||
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
|
||||||
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
|
||||||
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
|
||||||
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
|
||||||
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
|
||||||
`rigs/gpu-probe/src/main.rs`.
|
|
||||||
|
|
||||||
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
|
||||||
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
|
||||||
(`grow_array`): a new texture is created at twice the layer capacity, the
|
|
||||||
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
|
||||||
readback), and every bind group that referenced the old view — the main
|
|
||||||
one and every live standalone image's — is rebuilt, since the view's
|
|
||||||
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
|
||||||
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
|
||||||
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
|
||||||
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
|
||||||
any GPU sync happens — the renderer only finds out later, when it
|
|
||||||
processes the queued `Push`.
|
|
||||||
|
|
||||||
**2. Standalone images, one bind group each.** `TextureKind` on
|
|
||||||
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
|
||||||
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
|
||||||
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
|
||||||
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
|
||||||
than through the generic `Primitive` trait, since an image has nowhere in
|
|
||||||
`PrimitiveData` to put a per-instance entry once the bind group already
|
|
||||||
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
|
||||||
once as before, then walks `image_instance` one entry at a time, binding
|
|
||||||
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
|
||||||
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
|
||||||
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
|
||||||
1x1 null view in the image slot.
|
|
||||||
|
|
||||||
**The one addition beyond the proposal**: the masks storage buffer lives
|
|
||||||
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
|
||||||
recreates its buffer whenever the mask count changes size
|
|
||||||
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
|
||||||
A resize invalidates every bind group holding the old buffer, not just the
|
|
||||||
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
|
||||||
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
|
||||||
array-growth path already needed. This wasn't a design question the
|
|
||||||
proposal had to answer (it treated bind-group construction as a given),
|
|
||||||
but it's exactly the shape of trap layer growth already had, so it uses
|
|
||||||
the same fix.
|
|
||||||
|
|
||||||
**3. No thumbnail atlas.** Not built, as proposed.
|
|
||||||
|
|
||||||
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
|
||||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
|
||||||
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
|
||||||
itself, not just its binding-array methods — once its two fields were
|
|
||||||
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
|
||||||
a limits parameter). `binding_array` no longer appears anywhere in
|
|
||||||
`shader.wgsl`.
|
|
||||||
|
|
||||||
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
|
||||||
note that this is a separate decision for whenever the image widget itself
|
|
||||||
is touched.
|
|
||||||
|
|
||||||
**The `changed = false` bug is structurally gone, not patched.** The old
|
|
||||||
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
|
||||||
unconditionally, which could erase an earlier `Push` in the same batch (a
|
|
||||||
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
|
||||||
`Patch`, both queued before the renderer ever runs). The new `update`
|
|
||||||
computes the rebuild signal by OR-ing each event's own answer
|
|
||||||
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
|
||||||
contributes to it — there is no shared mutable flag left for a `Patch` to
|
|
||||||
stomp on. Documented at the call site
|
|
||||||
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
|
||||||
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
|
||||||
the mechanism that could go wrong no longer exists.
|
|
||||||
|
|
||||||
**In-layer draw order is an explicit invariant now, not just a fact about
|
|
||||||
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
|
||||||
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
|
||||||
directly that both of a layer's lists (`instances` and `images`) free with
|
|
||||||
`swap_remove` and that nothing may assume adjacency survives a free —
|
|
||||||
recorded there because `apply_free` is the one place a change to either
|
|
||||||
list's ordering would have to be reconciled.
|
|
||||||
|
|
||||||
**Verified:**
|
|
||||||
|
|
||||||
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
|
||||||
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
|
||||||
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
|
||||||
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
|
||||||
test to — it's all GPU resource wiring).
|
|
||||||
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
|
||||||
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
|
||||||
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
|
||||||
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
|
||||||
- The standalone-image path specifically: a throwaway example (not
|
|
||||||
committed) with an `image(...)` widget as part of the root, run the same
|
|
||||||
way, rendered the image next to glyph-atlas text in one frame —
|
|
||||||
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
|
||||||
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
|
||||||
"image span" tab exercises the same widget but needs a click to reach,
|
|
||||||
which the headless compositor can't deliver (no seat devices, per I1's
|
|
||||||
own note on this file) — the throwaway example is what stood in for it.
|
|
||||||
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
|
||||||
Rather than building a purpose-made glyph flood, `PAGE`
|
|
||||||
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
|
||||||
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
|
||||||
exotic: a handful of `Text` widgets at a few sizes, one at
|
|
||||||
`Family::Monospace`) already exceeds one page's worth of distinct
|
|
||||||
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
|
||||||
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
|
||||||
glyphs landed on at least a third layer), and
|
|
||||||
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
|
||||||
correctly with no corruption or missing glyphs — confirming the
|
|
||||||
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
|
||||||
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
|
||||||
both work. Command:
|
|
||||||
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
|
||||||
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
|
||||||
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
|
||||||
throwaway diagnostic value, never a committed change, since a real
|
|
||||||
1024px page holding only a handful of glyphs at a time would be mostly
|
|
||||||
wasted space in normal use. Confirmed the revert left `tabs` and
|
|
||||||
`minimal` byte-identical to the pre-check screenshots afterward.
|
|
||||||
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
|
|
||||||
(empty) feature/limit set and run on this checkout's own emulator
|
|
||||||
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
|
||||||
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
|
||||||
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
|
||||||
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
|
||||||
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
|
||||||
at all, which reads exactly like the old bindless failure if you don't
|
|
||||||
know to ask for `EMU_GPU=software`):
|
|
||||||
|
|
||||||
cd rigs/gpu-probe
|
|
||||||
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
|
||||||
cargo ndk -t arm64-v8a -P 26 build --release
|
|
||||||
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
|
||||||
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
|
||||||
adb shell chmod 755 /data/local/tmp/gpu-probe
|
|
||||||
adb shell /data/local/tmp/gpu-probe
|
|
||||||
|
|
||||||
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
|
||||||
`features iris requires:` (none listed — the set is empty),
|
|
||||||
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
|
||||||
measured working, on the exact rig that first measured it failing.
|
|
||||||
Emulator stopped afterward (`emu down`); nothing was left running.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
|
|
||||||
one in place when it turns out to need a decision.
|
|
||||||
|
|
||||||
## App — transcript
|
|
||||||
|
|
||||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
|
||||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
|
||||||
measured: a live Claude session only learns of a peer message from the
|
|
||||||
`origin` object on a turn's `result`
|
|
||||||
(`session/claude/translate.rs`), which the CLI attaches to a turn the
|
|
||||||
message *started*. So a message that arrives mid-turn, or a second one
|
|
||||||
within one turn, has nowhere to be reported — while an imported session,
|
|
||||||
which syncs from the CLI's own file, picks up every one of them. That
|
|
||||||
would show exactly as "sometimes". Confirming it means driving a real
|
|
||||||
stream-json session and sending it messages in both states.
|
|
||||||
|
|
||||||
## Session settings
|
|
||||||
|
|
||||||
- [ ] Autocompact belongs in session settings; empty disables it, which is the
|
|
||||||
default. Iris chose "hand it to the driver" — only where a driver has
|
|
||||||
auto-compaction of its own. **That option was offered on a false premise
|
|
||||||
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
|
|
||||||
was never built as a driver here: `session/llama.rs` talks to
|
|
||||||
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
|
|
||||||
refuses outright. Claude Code's auto-compaction is the CLI's own and
|
|
||||||
nothing in the stream-json control protocol this app uses configures it.
|
|
||||||
So the setting would be stored, passed to a driver, refused by every one
|
|
||||||
of them, and the field would never appear on any session. What is needed
|
|
||||||
first is either a driver that can take it, or a different rule — the
|
|
||||||
server watching `contextTokens` and running `/compact` itself is the one
|
|
||||||
that would work today, for Claude sessions, and it is the option that was
|
|
||||||
not chosen.
|
|
||||||
|
|
||||||
Generated
-1081
File diff suppressed because it is too large.
Load diff
@@ -1,36 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "android-shell"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
|
||||||
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
|
||||||
# why those two classes cannot be anything but Java/Kotlin, registered from
|
|
||||||
# the manifest by name). Everything they would otherwise have done in
|
|
||||||
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
|
||||||
# picking a session for a share -- is here instead, built on `client-core`
|
|
||||||
# so the networking and parsing are not duplicated a third time next to the
|
|
||||||
# server and the Kotlin app.
|
|
||||||
#
|
|
||||||
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
|
||||||
# on a normal host target without an Android NDK toolchain, the same
|
|
||||||
# posture `client-core` and `server` already have.
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "android_shell"
|
|
||||||
crate-type = ["cdylib", "lib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
client-core = { path = "../client-core" }
|
|
||||||
jni = "0.22"
|
|
||||||
log = "0.4"
|
|
||||||
|
|
||||||
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
|
||||||
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
|
||||||
# without a backend installed -- so without this, every recoverable error
|
|
||||||
# at a native entry point would be silently dropped rather than reaching
|
|
||||||
# logcat. Android-only: nothing else here needs it, and it does not build
|
|
||||||
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
|
||||||
# is used).
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
android_logger = "0.15"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
|
||||||
//! (a class name, a method name and a signature, all as plain `&str`).
|
|
||||||
//!
|
|
||||||
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
|
||||||
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
|
||||||
//! here uniformly rather than switching to the compile-time `jni_str!`
|
|
||||||
//! literal macro call by call -- these are a handful of short, one-off
|
|
||||||
//! lookups, not a hot loop, so the difference is not worth two code paths
|
|
||||||
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
|
||||||
//! `FieldSignature`, which is why those go through
|
|
||||||
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
|
||||||
//! parsed form is what lets these calls skip re-validating the signature
|
|
||||||
//! against the arguments on every call, which is the whole reason `jni`
|
|
||||||
//! moved to it.
|
|
||||||
//!
|
|
||||||
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
|
||||||
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
|
||||||
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
|
||||||
//! `call_method` on an object it already has, which needs no such lookup)
|
|
||||||
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
|
||||||
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
|
||||||
//! That default is fine on a thread the JVM itself started -- an
|
|
||||||
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
|
||||||
//! from `android-shell`'s own background thread (the notification
|
|
||||||
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
|
||||||
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
|
||||||
//! never gave an app classloader. Framework classes
|
|
||||||
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
|
||||||
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
|
||||||
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
|
||||||
//! packaged inside this app's own APK. The failure was
|
|
||||||
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
|
||||||
//! as "failed to resolve Java class ... (class not found or linkage
|
|
||||||
//! error)" -- on a real device this reads as "the notification silently
|
|
||||||
//! never arrives," since the whole call is inside the follow loop and the
|
|
||||||
//! ongoing foreground notification (built on the main thread, in
|
|
||||||
//! `try_start`, before the background thread exists) posts fine either
|
|
||||||
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
|
||||||
//! first time any entry point has a `Context` to ask, and every class
|
|
||||||
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
|
||||||
//! rather than the thread-dependent default -- so it is correct on the
|
|
||||||
//! main thread and on this crate's own background threads alike.
|
|
||||||
|
|
||||||
use jni::Env;
|
|
||||||
use jni::errors::Result;
|
|
||||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
|
||||||
use jni::refs::{Global, LoaderContext};
|
|
||||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
|
||||||
use jni::strings::JNIString;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
|
||||||
/// Cheap to call from every entry point that has a `Context` on hand
|
|
||||||
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
|
||||||
/// a `OnceLock::get` and nothing else.
|
|
||||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
|
||||||
if CLASS_LOADER.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
|
||||||
// real objects throughout, so this needs no class-name lookup of its
|
|
||||||
// own and has nothing to bootstrap.
|
|
||||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
|
||||||
let loader_obj = call_method(
|
|
||||||
env,
|
|
||||||
&class_obj,
|
|
||||||
"getClassLoader",
|
|
||||||
"()Ljava/lang/ClassLoader;",
|
|
||||||
&[],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
|
||||||
let global = env.new_global_ref(&loader)?;
|
|
||||||
// Lost the race with another entry point calling this concurrently --
|
|
||||||
// both loaders name the same app, so either one is fine and there is
|
|
||||||
// nothing to reconcile.
|
|
||||||
let _ = CLASS_LOADER.set(global);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
|
||||||
/// through the cached app classloader when one has been remembered, and
|
|
||||||
/// through the ordinary default otherwise -- which is every call made
|
|
||||||
/// before any entry point has run, and is also correct for a main-thread
|
|
||||||
/// caller, so there is no case this makes worse.
|
|
||||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
||||||
match CLASS_LOADER.get() {
|
|
||||||
Some(loader) => {
|
|
||||||
let binary_name = name.replace('/', ".");
|
|
||||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
|
||||||
}
|
|
||||||
None => env.find_class(JNIString::new(name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
||||||
resolve_class(env, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A new Java string as a plain `JObject` -- what every call site here
|
|
||||||
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
|
||||||
/// `JString: Into<JObject>` is the documented way across).
|
|
||||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
|
||||||
Ok(env.new_string(text)?.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_object<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JObject<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.new_object(class, sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_method<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
obj: &JObject,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_static_method<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_static_field<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
field: &str,
|
|
||||||
sig: &str,
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
android-project/.gradle/
|
||||||
|
android-project/build/
|
||||||
|
android-project/app/build/
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
|
||||||
|
# Gradle build -- see RUST.md's I2 for the exact command.
|
||||||
|
android-project/app/src/main/jniLibs/
|
||||||
|
target/
|
||||||
|
Cargo.lock.orig
|
||||||
+515
-168
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,109 @@
|
|||||||
|
# Product code lives here; reusable UI belongs in `iris/`.
|
||||||
|
[package]
|
||||||
|
name = "ai-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# Android loads the cdylib; desktop, examples, and tests link the rlib.
|
||||||
|
[lib]
|
||||||
|
name = "ai_app"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ai-app-desktop"
|
||||||
|
path = "src/bin_desktop.rs"
|
||||||
|
required-features = ["screens"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "transcript"
|
||||||
|
required-features = ["screens"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "phone"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
event-model = { path = "../event-model" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
# Transcript lines must retain exact float values and raw JSON bytes.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||||
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
|
base64 = "0.23"
|
||||||
|
log = { version = "0.4.34", features = ["std"] }
|
||||||
|
|
||||||
|
# Optional so the Compose shell does not link the renderer.
|
||||||
|
iris = { path = "../iris", optional = true }
|
||||||
|
tabs-ui = { path = "../iris/tabs-ui", optional = true }
|
||||||
|
jni = { version = "0.22", optional = true }
|
||||||
|
libc = { version = "0.2.189", optional = true }
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
winit = "0.30.13"
|
||||||
|
|
||||||
|
# Keep this pin synchronized with `iris/Cargo.toml`.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
android_logger = "0.15.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["screens", "fixture"]
|
||||||
|
screens = ["dep:iris"]
|
||||||
|
# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture.
|
||||||
|
fixture = ["screens"]
|
||||||
|
transcript-screen = ["screens"]
|
||||||
|
tabs-screen = ["screens", "dep:tabs-ui"]
|
||||||
|
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
|
||||||
|
shell = ["dep:jni"]
|
||||||
|
force-gles = ["screens", "iris/force-gles"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"] }
|
||||||
|
|
||||||
|
# APK builds select these profiles explicitly.
|
||||||
|
[profile.android-release]
|
||||||
|
inherits = "release"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus
|
||||||
|
# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost.
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.android-dev]
|
||||||
|
inherits = "dev"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# Full DWARF in each renderer-linked test binary writes tens of gigabytes.
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[profile.test]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "catch_a_fling"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "fence_fling"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "gesture_cancel"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "input_log_roundtrip"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "phone_screen"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "top_edge"
|
||||||
|
required-features = ["fixture"]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Rust side (this directory's Cargo.toml) is built separately with
|
||||||
|
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
|
||||||
|
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
|
||||||
|
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
|
||||||
|
// build command.
|
||||||
|
android {
|
||||||
|
namespace = "dev.iris.android.demo"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "dev.iris.android.demo"
|
||||||
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
|
// sample with `MotionEvent.getEventTimeNanos` and
|
||||||
|
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||||
|
// method there is a hard crash on the first touch rather than a
|
||||||
|
// degraded fling. Raised deliberately rather than guarded at
|
||||||
|
// runtime: nothing this app is built for runs below 29, and an
|
||||||
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
|
// `cargo ndk -P` is kept at the same number.
|
||||||
|
minSdk = 29
|
||||||
|
// 37, matching `compileSdk` and the Compose app in `app/` -- which
|
||||||
|
// is the one part of this that is measured rather than reasoned:
|
||||||
|
// that app targets 37 and its keyboard does push the transcript up
|
||||||
|
// on Iris's phone, and this one targeted 34 and does not
|
||||||
|
// (2026-09-07). The emulator here is API 36 and the push-up works
|
||||||
|
// there at either target, so the target is the only difference the
|
||||||
|
// two devices do not share.
|
||||||
|
//
|
||||||
|
// The mechanism, stated as the reading it is: below targetSdk 35
|
||||||
|
// a window keeps the legacy behaviour, where `adjustResize` shrinks
|
||||||
|
// the window for the IME and `getInsets(ime()).bottom` therefore
|
||||||
|
// measures the overlap with an already-shrunk window -- zero, with
|
||||||
|
// nothing left to push up. `MainActivity`'s
|
||||||
|
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
|
||||||
|
// 36 it still takes; Android 16 deprecated it and Android 17 is
|
||||||
|
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
|
||||||
|
// the app is handed the real overlap without relying on a
|
||||||
|
// deprecated call. If the phone still reports `ime_bottom=0` with
|
||||||
|
// a nonzero `dispatches` in the Diagnostics pane, this reading was
|
||||||
|
// wrong and the `WindowInsetsAnimation.Callback` in
|
||||||
|
// `MainActivity` is the other half to look at.
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release build must be signed, and the key is per machine rather than per repo -- same
|
||||||
|
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
|
||||||
|
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
|
||||||
|
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
|
||||||
|
// environment; without it a release build here is unsigned, which is fine for everything
|
||||||
|
// except installing.
|
||||||
|
def keystore = System.getenv("AI_APP_KEYSTORE")
|
||||||
|
signingConfigs {
|
||||||
|
if (keystore != null) {
|
||||||
|
release {
|
||||||
|
storeFile = file(keystore)
|
||||||
|
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = "ai-app"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
}
|
||||||
|
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
|
||||||
|
// itself is built separately with `cargo ndk --release --features "transcript-screen
|
||||||
|
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
|
||||||
|
// Gradle here only packages and signs whatever is already there, the same division as the
|
||||||
|
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
|
||||||
|
// installable beside a debug build of the tabs demo rather than replacing it.
|
||||||
|
release {
|
||||||
|
applicationIdSuffix ".bench"
|
||||||
|
if (keystore != null) {
|
||||||
|
signingConfig = signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
|
||||||
|
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
|
||||||
|
no network call and never noticed this was missing. Absent,
|
||||||
|
UreqTransport::new's connect failed with EPERM (Operation not
|
||||||
|
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
|
||||||
|
server would give: a seccomp-level socket denial reads nothing
|
||||||
|
like a network problem, which is what made it worth a comment. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="iris android-view demo"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- The enrollment link Dev Updater's Enroll button opens
|
||||||
|
(what `ai-server` mints), the same one the Compose app
|
||||||
|
in `app/` registers: which app answers it is the phone
|
||||||
|
owner's choice at the moment of the tap, and both being
|
||||||
|
offered is the intended behaviour rather than a clash.
|
||||||
|
BROWSABLE so a link tapped in another app reaches here,
|
||||||
|
and `android:host` so this app is not offered for every
|
||||||
|
aiapp:// URI a future route invents. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="aiapp" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- This app's own recent log, for Dev Updater to read on the
|
||||||
|
phone. Iris runs these builds with no adb, and Android
|
||||||
|
forbids one app reading another's logcat, so this is the
|
||||||
|
only way a log::info! here reaches her. The shape is Dev
|
||||||
|
Updater's contract (its README.md, "An app's own log"), not
|
||||||
|
something invented for this app.
|
||||||
|
|
||||||
|
The authority carries ${applicationId}, so the bench package
|
||||||
|
and the ordinary one each get their own and neither can read
|
||||||
|
the other's log. Exported, because the whole point is
|
||||||
|
another app reading it, and guarded by a permission Dev
|
||||||
|
Updater declares at protectionLevel="normal" (a signature
|
||||||
|
permission is not available: the two apps are signed with
|
||||||
|
different locally generated keys). Read-only: insert,
|
||||||
|
update and delete throw. -->
|
||||||
|
<provider
|
||||||
|
android:name=".DevLogProvider"
|
||||||
|
android:authorities="${applicationId}.devlog"
|
||||||
|
android:exported="true"
|
||||||
|
android:readPermission="dev.updater.permission.READ_DEVLOG" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.UriMatcher;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
|
||||||
|
public final class DevLogProvider extends ContentProvider {
|
||||||
|
static {
|
||||||
|
// A provider can start the process without creating MainActivity.
|
||||||
|
System.loadLibrary("ai_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int FIELDS_PER_LINE = 5;
|
||||||
|
|
||||||
|
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||||
|
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
|
||||||
|
|
||||||
|
private static final int LINES = 1;
|
||||||
|
private static final int STATUS = 2;
|
||||||
|
|
||||||
|
private UriMatcher matcher;
|
||||||
|
|
||||||
|
private static native String[] nativeLinesSince(long since);
|
||||||
|
|
||||||
|
private static native String[] nativeStatus();
|
||||||
|
|
||||||
|
// The provider may be the process's only component, so it must supply
|
||||||
|
// the files directory normally initialized by MainActivity.
|
||||||
|
private static native void nativeReady(String authority, String filesDir);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
String authority = getContext().getPackageName() + ".devlog";
|
||||||
|
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||||
|
matcher.addURI(authority, "lines", LINES);
|
||||||
|
matcher.addURI(authority, "status", STATUS);
|
||||||
|
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(
|
||||||
|
Uri uri,
|
||||||
|
String[] projection,
|
||||||
|
String selection,
|
||||||
|
String[] selectionArgs,
|
||||||
|
String sortOrder) {
|
||||||
|
switch (matcher.match(uri)) {
|
||||||
|
case LINES:
|
||||||
|
return lines(sinceOf(uri));
|
||||||
|
case STATUS:
|
||||||
|
return status();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long sinceOf(Uri uri) {
|
||||||
|
String since = uri.getQueryParameter("since");
|
||||||
|
if (since == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(since);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor lines(long since) {
|
||||||
|
String[] fields = nativeLinesSince(since);
|
||||||
|
if (fields == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
|
||||||
|
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[at]),
|
||||||
|
Long.parseLong(fields[at + 1]),
|
||||||
|
fields[at + 2],
|
||||||
|
fields[at + 3],
|
||||||
|
fields[at + 4],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor status() {
|
||||||
|
String[] fields = nativeStatus();
|
||||||
|
if (fields == null || fields.length != STATUS_COLUMNS.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
|
||||||
|
});
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.linebender.android.rustview.RustView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* android-view's abstract base plus the two native methods it has no hook
|
||||||
|
* for: window insets and unregistering this view's entry in
|
||||||
|
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
|
||||||
|
* comment for why those could not ride along on an existing android-view
|
||||||
|
* callback the way the back gesture does.
|
||||||
|
*/
|
||||||
|
public final class IrisView extends RustView {
|
||||||
|
@Override
|
||||||
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
|
native void applyWindowInsetsNative(
|
||||||
|
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||||
|
|
||||||
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
|
public IrisView(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyWindowInsets(
|
||||||
|
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
unregisterInsetsNative(mViewPeer);
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This path must not depend on the renderer that failed to initialize.
|
||||||
|
void showRendererError(String report) {
|
||||||
|
Context context = getContext();
|
||||||
|
if (!(context instanceof Activity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Activity activity = (Activity) context;
|
||||||
|
TextView text = new TextView(activity);
|
||||||
|
text.setText(report);
|
||||||
|
text.setTextIsSelectable(true);
|
||||||
|
text.setGravity(Gravity.TOP | Gravity.START);
|
||||||
|
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
|
||||||
|
text.setPadding(pad, pad, pad, pad);
|
||||||
|
ScrollView scroll = new ScrollView(activity);
|
||||||
|
scroll.addView(text);
|
||||||
|
activity.setContentView(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("ai_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeSetFilesDir(String path);
|
||||||
|
|
||||||
|
private static native void nativeEnroll(String uri);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||||
|
handleEnrollmentIntent(getIntent());
|
||||||
|
IrisView view = new IrisView(this);
|
||||||
|
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
view.setFocusable(true);
|
||||||
|
view.setFocusableInTouchMode(true);
|
||||||
|
FrameLayout layout = new FrameLayout(this);
|
||||||
|
layout.addView(view);
|
||||||
|
setContentView(layout);
|
||||||
|
view.requestFocus();
|
||||||
|
|
||||||
|
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static dispatch supplies settled insets; the animation callback
|
||||||
|
// supplies intermediate IME heights. An interrupted animation may
|
||||||
|
// omit its final progress frame, so onEnd re-reads the root insets.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||||
|
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||||
|
@Override
|
||||||
|
public WindowInsets onProgress(
|
||||||
|
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||||
|
sendInsets(view, insets);
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnd(WindowInsetsAnimation animation) {
|
||||||
|
WindowInsets settled = view.getRootWindowInsets();
|
||||||
|
if (settled != null) {
|
||||||
|
sendInsets(view, settled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
sendInsets((IrisView) v, insets);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
// Keep getIntent() consistent with the enrollment being handled.
|
||||||
|
setIntent(intent);
|
||||||
|
handleEnrollmentIntent(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleEnrollmentIntent(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
nativeEnroll(data.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||||
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
|
int top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
// Visibility and height disagree during IME animation, so neither
|
||||||
|
// can be inferred from the other.
|
||||||
|
int imeBottom = 0;
|
||||||
|
int imeVisible = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
+2
-6
@@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager;
|
|||||||
|
|
||||||
public abstract class RustView extends SurfaceView
|
public abstract class RustView extends SurfaceView
|
||||||
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||||
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
// Vendored from android-view bec6c62. The only local change is `protected`,
|
||||||
// with one deliberate change: `protected` rather than package-private, so a
|
// allowing IrisView to forward insets through this native peer.
|
||||||
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
|
|
||||||
// it to the window-insets native call android-view itself has no hook for --
|
|
||||||
// see iris/src/android/insets.rs's doc comment for why that call exists at
|
|
||||||
// all. No other line differs from upstream.
|
|
||||||
protected final long mViewPeer;
|
protected final long mViewPeer;
|
||||||
final InputMethodManager mInputMethodManager;
|
final InputMethodManager mInputMethodManager;
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Builds the Android app end to end: the cdylib (cargo ndk from this
|
||||||
|
# directory, straight into android-project/app/src/main/jniLibs/) then the
|
||||||
|
# APK (Gradle, from android-project/). Written to stop re-typing
|
||||||
|
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
|
||||||
|
# cargo ndk invocation, the keystore env for a release build, apksigner/
|
||||||
|
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
|
||||||
|
# build-apk.sh` (the Compose app's own build script) and `app/
|
||||||
|
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
|
||||||
|
#
|
||||||
|
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
|
||||||
|
# debug/release default to debug (matches this-machine-android's "the
|
||||||
|
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||||
|
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||||
|
# x86_64 for this checkout's own AVD. --features defaults to
|
||||||
|
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
|
||||||
|
# nothing should add it back for the emulator's sake.
|
||||||
|
#
|
||||||
|
# **The emulator does not need a GLES build, because it has no hardware
|
||||||
|
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
|
||||||
|
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
|
||||||
|
# in software, its GLES is the host's real GPU through virgl, and iris's
|
||||||
|
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
|
||||||
|
# `Backends::GL` -- takes an ordinary build there by itself. So the
|
||||||
|
# emulator and the phone run the *same binary* and differ only in what
|
||||||
|
# that binary finds, which is the whole point: a build flag that changed
|
||||||
|
# the backend would mean the thing measured here is not the thing
|
||||||
|
# shipped.
|
||||||
|
#
|
||||||
|
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
|
||||||
|
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
|
||||||
|
# frame time goes"), and the desktop is the better place to run it now
|
||||||
|
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
|
||||||
|
# to reach a real device, but this script's old default put it in every
|
||||||
|
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
|
||||||
|
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
|
||||||
|
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
|
||||||
|
# build meant for a phone.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
ABI="arm64-v8a"
|
||||||
|
FEATURES="transcript-screen bench"
|
||||||
|
case "${1:-}" in
|
||||||
|
debug|release) BUILD_TYPE="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--abi) ABI="$2"; shift 2 ;;
|
||||||
|
--features) FEATURES="$2"; shift 2 ;;
|
||||||
|
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SDK_ROOT="$HOME/Android/Sdk"
|
||||||
|
export ANDROID_HOME="$SDK_ROOT"
|
||||||
|
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||||
|
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
|
||||||
|
if [ -z "$NDK_DIR" ]; then
|
||||||
|
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||||
|
|
||||||
|
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
|
||||||
|
# whatever earlier builds left here, and Gradle packages every directory it
|
||||||
|
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
|
||||||
|
# 339 MB on 2026-09-06.
|
||||||
|
rm -rf android-project/app/src/main/jniLibs
|
||||||
|
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
|
||||||
|
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
|
||||||
|
# build that switches ABI packages the previous ABI: an `--abi x86_64`
|
||||||
|
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
|
||||||
|
# aborted at startup with `Could not get adapter!: NotFound {
|
||||||
|
# active_backends: VULKAN }` under libndk_translation -- which reads
|
||||||
|
# exactly like the phone's own Vulkan problem and is nothing of the kind.
|
||||||
|
# Scoped to the merge task's directory rather than all of `app/build`, so
|
||||||
|
# an ABI change costs the native merge and not the whole Gradle build.
|
||||||
|
rm -rf android-project/app/build/intermediates/merged_native_libs \
|
||||||
|
android-project/app/build/intermediates/stripped_native_libs \
|
||||||
|
android-project/app/build/intermediates/merged_jni_libs
|
||||||
|
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||||
|
--profile android-release --no-default-features --features "$FEATURES"
|
||||||
|
else
|
||||||
|
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||||
|
--profile android-dev --no-default-features --features "$FEATURES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GRADLE_TASK="assembleDebug"
|
||||||
|
APK_DIR="android-project/app/build/outputs/apk/debug"
|
||||||
|
APK_NAME="app-debug.apk"
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
GRADLE_TASK="assembleRelease"
|
||||||
|
APK_DIR="android-project/app/build/outputs/apk/release"
|
||||||
|
APK_NAME="app-release.apk"
|
||||||
|
# Same key `app/build-apk.sh` (the Compose app) generates once under
|
||||||
|
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
|
||||||
|
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
|
||||||
|
if [ ! -f "$AI_APP_KEYSTORE" ]; then
|
||||||
|
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export AI_APP_KEYSTORE_PASSWORD
|
||||||
|
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
|
||||||
|
fi
|
||||||
|
|
||||||
|
(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain)
|
||||||
|
|
||||||
|
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
|
||||||
|
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
|
||||||
|
echo "--- aapt2 dump badging ---"
|
||||||
|
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
echo "--- apksigner verify ---"
|
||||||
|
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
|
||||||
|
fi
|
||||||
|
echo "$APK_PATH"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
use iris::prelude::*;
|
||||||
|
use winit::{dpi::PhysicalSize, window::WindowAttributes};
|
||||||
|
|
||||||
|
fn ime_argv() -> Option<f32> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--ime" {
|
||||||
|
return args.next()?.parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_argv() -> Option<String> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--message" {
|
||||||
|
return Some(args.next()?.replace("\\n", "\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typed_argv() -> Option<String> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--typed" {
|
||||||
|
return Some(args.next()?.replace("\\n", "\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DefaultApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
screen: Option<ai_app::ui::TranscriptScreen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for Client {
|
||||||
|
fn window_attributes() -> WindowAttributes {
|
||||||
|
WindowAttributes::default()
|
||||||
|
.with_title("iris transcript (bench fixture)")
|
||||||
|
.with_inner_size(PhysicalSize::new(
|
||||||
|
ai_app::ui::fixture::PHONE_WIDTH,
|
||||||
|
ai_app::ui::fixture::PHONE_HEIGHT,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
|
||||||
|
Ok(opened) => {
|
||||||
|
if let Some(message) = message_argv() {
|
||||||
|
opened.screen.composer.field.edit(rsc).set(&message);
|
||||||
|
}
|
||||||
|
if let Some(text) = typed_argv() {
|
||||||
|
let field = opened.screen.composer.field;
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
for ch in text.chars() {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
ctx.update(move |state: &mut Client, rsc| {
|
||||||
|
state.set_focus(Some(field));
|
||||||
|
let end = rsc[field].text().len();
|
||||||
|
let mut edit = field.edit(rsc);
|
||||||
|
if edit.text.caret().is_none() {
|
||||||
|
edit.set_cursor_byte(end);
|
||||||
|
}
|
||||||
|
edit.insert(&ch.to_string());
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(inset) = ime_argv() {
|
||||||
|
opened.screen.composer.set_bottom_inset(rsc, inset);
|
||||||
|
}
|
||||||
|
Some(opened.screen)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(dp(16))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(rsc, text);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self { ui_state, screen }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
use ai_app::client::QuestionOption;
|
||||||
|
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DefaultApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
screen: ai_app::ui::TranscriptScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
|
||||||
|
FoldedRow::Single(if from_user {
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
|
||||||
|
tool_call_in("run1", id, tool, input, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call_in(
|
||||||
|
run: &str,
|
||||||
|
id: &str,
|
||||||
|
tool: &str,
|
||||||
|
input: &str,
|
||||||
|
result: Option<(&str, bool)>,
|
||||||
|
) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: id.into(),
|
||||||
|
run_id: run.into(),
|
||||||
|
tool: tool.into(),
|
||||||
|
input: input.into(),
|
||||||
|
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
|
||||||
|
done: result.is_some(),
|
||||||
|
failed: result.is_some_and(|(_, failed)| failed),
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||||
|
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||||
|
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||||
|
asks.push(QuestionCard {
|
||||||
|
seq: 9,
|
||||||
|
id: format!("{id}-q"),
|
||||||
|
prompt: "Allow this command?".into(),
|
||||||
|
header: None,
|
||||||
|
options: vec![
|
||||||
|
QuestionOption {
|
||||||
|
label: "Allow".into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
QuestionOption {
|
||||||
|
label: "Deny".into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multi_select: false,
|
||||||
|
answers: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
call
|
||||||
|
}
|
||||||
|
|
||||||
|
fn long_output() -> String {
|
||||||
|
(0..200)
|
||||||
|
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||||
|
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
||||||
|
),
|
||||||
|
FoldedRow::Tools(vec![
|
||||||
|
tool_call(
|
||||||
|
"t1",
|
||||||
|
"Read",
|
||||||
|
r#"{"file_path": "src/main.rs"}"#,
|
||||||
|
Some(("fn main() {}\n", false)),
|
||||||
|
),
|
||||||
|
tool_call(
|
||||||
|
"t2",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||||
|
Some((
|
||||||
|
"error: could not compile `iris`\nCaused by: linker not found",
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||||
|
]),
|
||||||
|
FoldedRow::Single(tool_call(
|
||||||
|
"t5",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
|
||||||
|
Some((&long_output(), false)),
|
||||||
|
)),
|
||||||
|
msg(6, true, "Looks good, thanks!"),
|
||||||
|
msg(7, false, BLOCK_SAMPLER),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK_SAMPLER: &str = "\
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Iris **fold** render measure session window anchor context transcript \
|
||||||
|
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||||
|
[bench](https://example.com/bench) link.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| column | value |
|
||||||
|
|---|---|
|
||||||
|
| a | measure place draw tool call token context window anchor |
|
||||||
|
|
||||||
|
- one bullet
|
||||||
|
- another, with `inline code`
|
||||||
|
- nested one level
|
||||||
|
1. first numbered
|
||||||
|
2. second numbered
|
||||||
|
|
||||||
|
> A quoted line, to show the bar and the indent.
|
||||||
|
";
|
||||||
|
|
||||||
|
impl DefaultAppState for Client {
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
|
||||||
|
screen.push_row(
|
||||||
|
rsc,
|
||||||
|
&FoldedRow::Single(TranscriptItem::CommandRow {
|
||||||
|
seq: 8,
|
||||||
|
text: "clear".into(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
screen.push_row(
|
||||||
|
rsc,
|
||||||
|
&FoldedRow::Tools(vec![
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t6",
|
||||||
|
"Read",
|
||||||
|
r#"{"file_path": "docs/RUST.md"}"#,
|
||||||
|
Some(("# Moving the app to Rust\n", false)),
|
||||||
|
),
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t7",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||||
|
Some(("error: unused variable `x`", true)),
|
||||||
|
),
|
||||||
|
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||||
|
asking(
|
||||||
|
"t9",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
screen.set_session_working(rsc, true);
|
||||||
|
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(rsc, true),
|
||||||
|
"the newest row must be the tool run this flag is about"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self { ui_state, screen }
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs and runs the iris `bench` build on this checkout's own emulator
|
||||||
|
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
|
||||||
|
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
|
||||||
|
# job. No coordinates: the button is found by its accessibility label
|
||||||
|
# through `ui-trace`, per AGENTS.md's "Driving the UI".
|
||||||
|
#
|
||||||
|
# Usage: ./run-bench.sh [--apk PATH]
|
||||||
|
# Defaults to this checkout's own release APK
|
||||||
|
# (android-project/app/build/outputs/apk/release/app-release.apk) if it
|
||||||
|
# exists, else the
|
||||||
|
# debug one -- build one first with ./build-apk.sh.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APK=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--apk) APK="$2"; shift 2 ;;
|
||||||
|
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -z "$APK" ]; then
|
||||||
|
if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
|
||||||
|
APK=android-project/app/build/outputs/apk/release/app-release.apk
|
||||||
|
else
|
||||||
|
APK=android-project/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ ! -f "$APK" ]; then
|
||||||
|
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERIAL=$(emu serial)
|
||||||
|
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||||
|
if [ -z "$PKG" ]; then
|
||||||
|
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
|
||||||
|
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
|
||||||
|
adb -s "$SERIAL" install -r "$APK" >/dev/null
|
||||||
|
adb -s "$SERIAL" shell am force-stop "$PKG"
|
||||||
|
adb -s "$SERIAL" logcat -c
|
||||||
|
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
|
||||||
|
|
||||||
|
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||||
|
|
||||||
|
# Which adapter drew, before any number is printed. The emulator is a GLES
|
||||||
|
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
|
||||||
|
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
|
||||||
|
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
|
||||||
|
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
|
||||||
|
# magnitude apart and are otherwise indistinguishable in this report, so
|
||||||
|
# the line is printed rather than left in logcat for somebody to think of.
|
||||||
|
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
|
||||||
|
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
|
||||||
|
if [ -n "$ADAPTER" ]; then
|
||||||
|
echo "run-bench.sh: $ADAPTER"
|
||||||
|
else
|
||||||
|
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Poll for the report line rather than a fixed sleep -- the run itself is
|
||||||
|
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
|
||||||
|
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
||||||
|
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
||||||
|
# a longer script than v1's swipe-loop-only run.
|
||||||
|
# The report's own first line, not the bare "iris bench report:" prefix:
|
||||||
|
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
|
||||||
|
# first", which the app emits at startup), so polling for the prefix
|
||||||
|
# returned instantly and the script printed a report that was never run.
|
||||||
|
REPORT_LINE="iris bench report: iris bench report"
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 260 ]; do
|
||||||
|
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||||
|
if [ -n "$LINE" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [ -z "$LINE" ]; then
|
||||||
|
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
||||||
|
# phases, four lines each) on top of the frames/bench sections v1 had.
|
||||||
|
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
|
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! The platform half of this app's logging: what
|
||||||
|
//! `crate::client::log_ring` needs that only Android can supply, which is
|
||||||
|
//! `android_logger` as the logger to forward to and nothing else.
|
||||||
|
|
||||||
|
use crate::client::log_ring::{self, LogRing};
|
||||||
|
|
||||||
|
/// Installs the ring in front of `android_logger`, so `logcat` still sees
|
||||||
|
/// exactly what it saw before and the ring sees it too.
|
||||||
|
///
|
||||||
|
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
|
||||||
|
/// itself; the message says which caller, since two initialisation paths
|
||||||
|
/// is a programmer error rather than something to recover from.
|
||||||
|
pub fn install(max_level: log::LevelFilter) {
|
||||||
|
let inner = android_logger::AndroidLogger::new(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(max_level)
|
||||||
|
.with_tag("iris-android-app"),
|
||||||
|
);
|
||||||
|
if log_ring::install_process_logger(
|
||||||
|
Box::new(inner),
|
||||||
|
max_level,
|
||||||
|
iris::diagnostics::trace_enabled,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// Not a panic: a logger already installed means logging works,
|
||||||
|
// just without the ring, and taking the app down over a
|
||||||
|
// diagnostic would be worse than the diagnostic being missing.
|
||||||
|
// The line goes through whatever logger did win.
|
||||||
|
log::warn!("iris app log: a logger was already installed, so there is no ring");
|
||||||
|
}
|
||||||
|
install_panic_hook();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ring() -> &'static LogRing {
|
||||||
|
log_ring::process_ring()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn diagnostics_line() -> String {
|
||||||
|
let where_to_read = match crate::android::devlog::authority() {
|
||||||
|
Some(authority) => format!("devlog provider: content://{authority}"),
|
||||||
|
// Not "off": Android creates a provider lazily, so this is what
|
||||||
|
// "nobody has asked for it yet" looks like, and it is a different
|
||||||
|
// thing from a build that does not have one.
|
||||||
|
None => "devlog provider: declared, not created yet".to_string(),
|
||||||
|
};
|
||||||
|
format!("{}\n{where_to_read}", ring().summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the panic hook leaves its report, under the app's private
|
||||||
|
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
||||||
|
/// start.
|
||||||
|
const CRASH_FILE: &str = "last-panic.txt";
|
||||||
|
|
||||||
|
/// How many of the dying run's own log lines the panic hook saves with
|
||||||
|
/// the panic, and [`set_crash_dir`] replays.
|
||||||
|
///
|
||||||
|
/// The panic's message and location say *what* broke; these say what the
|
||||||
|
/// app was doing on the way there, which is the half that is otherwise
|
||||||
|
/// unrecoverable -- the ring is memory only, so an abort takes every line
|
||||||
|
/// before the panic with it. Bounded rather than the whole ring because
|
||||||
|
/// this is written by a hook on a process that is about to die, and
|
||||||
|
/// because the replay pushes each line into the new run's ring, where an
|
||||||
|
/// unbounded paste would evict the run that is actually being watched.
|
||||||
|
const CRASH_CONTEXT_LINES: usize = 80;
|
||||||
|
|
||||||
|
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||||
|
|
||||||
|
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Copies aborting panics into the device-readable log ring.
|
||||||
|
fn install_panic_hook() {
|
||||||
|
let previous = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let where_at = match info.location() {
|
||||||
|
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
|
||||||
|
None => "an unknown location".to_string(),
|
||||||
|
};
|
||||||
|
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
|
||||||
|
let line = format!("iris panic at {where_at}: {message}");
|
||||||
|
log::error!("{line}");
|
||||||
|
if let Some(path) = CRASH_PATH.get() {
|
||||||
|
// The panic line first, then what the app was doing before
|
||||||
|
// it: one file, split again on that first newline by
|
||||||
|
// `set_crash_dir`.
|
||||||
|
let context = ring()
|
||||||
|
.try_tail_text(CRASH_CONTEXT_LINES)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
"(the log ring was locked as this run died; no context)".to_string()
|
||||||
|
});
|
||||||
|
// Best effort by design: a panic is already the failure, and
|
||||||
|
// failing to record it must not become a second one.
|
||||||
|
let _ = std::fs::write(path, format!("{line}\n{context}"));
|
||||||
|
}
|
||||||
|
previous(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the panic hook where to leave its report, and replays the report
|
||||||
|
/// a previous run left there into the ring before deleting it.
|
||||||
|
///
|
||||||
|
/// Called from **both** `MainActivity.nativeSetFilesDir` and
|
||||||
|
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
|
||||||
|
/// this process, since after a crash Dev Updater's query starts the
|
||||||
|
/// process for the provider alone and no activity ever runs. Safe to call
|
||||||
|
/// twice: the file is gone after the first, so the second finds nothing
|
||||||
|
/// and says nothing. The panic itself is replayed at `error` level and
|
||||||
|
/// says it is from the previous run, so a crash loop shows the reason it
|
||||||
|
/// is looping in the Runtime tab of the run that is still up.
|
||||||
|
pub fn set_crash_dir(dir: &std::path::Path) {
|
||||||
|
let path = dir.join(CRASH_FILE);
|
||||||
|
if let Ok(previous) = std::fs::read_to_string(&path) {
|
||||||
|
// Delete before replaying rather than after: a replay that itself
|
||||||
|
// panicked would otherwise leave the file to be replayed again on
|
||||||
|
// every start, and a crash loop nothing can get out of is worse
|
||||||
|
// than one report lost.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
replay_crash(&previous);
|
||||||
|
}
|
||||||
|
let _ = CRASH_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a previous run's report back in the ring: its context lines in
|
||||||
|
/// the order they happened, then the panic itself.
|
||||||
|
fn replay_crash(report: &str) {
|
||||||
|
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
|
||||||
|
for line in context.lines().filter(|line| !line.is_empty()) {
|
||||||
|
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
|
||||||
|
}
|
||||||
|
log::error!(
|
||||||
|
"iris app log: the previous run died -- {}",
|
||||||
|
panic_line.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
use crate::android::bench_jni::PlatformHandle;
|
||||||
|
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
||||||
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||||
|
const STREAM_SECONDS: u64 = 20;
|
||||||
|
|
||||||
|
const LEGACY_CYCLES: usize = 6;
|
||||||
|
|
||||||
|
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||||
|
const FLING_COUNT: usize = 8;
|
||||||
|
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||||
|
const FLING_PAUSE_MS: u64 = 300;
|
||||||
|
|
||||||
|
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
||||||
|
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
||||||
|
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
||||||
|
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
|
||||||
|
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
|
||||||
|
wrap across several lines while the transcript above is pushed upward by the growing \
|
||||||
|
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
|
||||||
|
happening now!!!";
|
||||||
|
const TYPE_CHAR_MS: u64 = 50;
|
||||||
|
|
||||||
|
const KEYBOARD_CYCLES: usize = 5;
|
||||||
|
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||||
|
|
||||||
|
const POLL_MS: u64 = 16;
|
||||||
|
|
||||||
|
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||||
|
|
||||||
|
pub struct BenchClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
report_display: WeakWidget<TextEdit>,
|
||||||
|
top_bar: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
|
last_report: Option<String>,
|
||||||
|
running: bool,
|
||||||
|
ime_state: Arc<Mutex<ImeState>>,
|
||||||
|
keyboard_was_visible: bool,
|
||||||
|
last_top_pad: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ImeState {
|
||||||
|
visible: bool,
|
||||||
|
shown_events: u32,
|
||||||
|
hidden_events: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasAndroidUiState for BenchClient {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_cpu_ms() -> Option<u64> {
|
||||||
|
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||||
|
// initialises on success; on failure it is never read.
|
||||||
|
unsafe {
|
||||||
|
let mut usage: libc::rusage = std::mem::zeroed();
|
||||||
|
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
||||||
|
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
||||||
|
Some(user_ms + sys_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peak_rss_kb() -> Option<u64> {
|
||||||
|
std::fs::read_to_string("/proc/self/status")
|
||||||
|
.ok()?
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmHWM:"))
|
||||||
|
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
||||||
|
.and_then(|n| n.trim().parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn battery_line(samples: &[i32]) -> String {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return " battery current: unavailable on this device".to_string();
|
||||||
|
}
|
||||||
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
|
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||||
|
unreachable!("samples is non-empty, checked above");
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||||
|
samples.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidAppState for BenchClient {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
let content = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading fixture...");
|
||||||
|
content(rsc).set(loading);
|
||||||
|
|
||||||
|
let report_display = wtext("")
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(true)
|
||||||
|
.size(14)
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.attr::<Selectable>(())
|
||||||
|
.label("Benchmark report")
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let top_bar = WidgetPtr::new().add(rsc);
|
||||||
|
let controls = bench_controls(rsc, 0.0);
|
||||||
|
top_bar(rsc).set(controls);
|
||||||
|
let tree = (
|
||||||
|
top_bar,
|
||||||
|
report_display
|
||||||
|
.pad(dp(8))
|
||||||
|
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||||
|
content.height(rest(1)),
|
||||||
|
)
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(rsc, tree);
|
||||||
|
|
||||||
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
|
log::info!(
|
||||||
|
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||||
|
bold={:?} italic={:?} mono={:?}, icons={:?}",
|
||||||
|
font.families_found,
|
||||||
|
font.default_family,
|
||||||
|
font.default_mono_family,
|
||||||
|
font.regular_resolved,
|
||||||
|
font.bold_resolved,
|
||||||
|
font.italic_resolved,
|
||||||
|
font.mono_resolved,
|
||||||
|
font.icon_family,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut client = Self {
|
||||||
|
ui_state,
|
||||||
|
content,
|
||||||
|
report_display,
|
||||||
|
top_bar,
|
||||||
|
screen: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
stream_tail: Vec::new(),
|
||||||
|
platform: None,
|
||||||
|
last_report: None,
|
||||||
|
running: false,
|
||||||
|
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
||||||
|
keyboard_was_visible: false,
|
||||||
|
last_top_pad: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
match crate::ui::fixture::build_screen(rsc) {
|
||||||
|
Ok((opened, tree)) => {
|
||||||
|
client.items = opened.items;
|
||||||
|
client.stream_tail = opened.stream_tail;
|
||||||
|
(client.content)(rsc).set(tree);
|
||||||
|
client.screen = Some(opened.screen);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
|
||||||
|
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_insets_changed(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut AndroidRsc<Self>,
|
||||||
|
insets: iris::android::WindowInsets,
|
||||||
|
) {
|
||||||
|
if insets.top != self.last_top_pad {
|
||||||
|
self.last_top_pad = insets.top;
|
||||||
|
let controls = bench_controls(rsc, insets.top);
|
||||||
|
(self.top_bar)(rsc).set(controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(screen) = &self.screen {
|
||||||
|
screen
|
||||||
|
.composer
|
||||||
|
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
||||||
|
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
||||||
|
// climbing while the keyboard slides in, so a frame or two of a
|
||||||
|
// real opening reads as "closed" when the boolean is inferred from
|
||||||
|
// it, and `shown_events`/`hidden_events` below count transitions.
|
||||||
|
let ime_visible = insets.ime_visible;
|
||||||
|
|
||||||
|
let mut ime = self.ime_state.lock().unwrap();
|
||||||
|
if ime_visible && !ime.visible {
|
||||||
|
ime.shown_events += 1;
|
||||||
|
}
|
||||||
|
if !ime_visible && ime.visible {
|
||||||
|
ime.hidden_events += 1;
|
||||||
|
}
|
||||||
|
ime.visible = ime_visible;
|
||||||
|
drop(ime);
|
||||||
|
|
||||||
|
if ime_visible && !self.keyboard_was_visible {
|
||||||
|
self.keyboard_was_visible = true;
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
state.capture_keyboard_diagnostics(rsc);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
});
|
||||||
|
} else if !ime_visible {
|
||||||
|
self.keyboard_was_visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
|
type Rsc = AndroidRsc<BenchClient>;
|
||||||
|
|
||||||
|
/// What a report says about the `iris::input`/`iris::frame` trace, from
|
||||||
|
/// the flag read at the start of what is being reported and again at the
|
||||||
|
/// end.
|
||||||
|
///
|
||||||
|
/// Three answers rather than two. Those lines are default-off and the
|
||||||
|
/// switch that turns them on is on screen while a benchmark runs, so
|
||||||
|
/// "somebody moved it half way through" is a state that actually happens
|
||||||
|
/// -- and reported as either "on" or "off" it is a confident sentence
|
||||||
|
/// about a log that only covers part of the run. The "on" wording also
|
||||||
|
/// says what it costs, because a traced run fills the ring in seconds and
|
||||||
|
/// a reader looking at a log with nothing else in it should know why.
|
||||||
|
fn trace_line(at_start: bool, at_end: bool) -> String {
|
||||||
|
match (at_start, at_end) {
|
||||||
|
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
|
||||||
|
the app log, and a traced run fills the ring in seconds)"
|
||||||
|
.to_string(),
|
||||||
|
(false, false) => "input/frame trace: off".to_string(),
|
||||||
|
_ => "input/frame trace: switched during this run, so those lines cover only part \
|
||||||
|
of it"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
||||||
|
/// why it needs one at all. A dark neutral rather than pure black
|
||||||
|
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||||
|
/// instead of a hole in the background the buttons happen to float in.
|
||||||
|
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
||||||
|
|
||||||
|
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||||
|
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||||
|
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||||
|
/// naming that builder's type at each call site is more machinery than a
|
||||||
|
/// top-of-screen padding number is worth.
|
||||||
|
const HEADER_TEXT: f32 = 18.0;
|
||||||
|
|
||||||
|
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||||
|
|
||||||
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||||
|
let run_rect = rect(Srgba8::rgb(40, 70, 40))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.start_benchmark(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Run benchmark");
|
||||||
|
let run = (
|
||||||
|
run_rect,
|
||||||
|
wtext("Run benchmark")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.copy_report(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Copy report");
|
||||||
|
let copy = (
|
||||||
|
copy_rect,
|
||||||
|
wtext("Copy report")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.show_diagnostics(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Diagnostics");
|
||||||
|
let diagnostics = (
|
||||||
|
diag_rect,
|
||||||
|
wtext("Diagnostics")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// A switch rather than a button, so its own appearance says which
|
||||||
|
// state it is in: the two `iris::input`/`iris::frame` targets are
|
||||||
|
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
|
||||||
|
// session fills the 2000-line ring in seconds, so "is it on right
|
||||||
|
// now" is the question somebody has while looking at a log that is
|
||||||
|
// either full of trace or has none.
|
||||||
|
let tracing = iris::diagnostics::trace_enabled();
|
||||||
|
let trace_rect = rect(if tracing {
|
||||||
|
Srgba8::rgb(90, 70, 30)
|
||||||
|
} else {
|
||||||
|
Srgba8::rgb(50, 50, 60)
|
||||||
|
})
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.toggle_trace(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Trace input and frames");
|
||||||
|
let trace = (
|
||||||
|
trace_rect,
|
||||||
|
wtext(if tracing { "Trace on" } else { "Trace off" })
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
||||||
|
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
||||||
|
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
||||||
|
|
||||||
|
(rect(HEADER_SURFACE), buttons)
|
||||||
|
.stack()
|
||||||
|
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||||
|
.pad(Padding::top(top_pad))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BenchClient {
|
||||||
|
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.content)(rsc).set(widget);
|
||||||
|
self.screen = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
|
let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items));
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
|
let report = self.diagnostics_text(rsc);
|
||||||
|
self.report_display.edit(rsc).set(&report);
|
||||||
|
self.last_report = Some(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
||||||
|
/// switch that says so, and shows the pane that now reports it.
|
||||||
|
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
||||||
|
let on = !iris::diagnostics::trace_enabled();
|
||||||
|
iris::diagnostics::set_trace(on);
|
||||||
|
log::info!(
|
||||||
|
"iris diagnostics: input/frame trace {}",
|
||||||
|
if on { "on" } else { "off" }
|
||||||
|
);
|
||||||
|
let controls = bench_controls(rsc, self.last_top_pad);
|
||||||
|
(self.top_bar)(rsc).set(controls);
|
||||||
|
self.show_diagnostics(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diagnostics report as text, with no side effect on what is on
|
||||||
|
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||||
|
/// the keyboard-open capture (which only logs it), so the two can
|
||||||
|
/// never drift into reporting different things.
|
||||||
|
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
||||||
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
|
let frame_report = match self.android_state().frame_report.report() {
|
||||||
|
Some(stats) => format!("{stats}"),
|
||||||
|
None => "no frames recorded yet".to_string(),
|
||||||
|
};
|
||||||
|
let renderer = match &self.android_state().renderer {
|
||||||
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||||
|
};
|
||||||
|
// Insets must be visible without adb so a missing callback can be
|
||||||
|
// distinguished from a callback reporting zero IME height.
|
||||||
|
format!(
|
||||||
|
"{renderer}\n{}\n{}\n{}\n{}",
|
||||||
|
trace_line(
|
||||||
|
iris::diagnostics::trace_enabled(),
|
||||||
|
iris::diagnostics::trace_enabled()
|
||||||
|
),
|
||||||
|
self.android_state().insets_report(),
|
||||||
|
// Which server this build talks to, and what to do when the
|
||||||
|
// answer is "none" -- the bench itself opens a checked-in
|
||||||
|
// fixture and needs no server, so this pane is the only place
|
||||||
|
// an enrolment can be seen to have taken.
|
||||||
|
crate::android::enrollment::status_line(),
|
||||||
|
crate::android::app_log::diagnostics_line()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
|
let report = self.diagnostics_text(rsc);
|
||||||
|
log::info!("iris keyboard diagnostics:\n{report}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_report(&mut self, rsc: &mut Rsc) {
|
||||||
|
let Some(platform) = &self.platform else {
|
||||||
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let report = match self.last_report.clone() {
|
||||||
|
Some(report) => report,
|
||||||
|
None => format!(
|
||||||
|
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
|
||||||
|
self.diagnostics_text(rsc)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if platform.copy_to_clipboard("iris bench report", &report) {
|
||||||
|
log::info!("iris bench report: copied to clipboard");
|
||||||
|
} else {
|
||||||
|
log::info!("iris bench report: clipboard copy failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||||
|
if self.running {
|
||||||
|
log::info!("iris bench report: already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.running = true;
|
||||||
|
self.android_state_mut().frame_report.reset();
|
||||||
|
self.report_display.edit(rsc).set("Running benchmark...");
|
||||||
|
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let platform = self.platform.clone();
|
||||||
|
let stream_tail = self.stream_tail.clone();
|
||||||
|
let ime_state = self.ime_state.clone();
|
||||||
|
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
|
||||||
|
let cpu_start = process_cpu_ms();
|
||||||
|
// Read at the start as well as the end, because the switch is on
|
||||||
|
// screen while a run is going: a report that only asked afterwards
|
||||||
|
// would say "on" about a run whose first half has no trace in it
|
||||||
|
// -- the inferred answer presented as the measured one.
|
||||||
|
let trace_at_start = iris::diagnostics::trace_enabled();
|
||||||
|
let run_started_at = Instant::now();
|
||||||
|
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
// The battery sampler runs for the whole run, once a second,
|
||||||
|
// the same cadence `BatterySampler` uses on the Compose side
|
||||||
|
// -- via its own JNI-attached thread, not `ctx.update`, since
|
||||||
|
// a sample needs no widget-tree access.
|
||||||
|
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||||
|
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
|
||||||
|
let sampler = platform.clone().map(|platform| {
|
||||||
|
let done = sampler_done.clone();
|
||||||
|
let samples = samples.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while !done.load(Ordering::Relaxed) {
|
||||||
|
if let Some(value) = platform.battery_current_ua() {
|
||||||
|
samples.lock().unwrap().push(value);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let travel = run_fling_phase(&mut ctx, &redraw).await;
|
||||||
|
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
|
||||||
|
run_type_phase(&mut ctx, &redraw, &platform).await;
|
||||||
|
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
|
||||||
|
|
||||||
|
sampler_done.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(sampler) = sampler {
|
||||||
|
let _ = sampler.await;
|
||||||
|
}
|
||||||
|
let battery = battery_line(&samples.lock().unwrap());
|
||||||
|
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||||
|
(Some(start), Some(end)) => {
|
||||||
|
format!(
|
||||||
|
" process CPU time over this run: {}ms",
|
||||||
|
end.saturating_sub(start)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => " process CPU time over this run: unavailable".to_string(),
|
||||||
|
};
|
||||||
|
let rss_line = match peak_rss_kb() {
|
||||||
|
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||||
|
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||||
|
};
|
||||||
|
let total_seconds = run_started_at.elapsed().as_secs_f64();
|
||||||
|
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
state.running = false;
|
||||||
|
let now = Instant::now();
|
||||||
|
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
|
||||||
|
let refresh_hz = match (drawn_hz, platform_hz) {
|
||||||
|
(Some(d), Some(p)) => d.max(p),
|
||||||
|
(Some(d), None) => d,
|
||||||
|
(None, Some(p)) => p,
|
||||||
|
(None, None) => 60.0,
|
||||||
|
};
|
||||||
|
let hz_line = match (drawn_hz, platform_hz) {
|
||||||
|
(Some(d), Some(p)) if d > p + 5.0 => format!(
|
||||||
|
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
|
||||||
|
),
|
||||||
|
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
|
||||||
|
(Some(d), None) => {
|
||||||
|
format!(" (sustained {d:.0}fps; the display would not say)")
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
" (as the display reports it; too few frames to measure)".to_string()
|
||||||
|
}
|
||||||
|
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
|
||||||
|
};
|
||||||
|
let phase_lines: String = state
|
||||||
|
.android_state()
|
||||||
|
.frame_report
|
||||||
|
.phase_stats(now, refresh_hz)
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{p}\n"))
|
||||||
|
.collect();
|
||||||
|
let per_phase = if phase_lines.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("per phase:\n{phase_lines}\n")
|
||||||
|
};
|
||||||
|
let frames_block = match state.android_state().frame_report.report() {
|
||||||
|
Some(stats) => {
|
||||||
|
let (late, late_pct) =
|
||||||
|
state.android_state().frame_report.late_at_hz(refresh_hz);
|
||||||
|
format!(
|
||||||
|
"frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
|
||||||
|
budget)\n \
|
||||||
|
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
||||||
|
p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
|
||||||
|
{:.1}ms submit_p50 {:.1}ms",
|
||||||
|
stats.total_frames,
|
||||||
|
total_seconds,
|
||||||
|
refresh_hz,
|
||||||
|
1000.0 / refresh_hz as f64,
|
||||||
|
stats.p50.as_secs_f64() * 1000.0,
|
||||||
|
stats.p90.as_secs_f64() * 1000.0,
|
||||||
|
stats.p99.as_secs_f64() * 1000.0,
|
||||||
|
stats.worst.as_secs_f64() * 1000.0,
|
||||||
|
stats.cpu_p50.as_secs_f64() * 1000.0,
|
||||||
|
stats.acquire_p50.as_secs_f64() * 1000.0,
|
||||||
|
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => "frames:\n no frames recorded".to_string(),
|
||||||
|
};
|
||||||
|
let scroll_line = format!(
|
||||||
|
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
|
||||||
|
{sent}/{total} fixture events",
|
||||||
|
LEGACY_CYCLES * 4
|
||||||
|
);
|
||||||
|
let fling_line = format!(
|
||||||
|
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
|
||||||
|
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
|
||||||
|
);
|
||||||
|
let type_line = format!(
|
||||||
|
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||||
|
TYPE_TEXT.chars().count()
|
||||||
|
);
|
||||||
|
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||||
|
let report = format!(
|
||||||
|
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
|
||||||
|
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
|
||||||
|
{rss_line}\n{battery}"
|
||||||
|
);
|
||||||
|
log::info!("iris bench report: {report}");
|
||||||
|
state.report_display.edit(rsc).set(&report);
|
||||||
|
state.last_report = Some(report);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
|
||||||
|
/// same `ctx.update` every other mutation here goes through) and returns
|
||||||
|
/// its result to the caller's async task -- `ctx.update` alone has no way
|
||||||
|
/// to hand a value back, since the closure only actually runs once the
|
||||||
|
/// next frame callback drains `IrisViewPeer`'s task channel
|
||||||
|
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
|
||||||
|
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
|
||||||
|
/// nothing drains it until something schedules the frame callback that
|
||||||
|
/// calls `drain_tasks`, and a caller relying on some *earlier*,
|
||||||
|
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
|
||||||
|
/// deadlocks the moment that earlier callback has already fired and
|
||||||
|
/// drained everything queued before this call existed. Cost a real hang
|
||||||
|
/// in this file's first version of the fling phase: every loop iteration
|
||||||
|
/// after the first sat forever with nothing scheduled to drain it.
|
||||||
|
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
|
||||||
|
/// slow device's frame callback can lag further than that.
|
||||||
|
async fn read_from_state<T, F>(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
f: F,
|
||||||
|
) -> T
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
let _ = tx.send(f(state, rsc));
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
loop {
|
||||||
|
if let Ok(value) = rx.try_recv() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_fling_phase(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
) -> String {
|
||||||
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||||
|
state.android_state_mut().frame_report.mark_phase("fling");
|
||||||
|
});
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).jump_to_end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||||
|
// `anchor = None` into a real slot before `start` is read.
|
||||||
|
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
|
||||||
|
let start = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
|
for _ in 0..FLING_COUNT {
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||||
|
animate_scroll(screen.list, rsc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
wait_for_fling_settle(ctx, redraw).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||||
|
}
|
||||||
|
let outward = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
|
for _ in 0..FLING_COUNT {
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||||
|
animate_scroll(screen.list, rsc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
wait_for_fling_settle(ctx, redraw).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||||
|
}
|
||||||
|
let end = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
|
format!("start={start} outward={outward} end={end} ticked=frame-loop")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_anchor_position(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
) -> String {
|
||||||
|
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||||
|
Some(screen) => (screen.list)(rsc).anchor_position_display(),
|
||||||
|
None => "idx=none".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||||
|
let id = scroll.id();
|
||||||
|
rsc.ui_mut().animate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_fling_settle(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
) {
|
||||||
|
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
|
||||||
|
let started = Instant::now();
|
||||||
|
while started.elapsed() < cap {
|
||||||
|
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||||
|
Some(screen) => (screen.list)(rsc).is_scrolling(),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if !still_scrolling {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_stream_phase(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||||
|
state.android_state_mut().frame_report.mark_phase("stream");
|
||||||
|
});
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).jump_to_end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
|
||||||
|
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||||
|
let mut sent = 0usize;
|
||||||
|
for event in stream_tail.into_iter().take(total) {
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
let old_items = state.items.clone();
|
||||||
|
state.items = fold_event(&state.items, &event);
|
||||||
|
match &state.screen {
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||||
|
None => state.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
sent += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
(sent, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_type_phase(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
platform: &Option<Arc<PlatformHandle>>,
|
||||||
|
) {
|
||||||
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||||
|
state.android_state_mut().frame_report.mark_phase("type");
|
||||||
|
});
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).jump_to_end();
|
||||||
|
state.set_focus(Some(screen.composer.field));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
if let Some(p) = platform {
|
||||||
|
p.show_ime();
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
let mut typed = String::new();
|
||||||
|
for ch in TYPE_TEXT.chars() {
|
||||||
|
typed.push(ch);
|
||||||
|
let text = typed.clone();
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
while !typed.is_empty() {
|
||||||
|
typed.pop();
|
||||||
|
let text = typed.clone();
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_keyboard_phase(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
platform: &Option<Arc<PlatformHandle>>,
|
||||||
|
ime_state: &Arc<Mutex<ImeState>>,
|
||||||
|
) -> String {
|
||||||
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||||
|
state
|
||||||
|
.android_state_mut()
|
||||||
|
.frame_report
|
||||||
|
.mark_phase("keyboard");
|
||||||
|
});
|
||||||
|
let mut shown = 0;
|
||||||
|
let mut hidden = 0;
|
||||||
|
for _ in 0..KEYBOARD_CYCLES {
|
||||||
|
let before_shown = ime_state.lock().unwrap().shown_events;
|
||||||
|
if let Some(p) = platform {
|
||||||
|
p.show_ime();
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||||
|
if ime_state.lock().unwrap().shown_events > before_shown {
|
||||||
|
shown += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let before_hidden = ime_state.lock().unwrap().hidden_events;
|
||||||
|
if let Some(p) = platform {
|
||||||
|
p.hide_ime();
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||||
|
if ime_state.lock().unwrap().hidden_events > before_hidden {
|
||||||
|
hidden += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shown == 0 {
|
||||||
|
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
|
||||||
|
(confirmed via on_insets_changed)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::TYPE_TEXT;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_text_is_exactly_600_characters() {
|
||||||
|
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
use android_view::jni::{
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
objects::{GlobalRef, JObject, JValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
|
||||||
|
/// as a constant anywhere reachable without the Android SDK jar, so named
|
||||||
|
/// here with its source rather than left as a bare `2`.
|
||||||
|
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
|
||||||
|
|
||||||
|
pub struct PlatformHandle {
|
||||||
|
vm: JavaVM,
|
||||||
|
view: GlobalRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlatformHandle {
|
||||||
|
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
|
||||||
|
Self { vm, view }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
|
||||||
|
env.call_method(
|
||||||
|
self.view.as_obj(),
|
||||||
|
"getContext",
|
||||||
|
"()Landroid/content/Context;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_service<'e>(
|
||||||
|
&self,
|
||||||
|
env: &mut JNIEnv<'e>,
|
||||||
|
context: &JObject<'e>,
|
||||||
|
name: &str,
|
||||||
|
) -> Option<JObject<'e>> {
|
||||||
|
let jname = env.new_string(name).ok()?;
|
||||||
|
env.call_method(
|
||||||
|
context,
|
||||||
|
"getSystemService",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
|
&[JValue::Object(jname.as_ref())],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let battery_manager = self.system_service(env, &context, "batterymanager")?;
|
||||||
|
let value = env
|
||||||
|
.call_method(
|
||||||
|
&battery_manager,
|
||||||
|
"getIntProperty",
|
||||||
|
"(I)I",
|
||||||
|
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.i()
|
||||||
|
.ok()?;
|
||||||
|
if value == 0 || value == i32::MIN {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `text` on the system clipboard through `ClipboardManager` --
|
||||||
|
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
|
||||||
|
/// `setPrimaryClip`) succeeded.
|
||||||
|
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
|
||||||
|
self.try_copy_to_clipboard(label, text).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let clipboard = self.system_service(env, &context, "clipboard")?;
|
||||||
|
let jlabel = env.new_string(label).ok()?;
|
||||||
|
let jtext = env.new_string(text).ok()?;
|
||||||
|
let clip = env
|
||||||
|
.call_static_method(
|
||||||
|
"android/content/ClipData",
|
||||||
|
"newPlainText",
|
||||||
|
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||||
|
&[
|
||||||
|
JValue::Object(jlabel.as_ref()),
|
||||||
|
JValue::Object(jtext.as_ref()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()?;
|
||||||
|
env.call_method(
|
||||||
|
&clipboard,
|
||||||
|
"setPrimaryClip",
|
||||||
|
"(Landroid/content/ClipData;)V",
|
||||||
|
&[JValue::Object(&clip)],
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let display = env
|
||||||
|
.call_method(
|
||||||
|
self.view.as_obj(),
|
||||||
|
"getDisplay",
|
||||||
|
"()Landroid/view/Display;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()?;
|
||||||
|
if display.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rate = env
|
||||||
|
.call_method(&display, "getRefreshRate", "()F", &[])
|
||||||
|
.ok()?
|
||||||
|
.f()
|
||||||
|
.ok()?;
|
||||||
|
if rate > 0.0 { Some(rate) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_ime(&self) -> bool {
|
||||||
|
self.try_toggle_ime(true).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hide_ime(&self) -> bool {
|
||||||
|
self.try_toggle_ime(false).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let imm = self.system_service(env, &context, "input_method")?;
|
||||||
|
if show {
|
||||||
|
env.call_method(
|
||||||
|
&imm,
|
||||||
|
"showSoftInput",
|
||||||
|
"(Landroid/view/View;I)Z",
|
||||||
|
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.z()
|
||||||
|
.ok()
|
||||||
|
} else {
|
||||||
|
let token = env
|
||||||
|
.call_method(
|
||||||
|
self.view.as_obj(),
|
||||||
|
"getWindowToken",
|
||||||
|
"()Landroid/os/IBinder;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()?;
|
||||||
|
env.call_method(
|
||||||
|
&imm,
|
||||||
|
"hideSoftInputFromWindow",
|
||||||
|
"(Landroid/os/IBinder;I)Z",
|
||||||
|
&[JValue::Object(&token), JValue::Int(0)],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.z()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
||||||
|
//! for a `ContentProvider` that Dev Updater queries.
|
||||||
|
|
||||||
|
use android_view::jni::JNIEnv;
|
||||||
|
use android_view::jni::objects::{JClass, JObject, JString};
|
||||||
|
use android_view::jni::sys::{jlong, jobjectArray};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Gated with its one reader: the tabs demo links no `client-core` and so
|
||||||
|
/// has no ring to lay out, and an ungated constant is a warning in that
|
||||||
|
/// build (`iris-android-app` without `transcript-screen`).
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
const FIELDS_PER_LINE: usize = 5;
|
||||||
|
|
||||||
|
/// The authority the provider registered itself under, once it has been
|
||||||
|
/// created. `None` until then, which is a state worth being able to say:
|
||||||
|
/// a provider Android never instantiated and one that is answering look
|
||||||
|
/// the same from inside this process otherwise.
|
||||||
|
static AUTHORITY: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn authority() -> Option<&'static str> {
|
||||||
|
AUTHORITY.get().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory is taken here as well as in
|
||||||
|
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
|
||||||
|
/// only thing running**: once the app has died, Dev Updater's query
|
||||||
|
/// starts the process for the provider alone, so no activity ever runs
|
||||||
|
/// and the panic hook's file would never be replayed into the ring. That
|
||||||
|
/// is precisely the run whose log is being asked for. Whichever of the
|
||||||
|
/// two arrives first does the replay; `set_crash_dir` deletes the file,
|
||||||
|
/// so the second finds nothing and says nothing.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
authority: JString,
|
||||||
|
files_dir: JString,
|
||||||
|
) {
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
||||||
|
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
let _ = &files_dir;
|
||||||
|
let Some(authority) = string_arg(&mut env, &authority) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
log::info!("iris devlog: serving this app's log at content://{authority}");
|
||||||
|
let _ = AUTHORITY.set(authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
env.get_string(value).ok().map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
||||||
|
/// what tells a reader holding a cursor that this process **restarted**:
|
||||||
|
/// the ring is in memory, so a new process starts again at zero and a
|
||||||
|
/// stale cursor would otherwise skip everything silently.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jobjectArray {
|
||||||
|
string_array(&mut env, &status_fields())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
|
||||||
|
/// is, and one definition of the cursor is what keeps the app's own
|
||||||
|
/// uploaded report and this provider describing the same lines.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
since: jlong,
|
||||||
|
) -> jobjectArray {
|
||||||
|
// A negative cursor is a caller asking for everything, not an error to
|
||||||
|
// take the app down over: the provider is a diagnostic.
|
||||||
|
string_array(&mut env, &line_fields(since.max(0) as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
fn status_fields() -> Vec<String> {
|
||||||
|
let ring = crate::client::log_ring::process_ring();
|
||||||
|
vec![
|
||||||
|
ring.len().to_string(),
|
||||||
|
ring.dropped().to_string(),
|
||||||
|
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tabs demo links no `client-core` and keeps no ring, so it holds
|
||||||
|
/// nothing and has never dropped anything -- which is the truth, not a
|
||||||
|
/// stand-in. The natives are still exported there, because a `native`
|
||||||
|
/// method Java declares and the library does not is an
|
||||||
|
/// `UnsatisfiedLinkError` the moment the class loads.
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
fn status_fields() -> Vec<String> {
|
||||||
|
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
fn line_fields(since: u64) -> Vec<String> {
|
||||||
|
let (lines, _next) = crate::client::log_ring::process_ring().since(since);
|
||||||
|
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
|
||||||
|
for line in lines {
|
||||||
|
fields.push(line.seq.to_string());
|
||||||
|
fields.push(line.at_ms.to_string());
|
||||||
|
fields.push(line.level.to_string());
|
||||||
|
fields.push(line.target);
|
||||||
|
fields.push(line.message);
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
fn line_fields(_since: u64) -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
|
||||||
|
/// reads it as "the provider could not answer" and returns no cursor,
|
||||||
|
/// which Dev Updater already draws as a distinct state. Taking the app
|
||||||
|
/// down to report that its diagnostic is unavailable would be worse than
|
||||||
|
/// the diagnostic being unavailable.
|
||||||
|
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
|
||||||
|
let null = std::ptr::null_mut();
|
||||||
|
let Ok(class) = env.find_class("java/lang/String") else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
for (index, field) in fields.iter().enumerate() {
|
||||||
|
let Ok(value) = env.new_string(field) else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
if env
|
||||||
|
.set_object_array_element(&array, index as i32, value)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array.into_raw()
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#[cfg(not(feature = "bench"))]
|
||||||
|
use crate::client::api::UreqTransport;
|
||||||
|
use crate::client::config::{EnrolledServer, EnrollmentStore};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn set_files_dir(dir: PathBuf) {
|
||||||
|
if let Err(existing) = FILES_DIR.set(dir.clone()) {
|
||||||
|
assert_eq!(
|
||||||
|
existing, dir,
|
||||||
|
"the app's files directory was set twice with different paths"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store() -> Option<EnrollmentStore> {
|
||||||
|
FILES_DIR.get().map(EnrollmentStore::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Status {
|
||||||
|
Enrolled(EnrolledServer),
|
||||||
|
NotEnrolled,
|
||||||
|
/// The question could not be answered -- the activity never handed a
|
||||||
|
/// files directory over, or the file is there and unreadable. Kept
|
||||||
|
/// apart from `NotEnrolled` because the two want different actions
|
||||||
|
/// from whoever is looking.
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> Status {
|
||||||
|
let Some(store) = store() else {
|
||||||
|
return Status::Unknown("the activity never handed over a files directory".to_string());
|
||||||
|
};
|
||||||
|
match store.load() {
|
||||||
|
Ok(Some(server)) => Status::Enrolled(server),
|
||||||
|
Ok(None) => Status::NotEnrolled,
|
||||||
|
Err(error) => Status::Unknown(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for the diagnostics pane. The three states read differently on
|
||||||
|
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||||
|
/// must not be mistaken for it.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn status_line() -> String {
|
||||||
|
match status() {
|
||||||
|
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
|
||||||
|
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
|
||||||
|
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||||
|
let server = EnrolledServer::parse_link(uri)?;
|
||||||
|
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||||
|
store
|
||||||
|
.save(&server)
|
||||||
|
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gated to the same builds as `transcript_client`, its only caller: the
|
||||||
|
/// bench build opens a checked-in fixture and reaches no server, so
|
||||||
|
/// compiling this into it would be a warning about dead code that is
|
||||||
|
/// dead on purpose.
|
||||||
|
///
|
||||||
|
/// Every failure here is a sentence a screen can show, because there is
|
||||||
|
/// nowhere else for it to go: this app has no `logcat` on the phone it is
|
||||||
|
/// built for.
|
||||||
|
#[cfg(not(feature = "bench"))]
|
||||||
|
pub fn transport() -> Result<UreqTransport, String> {
|
||||||
|
let server = match status() {
|
||||||
|
Status::Enrolled(server) => server,
|
||||||
|
Status::NotEnrolled => {
|
||||||
|
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
|
||||||
|
}
|
||||||
|
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
|
||||||
|
};
|
||||||
|
let ca_pem = server.ca_pem.as_ref().ok_or(
|
||||||
|
"The enrollment link carried no CA, so there is nothing to pin. \
|
||||||
|
Enrol again with a link minted by this server.",
|
||||||
|
)?;
|
||||||
|
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
|
||||||
|
.map_err(|error| error.message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use android_view::{
|
||||||
|
Context, View,
|
||||||
|
jni::{
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
objects::{JClass, JString},
|
||||||
|
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||||
|
},
|
||||||
|
register_view_class,
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
use iris::prelude::*;
|
||||||
|
use log::LevelFilter;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
/// The app's own log ring and its upload -- only where `client-core` is
|
||||||
|
/// linked, which is every build that has a server to send to. The plain
|
||||||
|
/// tabs demo keeps `android_logger` alone, as it always had.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
mod app_log;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_client;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_jni;
|
||||||
|
mod devlog;
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
mod enrollment;
|
||||||
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
|
mod transcript_client;
|
||||||
|
|
||||||
|
/// The app's `View` subclass, matching the Java side's package --
|
||||||
|
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
|
||||||
|
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
impl HasAndroidUiState for Client {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
impl AndroidAppState for Client {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
// `widgets.info` is the winit example's frame-debug readout, kept
|
||||||
|
// current from `DefaultAppState::window_event` -- android-view has
|
||||||
|
// no per-frame hook to drive the equivalent from here yet, so it
|
||||||
|
// is left at its built "" text rather than wired to nothing.
|
||||||
|
let _ = tabs_ui::build(rsc, &mut ui_state);
|
||||||
|
Self { ui_state }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
type ActiveClient = Client;
|
||||||
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
|
type ActiveClient = transcript_client::TranscriptClient;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
type ActiveClient = bench_client::BenchClient;
|
||||||
|
|
||||||
|
extern "system" fn new_view_peer<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
view: View<'local>,
|
||||||
|
context: Context<'local>,
|
||||||
|
) -> jlong {
|
||||||
|
iris::android::new_peer::<ActiveClient>(env, view, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// Interacting with JNI at load time is always unsafe at some level --
|
||||||
|
/// mirrors android-view's own demo, which carries the same comment.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
||||||
|
// The ring in front of `android_logger` where there is one (see
|
||||||
|
// `app_log`), and `android_logger` alone otherwise. Both install the
|
||||||
|
// same tag and level, so `logcat` cannot tell the two builds apart --
|
||||||
|
// the ring only adds a second reader.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
app_log::install(LevelFilter::Debug);
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
android_logger::init_once(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(LevelFilter::Debug)
|
||||||
|
.with_tag("iris-android-app"),
|
||||||
|
);
|
||||||
|
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
|
||||||
|
let mut env = vm.get_env().unwrap();
|
||||||
|
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
|
||||||
|
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||||
|
JNI_VERSION_1_6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
|
||||||
|
/// over before the view exists because that is where the enrollment is
|
||||||
|
/// read from and written to (`enrollment`'s module doc).
|
||||||
|
///
|
||||||
|
/// Exported by name rather than registered through `RegisterNatives`: the
|
||||||
|
/// view's methods are registered because `android-view` owns that class
|
||||||
|
/// and hands out one function pointer, whereas these two are this app's
|
||||||
|
/// own activity and the mangled name is the whole of what is needed.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
dir: JString,
|
||||||
|
) {
|
||||||
|
let Some(dir) = jstring(&mut env, dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
{
|
||||||
|
app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||||
|
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
|
||||||
|
}
|
||||||
|
log::debug!("iris app: files directory is {dir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
uri: JString,
|
||||||
|
) {
|
||||||
|
let Some(uri) = jstring(&mut env, uri) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
match enrollment::apply_link(&uri) {
|
||||||
|
// Never the token: `wg-app-link`'s enroll module forbids logging
|
||||||
|
// it, and this line would otherwise be the one place it leaked.
|
||||||
|
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
|
||||||
|
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
log::warn!("iris app: the activity passed a null string across JNI");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match env.get_string(&value) {
|
||||||
|
Ok(value) => Some(value.into()),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("iris app: couldn't read a string from the activity -- {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
use crate::client::api::{ApiClient, UreqTransport};
|
||||||
|
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||||
|
use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
pub struct TranscriptClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
/// The screen's own content -- everything under the fixed
|
||||||
|
/// [`frame_report_controls`] bar, which is built once (`new`, below)
|
||||||
|
/// and never touched by `show_message`/`rebuild_transcript`'s own
|
||||||
|
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
||||||
|
/// by rebuilding the session list beside it.
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
generation: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasAndroidUiState for TranscriptClient {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_transport() -> Result<UreqTransport, String> {
|
||||||
|
crate::android::enrollment::transport()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||||
|
type Rsc = AndroidRsc<TranscriptClient>;
|
||||||
|
let report_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
||||||
|
.state
|
||||||
|
.android_state()
|
||||||
|
.frame_report
|
||||||
|
.report()
|
||||||
|
{
|
||||||
|
Some(stats) => log::info!("iris frame report: {stats}"),
|
||||||
|
None => log::info!(
|
||||||
|
"iris frame report: no frames recorded -- scroll first, then press this"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Frame report");
|
||||||
|
let report = (
|
||||||
|
report_rect,
|
||||||
|
wtext("Frame report").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
|
ctx.state.android_state_mut().frame_report.reset();
|
||||||
|
log::info!("iris frame report: reset");
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Reset frame report");
|
||||||
|
let reset = (
|
||||||
|
reset_rect,
|
||||||
|
wtext("Reset").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidAppState for TranscriptClient {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
let content = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading sessions...");
|
||||||
|
content(rsc).set(loading);
|
||||||
|
|
||||||
|
let tree = (frame_report_controls(rsc), content.height(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(rsc, tree);
|
||||||
|
|
||||||
|
let mut client = Self {
|
||||||
|
ui_state,
|
||||||
|
content,
|
||||||
|
screen: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
session_id: None,
|
||||||
|
generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
};
|
||||||
|
client.spawn_fetch_sessions(rsc);
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
// No screen stack of its own -- same "let the activity finish"
|
||||||
|
// answer `iris-android-app`'s tabs `Client` already gives.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptClient {
|
||||||
|
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.content)(rsc).set(widget);
|
||||||
|
self.screen = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let my_generation = self.generation.load(Ordering::SeqCst);
|
||||||
|
let generation = self.generation.clone();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
let outcome = match build_transport() {
|
||||||
|
Ok(transport) => ApiClient::new(transport)
|
||||||
|
.fetch_sessions()
|
||||||
|
.map_err(|e| e.to_string()),
|
||||||
|
Err(e) => Err(format!("couldn't set up TLS: {e}")),
|
||||||
|
};
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok(sessions) => match sessions.into_iter().next() {
|
||||||
|
Some(session) => state.select_session(rsc, session.id),
|
||||||
|
None => state.show_message(rsc, "No sessions on the sandbox server."),
|
||||||
|
},
|
||||||
|
Err(message) => {
|
||||||
|
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||||
|
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
self.items.clear();
|
||||||
|
self.session_id = Some(session_id.clone());
|
||||||
|
self.show_message(rsc, "Loading transcript...");
|
||||||
|
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let live_generation = self.generation.clone();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
let transports =
|
||||||
|
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
|
||||||
|
let (rest, stream_transport) = match transports {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
let message = format!("couldn't set up TLS: {e}");
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if live_generation.load(Ordering::SeqCst) == my_generation {
|
||||||
|
state.show_message(rsc, &message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let api = ApiClient::new(rest);
|
||||||
|
|
||||||
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
let after = page
|
||||||
|
.as_ref()
|
||||||
|
.ok()
|
||||||
|
.and_then(|values| values.last())
|
||||||
|
.and_then(crate::client::transcript_fold::raw_seq)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let result = page.and_then(|values| fold_page(&values));
|
||||||
|
|
||||||
|
{
|
||||||
|
let live_generation = live_generation.clone();
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(items) => {
|
||||||
|
state.items = items;
|
||||||
|
state.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
redraw.request_redraw();
|
||||||
|
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
follow_session_events(
|
||||||
|
&stream_transport,
|
||||||
|
&session_id,
|
||||||
|
after,
|
||||||
|
move |item| match item {
|
||||||
|
StreamItem::Open | StreamItem::Reset => {
|
||||||
|
live_generation.load(Ordering::SeqCst) == my_generation
|
||||||
|
}
|
||||||
|
StreamItem::Event { event, .. } => {
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let live_generation = live_generation.clone();
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.apply_event(rsc, &event);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||||
|
let in_progress = self
|
||||||
|
.screen
|
||||||
|
.as_ref()
|
||||||
|
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
|
let rows = group_tool_runs(&self.items);
|
||||||
|
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
|
if let Some(text) = in_progress {
|
||||||
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
|
}
|
||||||
|
if let Some(session_id) = self.session_id.clone() {
|
||||||
|
let field = screen.composer.field;
|
||||||
|
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||||
|
let text = field.edit(rsc).take();
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
ctx.state.send_message(session_id.clone(), text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||||
|
let old_items = self.items.clone();
|
||||||
|
self.items = fold_event(&self.items, event);
|
||||||
|
match &self.screen {
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||||
|
None => self.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Ok(transport) = build_transport() {
|
||||||
|
let api = ApiClient::new(transport);
|
||||||
|
let _ = api.send_message(&session_id, &text, &[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use ai_app::desktop::{app, startup};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(e) = startup::load_startup_config() {
|
||||||
|
eprintln!("desktop-app: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
app::run();
|
||||||
|
}
|
||||||
@@ -1,25 +1,3 @@
|
|||||||
//! What a tool printed, with its terminal styling applied and everything
|
|
||||||
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
|
||||||
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
|
||||||
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
|
||||||
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
|
||||||
//! [`Style`] onto whatever it draws with.
|
|
||||||
//!
|
|
||||||
//! Bash output arrives exactly as the program wrote it, escape sequences
|
|
||||||
//! included, and drawn verbatim those are line noise in the middle of the
|
|
||||||
//! thing being read. Stripping them all would be the other half-answer --
|
|
||||||
//! colour is often the whole of what a diff or a test run is saying.
|
|
||||||
//!
|
|
||||||
//! So the sequences that decide how text *looks* become spans, and every
|
|
||||||
//! other one is dropped rather than shown: the rest move a cursor around a
|
|
||||||
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
|
||||||
//! document.
|
|
||||||
//!
|
|
||||||
//! A carriage return is honoured the way a terminal honours it: what was
|
|
||||||
//! written since the last line break is thrown away and the line starts
|
|
||||||
//! again. That is what makes a progress bar show its final state rather
|
|
||||||
//! than every state it passed through.
|
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||||
@@ -38,18 +16,10 @@ impl Rgb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sixteen colours a terminal program names, and the two it assumes.
|
|
||||||
///
|
|
||||||
/// Its own palette rather than the syntax one: a program that prints in red
|
|
||||||
/// has chosen red, where a highlighter's colours are this app's reading of
|
|
||||||
/// somebody else's code.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AnsiPalette {
|
pub struct AnsiPalette {
|
||||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
|
||||||
pub colours: [Rgb; 16],
|
pub colours: [Rgb; 16],
|
||||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
|
||||||
pub foreground: Rgb,
|
pub foreground: Rgb,
|
||||||
/// What the text sits on, needed for reverse video.
|
|
||||||
pub background: Rgb,
|
pub background: Rgb,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +37,6 @@ pub struct Style {
|
|||||||
pub strikethrough: bool,
|
pub strikethrough: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
|
||||||
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
pub struct StyledText {
|
pub struct StyledText {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -87,11 +55,7 @@ impl StyledText {
|
|||||||
const ESC: char = '\u{1B}';
|
const ESC: char = '\u{1B}';
|
||||||
const BELL: char = '\u{7}';
|
const BELL: char = '\u{7}';
|
||||||
|
|
||||||
/// [text] with its terminal styling applied and everything else taken out;
|
|
||||||
/// see the module doc.
|
|
||||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||||
// The common case by a long way -- nothing to do, and nothing allocated
|
|
||||||
// to find that out.
|
|
||||||
if !text.contains(ESC) && !text.contains('\r') {
|
if !text.contains(ESC) && !text.contains('\r') {
|
||||||
return StyledText::plain(text.to_string());
|
return StyledText::plain(text.to_string());
|
||||||
}
|
}
|
||||||
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||||
// A bare carriage return rewrites the line. One before a newline
|
|
||||||
// is the other half of a Windows line ending: it rewrites
|
|
||||||
// nothing, and it is dropped rather than kept, since that pair
|
|
||||||
// is one line break.
|
|
||||||
flush(&mut plain, sgr, &mut runs);
|
flush(&mut plain, sgr, &mut runs);
|
||||||
drop_line(&mut runs);
|
drop_line(&mut runs);
|
||||||
at += 1;
|
at += 1;
|
||||||
} else if c == '\r' {
|
} else if c == '\r' {
|
||||||
at += 1;
|
at += 1;
|
||||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||||
// Everything printable, plus the two control characters that are
|
|
||||||
// layout rather than terminal commands. A stray bell or
|
|
||||||
// backspace goes for the same reason a cursor move does.
|
|
||||||
plain.push(c);
|
plain.push(c);
|
||||||
at += 1;
|
at += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
|||||||
StyledText { text: out, spans }
|
StyledText { text: out, spans }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throws away everything written since the last line break, as a carriage
|
|
||||||
/// return does.
|
|
||||||
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||||
while let Some((text, style)) = runs.pop() {
|
while let Some((text, style)) = runs.pop() {
|
||||||
if let Some(break_at) = text.rfind('\n') {
|
if let Some(break_at) = text.rfind('\n') {
|
||||||
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bytes that end a CSI sequence.
|
|
||||||
fn is_csi_final(c: char) -> bool {
|
fn is_csi_final(c: char) -> bool {
|
||||||
('@'..='~').contains(&c)
|
('@'..='~').contains(&c)
|
||||||
}
|
}
|
||||||
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
end += 1;
|
end += 1;
|
||||||
}
|
}
|
||||||
if end >= chars.len() {
|
if end >= chars.len() {
|
||||||
// Cut off mid-sequence, which is what a stream that has not
|
|
||||||
// finished arriving looks like: drop the fragment rather
|
|
||||||
// than printing it, and the whole sequence arrives with the
|
|
||||||
// next delta.
|
|
||||||
chars.len()
|
chars.len()
|
||||||
} else {
|
} else {
|
||||||
let params: String = chars[at + 2..end].iter().collect();
|
let params: String = chars[at + 2..end].iter().collect();
|
||||||
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
']' | 'P' | 'X' | '^' | '_' => {
|
']' | 'P' | 'X' | '^' | '_' => {
|
||||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
|
||||||
// allows after an OSC.
|
|
||||||
let mut end = at + 2;
|
let mut end = at + 2;
|
||||||
while end < chars.len() {
|
while end < chars.len() {
|
||||||
if chars[end] == BELL {
|
if chars[end] == BELL {
|
||||||
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
struct Sgr {
|
struct Sgr {
|
||||||
fg: Option<Rgb>,
|
fg: Option<Rgb>,
|
||||||
@@ -227,7 +174,6 @@ struct Sgr {
|
|||||||
reverse: bool,
|
reverse: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
|
||||||
const DIM_ALPHA: f32 = 0.65;
|
const DIM_ALPHA: f32 = 0.65;
|
||||||
|
|
||||||
impl Sgr {
|
impl Sgr {
|
||||||
@@ -242,7 +188,6 @@ impl Sgr {
|
|||||||
reverse: false,
|
reverse: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
|
||||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||||
if *self == Sgr::PLAIN {
|
if *self == Sgr::PLAIN {
|
||||||
return None;
|
return None;
|
||||||
@@ -275,15 +220,7 @@ impl Sgr {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This state with `params` applied -- one `ESC[...m`, which carries any
|
|
||||||
/// number of them.
|
|
||||||
///
|
|
||||||
/// A code this does not model is ignored rather than reset from: the
|
|
||||||
/// program meant something by it, and starting again would also drop
|
|
||||||
/// the codes beside it that are understood.
|
|
||||||
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
|
||||||
// zero too.
|
|
||||||
let codes: Vec<i64> = params
|
let codes: Vec<i64> = params
|
||||||
.split(';')
|
.split(';')
|
||||||
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||||
@@ -377,12 +314,6 @@ impl Sgr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
|
|
||||||
/// last parameter.
|
|
||||||
///
|
|
||||||
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
|
|
||||||
/// one. The first sixteen of that table are the palette's own, so a program
|
|
||||||
/// asking for "colour 1" through either spelling gets the same red.
|
|
||||||
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||||
match codes.get(at + 1) {
|
match codes.get(at + 1) {
|
||||||
Some(&5) => match codes.get(at + 2) {
|
Some(&5) => match codes.get(at + 2) {
|
||||||
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
|
||||||
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||||
|
|
||||||
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
|
||||||
/// grey ramp.
|
|
||||||
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
palette.foreground
|
palette.foreground
|
||||||
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
|
||||||
/// white foreground, black background.
|
|
||||||
fn palette() -> AnsiPalette {
|
fn palette() -> AnsiPalette {
|
||||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||||
for (i, c) in colours.iter_mut().enumerate() {
|
for (i, c) in colours.iter_mut().enumerate() {
|
||||||
@@ -452,8 +378,6 @@ mod tests {
|
|||||||
ansi_styled(text, &palette())
|
ansi_styled(text, &palette())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The style covering the first character of `word`, or `None` where
|
|
||||||
/// nothing styles it.
|
|
||||||
fn style_over(text: &str, word: &str) -> Option<Style> {
|
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||||
let out = styled(text);
|
let out = styled(text);
|
||||||
let at = out
|
let at = out
|
||||||
@@ -509,8 +433,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||||
// A cursor move, an erase, an OSC window title with its bell, and a
|
|
||||||
// bare two-character escape.
|
|
||||||
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||||
assert_eq!(styled(&text).text, "abcde");
|
assert_eq!(styled(&text).text, "abcde");
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
|
||||||
//! module doc for the table); the SSE half is [`crate::event_stream`].
|
|
||||||
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
|
||||||
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
|
||||||
//! which do not.
|
|
||||||
//!
|
|
||||||
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
|
||||||
//! crate, and anything built on it, can be tested against a fake one with
|
|
||||||
//! no server involved. [`UreqTransport`] is the only real implementation.
|
|
||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// A request that did not produce what it asked for, carrying the server's
|
|
||||||
/// own wording where it sent some.
|
|
||||||
///
|
|
||||||
/// `status` is the HTTP status where there was a response at all, and
|
/// `status` is the HTTP status where there was a response at all, and
|
||||||
/// `None` where the server was never reached -- mirroring `ApiException` in
|
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||||
/// `Api.kt`.
|
/// `Api.kt`.
|
||||||
@@ -32,8 +20,6 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
/// A request body to send, in whichever of the two shapes the surface
|
|
||||||
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
|
||||||
pub enum Body {
|
pub enum Body {
|
||||||
Json(Value),
|
Json(Value),
|
||||||
Bytes {
|
Bytes {
|
||||||
@@ -50,10 +36,7 @@ pub struct RawResponse {
|
|||||||
pub body: Vec<u8>,
|
pub body: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The network boundary this crate's pure logic is kept out from behind.
|
|
||||||
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
|
||||||
pub trait Transport: Send + Sync {
|
pub trait Transport: Send + Sync {
|
||||||
/// One request/response call -- everything but the long-lived SSE GETs.
|
|
||||||
fn request(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -61,10 +44,6 @@ pub trait Transport: Send + Sync {
|
|||||||
body: Option<Body>,
|
body: Option<Body>,
|
||||||
) -> Result<RawResponse, ApiError>;
|
) -> Result<RawResponse, ApiError>;
|
||||||
|
|
||||||
/// Opens `path` and answers a reader over the response body, for a
|
|
||||||
/// caller that reads it as a stream rather than all at once (the SSE
|
|
||||||
/// connections in [`crate::event_stream`]). Fails the same way
|
|
||||||
/// [`Transport::request`] does for a non-2xx response.
|
|
||||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +82,6 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
|
||||||
/// built on it. Holds no state of its own beyond the transport -- the
|
|
||||||
/// session id or setup id a call is about is a parameter, per this
|
|
||||||
/// project's "ask for the least you need".
|
|
||||||
pub struct ApiClient<T: Transport> {
|
pub struct ApiClient<T: Transport> {
|
||||||
transport: T,
|
transport: T,
|
||||||
}
|
}
|
||||||
@@ -116,6 +91,14 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
Self { transport }
|
Self { transport }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The transport underneath, for a caller that needs the raw SSE
|
||||||
|
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||||
|
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||||
|
/// is the one that does.
|
||||||
|
pub fn transport(&self) -> &T {
|
||||||
|
&self.transport
|
||||||
|
}
|
||||||
|
|
||||||
fn json_request<R: for<'de> Deserialize<'de>>(
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -256,7 +239,7 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
/// A page of transcript history. `before` is the newest-first cursor
|
/// A page of transcript history. `before` is the newest-first cursor
|
||||||
/// (server default is "the newest page" when absent, which a caller
|
/// (server default is "the newest page" when absent, which a caller
|
||||||
/// gets by passing `None`); the events themselves are handed back as
|
/// gets by passing `None`); the events themselves are handed back as
|
||||||
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
/// [`event_model::SeqEvent`] via `crate::client::event_stream`'s parsing, kept
|
||||||
/// out of this method's signature so a caller that only wants the raw
|
/// out of this method's signature so a caller that only wants the raw
|
||||||
/// lines (for the transcript cache) is not forced to parse them.
|
/// lines (for the transcript cache) is not forced to parse them.
|
||||||
pub fn fetch_transcript_page(
|
pub fn fetch_transcript_page(
|
||||||
@@ -266,23 +249,58 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
) -> Result<Vec<Value>, ApiError> {
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
self.json_request(
|
||||||
if let Some(before) = before {
|
"GET",
|
||||||
path.push_str(&format!("&before={before}"));
|
&transcript_path(session_id, before, limit, coalesce, None),
|
||||||
}
|
None,
|
||||||
if coalesce {
|
)
|
||||||
path.push_str("&coalesce=true");
|
}
|
||||||
}
|
|
||||||
self.json_request("GET", &path, None)
|
pub fn fetch_transcript_lines(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: u32,
|
||||||
|
coalesce: bool,
|
||||||
|
after: Option<u64>,
|
||||||
|
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||||
|
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||||
|
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||||
|
raw.into_iter()
|
||||||
|
.map(|value| {
|
||||||
|
let line = value.get().to_string();
|
||||||
|
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||||
|
message: format!(
|
||||||
|
"the server sent a transcript line this build couldn't parse: {e}"
|
||||||
|
),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
Ok((line, event))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
fn transcript_path(
|
||||||
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
session_id: &str,
|
||||||
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
before: Option<u64>,
|
||||||
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
limit: u32,
|
||||||
/// store -- the server's certificate is self-signed on purpose (see
|
coalesce: bool,
|
||||||
/// `wg-app-link`).
|
after: Option<u64>,
|
||||||
|
) -> String {
|
||||||
|
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||||
|
if let Some(before) = before {
|
||||||
|
path.push_str(&format!("&before={before}"));
|
||||||
|
}
|
||||||
|
if coalesce {
|
||||||
|
path.push_str("&coalesce=true");
|
||||||
|
}
|
||||||
|
if let Some(after) = after {
|
||||||
|
path.push_str(&format!("&after={after}"));
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UreqTransport {
|
pub struct UreqTransport {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -290,8 +308,6 @@ pub struct UreqTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UreqTransport {
|
impl UreqTransport {
|
||||||
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
|
||||||
/// exactly as read from `certs/ca.pem`.
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
token: impl Into<String>,
|
token: impl Into<String>,
|
||||||
@@ -306,10 +322,6 @@ impl UreqTransport {
|
|||||||
.build();
|
.build();
|
||||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||||
.tls_config(tls_config)
|
.tls_config(tls_config)
|
||||||
// Read the body ourselves on every status, the way
|
|
||||||
// `requestFromServer` does: the server's own error wording is
|
|
||||||
// in the body of a 4xx/5xx, and the default behaviour throws
|
|
||||||
// it away before this code can read it.
|
|
||||||
.http_status_as_error(false)
|
.http_status_as_error(false)
|
||||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||||
.build()
|
.build()
|
||||||
@@ -387,9 +399,6 @@ impl Transport for UreqTransport {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", &auth)
|
.header("Authorization", &auth)
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
// No read timeout: between events there is nothing to read for
|
|
||||||
// as long as the thing being followed is idle, mirroring
|
|
||||||
// `EventStream.kt`'s `readTimeout = 0`.
|
|
||||||
.config()
|
.config()
|
||||||
.timeout_recv_response(None)
|
.timeout_recv_response(None)
|
||||||
.build()
|
.build()
|
||||||
@@ -415,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
|
||||||
/// for the reader rather than a diagnostic -- see this project's UI rule
|
|
||||||
/// about shortening a failure in one place rather than at each display site.
|
|
||||||
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||||
let detail = String::from_utf8_lossy(body).trim().to_string();
|
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||||
let message = if status == 401 {
|
let message = if status == 401 {
|
||||||
@@ -441,8 +447,6 @@ mod tests {
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A transport with no network at all, for the pure-logic tests this
|
|
||||||
/// module can run without a server.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeTransport {
|
struct FakeTransport {
|
||||||
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||||
@@ -503,7 +507,6 @@ mod tests {
|
|||||||
assert_eq!(sessions.len(), 1);
|
assert_eq!(sessions.len(), 1);
|
||||||
assert_eq!(sessions[0].id, "s1");
|
assert_eq!(sessions[0].id, "s1");
|
||||||
assert_eq!(sessions[0].setup_name, "desktop");
|
assert_eq!(sessions[0].setup_name, "desktop");
|
||||||
// Defaults for fields the server omits.
|
|
||||||
assert!(sessions[0].notify);
|
assert!(sessions[0].notify);
|
||||||
assert_eq!(sessions[0].model, None);
|
assert_eq!(sessions[0].model, None);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||||
|
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||||
|
/// because an app built on the machine its server runs on pins the CA at
|
||||||
|
/// build time and needs nothing from the link; one built elsewhere -- the
|
||||||
|
/// iris Android client is cross-compiled in a VM and run against the
|
||||||
|
/// host's server -- has no other way to get it. A public certificate
|
||||||
|
/// rather than a secret, so it costs the link nothing but length.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EnrolledServer {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ca_pem: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrolledServer {
|
||||||
|
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||||
|
/// here, because that is what every consumer of it wants
|
||||||
|
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||||
|
/// at). A `ca` that does not decode fails the whole link rather than
|
||||||
|
/// enrolling a server with no trust anchor: the link said which
|
||||||
|
/// certificate to pin, and quietly not pinning it is the one outcome
|
||||||
|
/// nothing downstream could notice.
|
||||||
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||||
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"'{link}' has no query string (expected \
|
||||||
|
aiapp://enroll?host=...&port=...&token=...)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut host = None;
|
||||||
|
let mut port = None;
|
||||||
|
let mut token = None;
|
||||||
|
let mut ca = None;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = percent_decode(value);
|
||||||
|
match key {
|
||||||
|
"host" => host = Some(value),
|
||||||
|
"port" => port = Some(value),
|
||||||
|
"token" => token = Some(value),
|
||||||
|
"ca" => ca = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
||||||
|
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
||||||
|
let port: u16 = port_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||||
|
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||||
|
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
token,
|
||||||
|
ca_pem,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("https://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||||
|
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(ca.as_bytes())
|
||||||
|
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
||||||
|
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
||||||
|
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
||||||
|
for line in body.as_bytes().chunks(64) {
|
||||||
|
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
||||||
|
pem.push('\n');
|
||||||
|
}
|
||||||
|
pem.push_str("-----END CERTIFICATE-----\n");
|
||||||
|
Ok(pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where one client keeps the enrollment it should not have to be told
|
||||||
|
/// about a second time. `dir` is the caller's, because that is the only
|
||||||
|
/// part that differs by platform -- see this module's doc.
|
||||||
|
pub struct EnrollmentStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrollmentStore {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(&self) -> PathBuf {
|
||||||
|
self.dir.join("enrollment.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||||
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
|
let path = self.file();
|
||||||
|
let json = serde_json::to_vec_pretty(server)
|
||||||
|
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||||
|
std::fs::write(&path, json)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||||
|
let path = self.file();
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(server))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%'
|
||||||
|
&& i + 2 < bytes.len()
|
||||||
|
&& let Ok(byte) =
|
||||||
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||||
|
{
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_host_port_and_token() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
server,
|
||||||
|
EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "abcDEF123".to_string(),
|
||||||
|
ca_pem: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_order_does_not_matter() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(server.host, "example.com");
|
||||||
|
assert_eq!(server.port, 443);
|
||||||
|
assert_eq!(server.token, "tok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_percent_encoded_token_is_decoded() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||||
|
assert_eq!(server.token, "a+b/c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_field_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("token"),
|
||||||
|
"error should name the missing field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ca_in_the_link_comes_back_as_pem() {
|
||||||
|
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||||
|
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
||||||
|
.unwrap();
|
||||||
|
let pem = server.ca_pem.expect("the link carried a CA");
|
||||||
|
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
||||||
|
assert!(
|
||||||
|
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
||||||
|
"{pem}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(
|
||||||
|
pem.lines()
|
||||||
|
.filter(|l| !l.starts_with("-----"))
|
||||||
|
.collect::<String>()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
der
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ca_parameter_is_none_not_an_error() {
|
||||||
|
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||||
|
assert_eq!(server.ca_pem, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||||
|
let err =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("ca"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_enrollment_reads_back_the_same() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
let server = EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "tok".to_string(),
|
||||||
|
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
||||||
|
};
|
||||||
|
store.save(&server).unwrap();
|
||||||
|
assert_eq!(store.load().unwrap(), Some(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_saved_yet_is_none_not_an_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_enrollment_without_a_ca_still_loads() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
std::fs::create_dir_all(dir.path()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("enrollment.json"),
|
||||||
|
br#"{"host":"h","port":1,"token":"t"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn the_saved_file_is_owner_only() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
store
|
||||||
|
.save(&EnrolledServer {
|
||||||
|
host: "h".to_string(),
|
||||||
|
port: 1,
|
||||||
|
token: "t".to_string(),
|
||||||
|
ca_pem: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_file_is_named_in_the_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
||||||
|
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
||||||
|
assert!(err.to_string().contains("enrollment.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_numeric_port_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("port"),
|
||||||
|
"error should name the offending field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||||
|
/// minutes. The rule has two halves, because a short span and a long one
|
||||||
|
/// are read for different things. Under a minute the question is "roughly
|
||||||
|
/// how long", so only the largest unit is shown and a fraction carries the
|
||||||
|
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||||
|
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||||
|
/// units are left out rather than written as zero.
|
||||||
|
pub fn format_millis(ms: i64) -> String {
|
||||||
|
if ms < 0 {
|
||||||
|
return format!("-{}", format_millis(-ms));
|
||||||
|
}
|
||||||
|
if ms < 1000 {
|
||||||
|
return format!("{ms}ms");
|
||||||
|
}
|
||||||
|
if ms < 60_000 {
|
||||||
|
let tenths = (ms + 50) / 100;
|
||||||
|
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||||
|
return if rest == 0 {
|
||||||
|
format!("{whole}s")
|
||||||
|
} else {
|
||||||
|
format!("{whole}.{rest}s")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let seconds = ms / 1000;
|
||||||
|
[
|
||||||
|
("d", seconds / 86_400),
|
||||||
|
("h", seconds / 3600 % 24),
|
||||||
|
("m", seconds / 60 % 60),
|
||||||
|
("s", seconds % 60),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, n)| *n > 0)
|
||||||
|
.map(|(unit, n)| format!("{n}{unit}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_millis_text(text: &str) -> String {
|
||||||
|
match text.trim().parse::<i64>() {
|
||||||
|
Ok(ms) => format_millis(ms),
|
||||||
|
Err(_) => text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn under_a_minute_is_the_largest_unit_alone() {
|
||||||
|
assert_eq!(format_millis(30), "30ms");
|
||||||
|
assert_eq!(format_millis(999), "999ms");
|
||||||
|
assert_eq!(format_millis(1000), "1s");
|
||||||
|
assert_eq!(format_millis(2500), "2.5s");
|
||||||
|
assert_eq!(format_millis(2460), "2.5s");
|
||||||
|
assert_eq!(format_millis(59_900), "59.9s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||||
|
assert_eq!(format_millis(480_000), "8m");
|
||||||
|
assert_eq!(format_millis(60_000), "1m");
|
||||||
|
assert_eq!(format_millis(90_000), "1m 30s");
|
||||||
|
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||||
|
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||||
|
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||||
|
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||||
|
assert_eq!(format_millis_text(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
//! The SSE half of the API: one long-lived GET per open session screen,
|
|
||||||
//! replaying the transcript after a cursor and then following it live.
|
|
||||||
//! Ported from `app/.../EventStream.kt`; the framing itself is
|
|
||||||
//! [`crate::sse`].
|
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
use crate::api::{ApiError, Transport};
|
use crate::client::api::{ApiError, Transport};
|
||||||
use crate::sse::SseReader;
|
use crate::client::sse::SseReader;
|
||||||
|
|
||||||
/// The frame name the server uses to say a cursor was too far behind to
|
/// The frame name the server uses to say a cursor was too far behind to
|
||||||
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
||||||
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
|
|||||||
/// callbacks were for, as a single enum instead, since Rust has no
|
/// callbacks were for, as a single enum instead, since Rust has no
|
||||||
/// equivalent of handing three closures to one blocking call.
|
/// equivalent of handing three closures to one blocking call.
|
||||||
pub enum StreamItem {
|
pub enum StreamItem {
|
||||||
/// The connection was accepted; the measured moment the stream is live
|
|
||||||
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
|
|
||||||
/// event, is what clears a previous failure on screen).
|
|
||||||
Open,
|
Open,
|
||||||
/// The cursor was too far behind to continue from: everything already
|
|
||||||
/// displayed is stale, and the events that follow are a fresh window.
|
|
||||||
/// Arrives before those events, so a caller that clears on it stays in
|
|
||||||
/// order.
|
|
||||||
Reset,
|
Reset,
|
||||||
/// One event, as both the raw line the transcript cache stores and the
|
|
||||||
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
|
|
||||||
/// line, so both travel together rather than being parsed twice from
|
|
||||||
/// two call sites.
|
|
||||||
Event { raw: String, event: SeqEvent },
|
Event { raw: String, event: SeqEvent },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +43,6 @@ pub fn follow_session_events(
|
|||||||
let Some(frame) = reader.feed_line(&line) else {
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// A named frame carries no payload and a data frame has no name.
|
|
||||||
if frame.name.as_deref() == Some(RESET_EVENT) {
|
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||||
if !on_item(StreamItem::Reset) {
|
if !on_item(StreamItem::Reset) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -83,7 +66,7 @@ pub fn follow_session_events(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
struct FixtureTransport {
|
struct FixtureTransport {
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
|
||||||
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
|
||||||
//! why nearly every language is a row of data read by one shared scanner,
|
|
||||||
//! with Markdown the one exception (`super::markdown`).
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -32,8 +27,6 @@ pub enum Language {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Language {
|
impl Language {
|
||||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
|
||||||
/// (`Language.entries`).
|
|
||||||
pub const ALL: [Language; 22] = [
|
pub const ALL: [Language; 22] = [
|
||||||
Language::C,
|
Language::C,
|
||||||
Language::Coffeescript,
|
Language::Coffeescript,
|
||||||
@@ -60,24 +53,14 @@ impl Language {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`super::scan`] needs to know about one language -- data, not code,
|
|
||||||
/// so that adding a language is a row here rather than a branch anywhere.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Rules {
|
pub struct Rules {
|
||||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
|
||||||
/// anything else.
|
|
||||||
pub keywords: HashSet<&'static str>,
|
pub keywords: HashSet<&'static str>,
|
||||||
/// Tokens that open a comment running to the end of the line.
|
|
||||||
pub line_comments: Vec<&'static str>,
|
pub line_comments: Vec<&'static str>,
|
||||||
/// Whether `line_comments` count only at the start of a word. The shells
|
|
||||||
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
|
||||||
pub line_comments_at_word_start: bool,
|
pub line_comments_at_word_start: bool,
|
||||||
pub block_comment: Option<BlockComment>,
|
pub block_comment: Option<BlockComment>,
|
||||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
|
||||||
/// tried before `"`.
|
|
||||||
pub quotes: Vec<Quote>,
|
pub quotes: Vec<Quote>,
|
||||||
pub attributes: Attributes,
|
pub attributes: Attributes,
|
||||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
|
||||||
pub raw_strings: bool,
|
pub raw_strings: bool,
|
||||||
/// Rust: `'` opens a character literal only when a backslash or one
|
/// Rust: `'` opens a character literal only when a backslash or one
|
||||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||||
@@ -91,8 +74,6 @@ pub struct BlockComment {
|
|||||||
pub nests: bool,
|
pub nests: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
|
||||||
/// (and itself).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Quote {
|
pub struct Quote {
|
||||||
pub open: &'static str,
|
pub open: &'static str,
|
||||||
@@ -100,18 +81,13 @@ pub struct Quote {
|
|||||||
pub escapes: bool,
|
pub escapes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub enum Attributes {
|
pub enum Attributes {
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
|
||||||
AtWord,
|
AtWord,
|
||||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
|
||||||
HashBracket,
|
HashBracket,
|
||||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
|
||||||
HashLine,
|
HashLine,
|
||||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
|
||||||
LineBracket,
|
LineBracket,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
|
|||||||
list.split_whitespace().collect()
|
list.split_whitespace().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rules for one language. A `match` rather than a lazily-built map --
|
|
||||||
/// there is no once-per-process cost worth paying for in a language table
|
|
||||||
/// this small, and it sidesteps the Kotlin version's own workaround for
|
|
||||||
/// property initialization order.
|
|
||||||
pub fn rules_for(language: Language) -> Rules {
|
pub fn rules_for(language: Language) -> Rules {
|
||||||
match language {
|
match language {
|
||||||
Language::C => Rules {
|
Language::C => Rules {
|
||||||
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
quotes: vec![DOUBLE, SINGLE],
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
// `###` opens and closes a block comment and `#` opens a line one,
|
|
||||||
// which is why the scanner tries the block opener first.
|
|
||||||
Language::Coffeescript => Rules {
|
Language::Coffeescript => Rules {
|
||||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||||
line_comments: vec!["#"],
|
line_comments: vec!["#"],
|
||||||
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
keywords: words(KEYWORDS_SHELL),
|
keywords: words(KEYWORDS_SHELL),
|
||||||
line_comments: vec!["#"],
|
line_comments: vec!["#"],
|
||||||
line_comments_at_word_start: true,
|
line_comments_at_word_start: true,
|
||||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
|
||||||
quotes: vec![
|
quotes: vec![
|
||||||
DOUBLE,
|
DOUBLE,
|
||||||
Quote {
|
Quote {
|
||||||
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
attributes: Attributes::AtWord,
|
attributes: Attributes::AtWord,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
|
||||||
Language::Markdown => Rules::default(),
|
Language::Markdown => Rules::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The keyword sets. Every list below other than RON, TOML, fish and JSON
|
|
||||||
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
|
|
||||||
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
|
||||||
// plain here either.
|
|
||||||
|
|
||||||
const KEYWORDS_C: &str =
|
const KEYWORDS_C: &str =
|
||||||
"auto break case char const continue default do double else enum extern float for goto if
|
"auto break case char const continue default do double else enum extern float for goto if
|
||||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||||
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
|
|||||||
required rethrow return sealed set show static super switch this throw true try var void
|
required rethrow return sealed set show static super switch this throw true try var void
|
||||||
when with while yield";
|
when with while yield";
|
||||||
|
|
||||||
/// fish is not in the library at all, so its fences are drawn plain today.
|
|
||||||
/// The list is the shell's own words, which is what a fish fence is mostly
|
|
||||||
/// made of.
|
|
||||||
const KEYWORDS_FISH: &str =
|
const KEYWORDS_FISH: &str =
|
||||||
"and begin break builtin case command continue else end exec for function if in not or
|
"and begin break builtin case command continue else end exec for function if in not or
|
||||||
return switch while set echo test string math read source";
|
return switch while set echo test string math read source";
|
||||||
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
|
|||||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||||
yield";
|
yield";
|
||||||
|
|
||||||
/// RON is not in the library either; these are the words a RON file can hold.
|
|
||||||
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||||
|
|
||||||
const KEYWORDS_RUBY: &str =
|
const KEYWORDS_RUBY: &str =
|
||||||
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
|
|||||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||||
unowned weak willSet";
|
unowned weak willSet";
|
||||||
|
|
||||||
/// TOML is not in the library; `inf` and `nan` are values rather than
|
|
||||||
/// names, like the booleans.
|
|
||||||
const KEYWORDS_TOML: &str = "true false inf nan";
|
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||||
|
|
||||||
const KEYWORDS_TYPESCRIPT: &str =
|
const KEYWORDS_TYPESCRIPT: &str =
|
||||||
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
|||||||
.map(|(_, language)| *language)
|
.map(|(_, language)| *language)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The highlighter's language for a *file*, from its name.
|
|
||||||
///
|
|
||||||
/// The extension is the part after the *last* dot, which is what makes
|
/// The extension is the part after the *last* dot, which is what makes
|
||||||
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
|
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
|
||||||
/// extension, it has a name that starts with a dot. A name with no dot at
|
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
|
||||||
//! in a reply, and a `.md` file in the viewer. Ported from
|
|
||||||
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
|
||||||
//! scanner rather than a row of [`super::Rules`] (what a character means
|
|
||||||
//! depends on where it sits, not on what it is) and why an indented code
|
|
||||||
//! block is deliberately not recognised.
|
|
||||||
//!
|
|
||||||
//! Structure is read a line at a time and each line's prose left to right,
|
|
||||||
//! except the two decisions that are not: a fenced block is state carried
|
|
||||||
//! forward, and a table is found by its delimiter row, which comes after
|
|
||||||
//! the header it belongs to (the one place here that looks ahead).
|
|
||||||
|
|
||||||
use super::{Kind, Span};
|
use super::{Kind, Span};
|
||||||
|
|
||||||
/// The characters an unordered list may be bulleted with.
|
|
||||||
const BULLETS: &str = "-*+";
|
const BULLETS: &str = "-*+";
|
||||||
/// The characters a thematic break, or a setext heading's underline, can be
|
|
||||||
/// drawn with.
|
|
||||||
const RULE_MARKERS: &str = "-*_=";
|
const RULE_MARKERS: &str = "-*_=";
|
||||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
|
||||||
const EMPHASIS: &str = "*_~";
|
const EMPHASIS: &str = "*_~";
|
||||||
/// Characters that end a bare URL wherever they appear, and ones only
|
|
||||||
/// trimmed off the end.
|
|
||||||
const URL_STOPS: &str = "<>\"'`|";
|
const URL_STOPS: &str = "<>\"'`|";
|
||||||
const URL_TRAILING: &str = ".,:;!?";
|
const URL_TRAILING: &str = ".,:;!?";
|
||||||
|
|
||||||
@@ -46,15 +28,10 @@ impl MarkdownScanner {
|
|||||||
// The delimiter run that opened the fenced block we are inside, or
|
// The delimiter run that opened the fenced block we are inside, or
|
||||||
// None between them.
|
// None between them.
|
||||||
let mut fence: Option<Vec<char>> = None;
|
let mut fence: Option<Vec<char>> = None;
|
||||||
// Whether the row above was part of a table, which is what makes
|
|
||||||
// this one a body row.
|
|
||||||
let mut table = false;
|
let mut table = false;
|
||||||
loop {
|
loop {
|
||||||
let end = self.line_end(at);
|
let end = self.line_end(at);
|
||||||
if let Some(open) = fence.clone() {
|
if let Some(open) = fence.clone() {
|
||||||
// The content and the closing line alike: a fence is one
|
|
||||||
// block of code, and its own delimiters belong to it the
|
|
||||||
// way a string's quotes belong to the string.
|
|
||||||
self.emit(at, end, Kind::String);
|
self.emit(at, end, Kind::String);
|
||||||
if self.closes_fence(at, end, &open) {
|
if self.closes_fence(at, end, &open) {
|
||||||
fence = None;
|
fence = None;
|
||||||
@@ -76,7 +53,6 @@ impl MarkdownScanner {
|
|||||||
self.spans
|
self.spans
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
|
||||||
fn line_end(&self, at: usize) -> usize {
|
fn line_end(&self, at: usize) -> usize {
|
||||||
self.code[at..]
|
self.code[at..]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -85,8 +61,6 @@ impl MarkdownScanner {
|
|||||||
.unwrap_or(self.code.len())
|
.unwrap_or(self.code.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line that is not inside a fence, and whether the table it may be
|
|
||||||
/// part of is still open.
|
|
||||||
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||||
if self.table_delimiter(start, end) {
|
if self.table_delimiter(start, end) {
|
||||||
let indented = self.indented(start, end);
|
let indented = self.indented(start, end);
|
||||||
@@ -102,13 +76,11 @@ impl MarkdownScanner {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
|
||||||
/// one of each needed.
|
|
||||||
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||||
let mut dashes = false;
|
let mut dashes = false;
|
||||||
let mut pipes = false;
|
let mut pipes = false;
|
||||||
for at in self.indented(start, end)..end {
|
for c in &self.code[self.indented(start, end)..end] {
|
||||||
match self.code[at] {
|
match c {
|
||||||
'-' => dashes = true,
|
'-' => dashes = true,
|
||||||
'|' => pipes = true,
|
'|' => pipes = true,
|
||||||
':' | ' ' | '\t' => {}
|
':' | ' ' | '\t' => {}
|
||||||
@@ -132,7 +104,6 @@ impl MarkdownScanner {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A table row: the pipes are the structure, and what is between them is prose.
|
|
||||||
fn table_row(&mut self, start: usize, end: usize) {
|
fn table_row(&mut self, start: usize, end: usize) {
|
||||||
let mut at = self.indented(start, end);
|
let mut at = self.indented(start, end);
|
||||||
let mut cell = at;
|
let mut cell = at;
|
||||||
@@ -151,7 +122,6 @@ impl MarkdownScanner {
|
|||||||
self.inline(cell, end);
|
self.inline(cell, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spans, coalesced with the one before when they touch and agree.
|
|
||||||
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||||
if end <= start {
|
if end <= start {
|
||||||
return;
|
return;
|
||||||
@@ -166,7 +136,6 @@ impl MarkdownScanner {
|
|||||||
self.spans.push(Span { start, end, kind });
|
self.spans.push(Span { start, end, kind });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first character of the line at or after `start` that is not indentation.
|
|
||||||
fn indented(&self, start: usize, end: usize) -> usize {
|
fn indented(&self, start: usize, end: usize) -> usize {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||||
@@ -175,8 +144,6 @@ impl MarkdownScanner {
|
|||||||
at
|
at
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run of backticks or tildes that could open or close a fence on
|
|
||||||
/// this line, or `None`.
|
|
||||||
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||||
let at = self.indented(start, end);
|
let at = self.indented(start, end);
|
||||||
if at == end {
|
if at == end {
|
||||||
@@ -193,20 +160,14 @@ impl MarkdownScanner {
|
|||||||
if run - at >= 3 { Some((at, run)) } else { None }
|
if run - at >= 3 { Some((at, run)) } else { None }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws an opening fence line and answers its delimiter, or `None` if
|
|
||||||
/// this is not one.
|
|
||||||
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||||
self.emit(run_start, run_end, Kind::String);
|
self.emit(run_start, run_end, Kind::String);
|
||||||
// The info word is what the fence is a fence *of*, which is
|
|
||||||
// metadata about the block rather than part of it.
|
|
||||||
let indented = self.indented(run_end, end);
|
let indented = self.indented(run_end, end);
|
||||||
self.emit(indented, end, Kind::Metadata);
|
self.emit(indented, end, Kind::Metadata);
|
||||||
Some(self.code[run_start..run_end].to_vec())
|
Some(self.code[run_start..run_end].to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this line closes a fence opened by `open`: the same
|
|
||||||
/// character, at least as many of them, and nothing else on the line.
|
|
||||||
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -217,12 +178,8 @@ impl MarkdownScanner {
|
|||||||
self.indented(run_end, end) == end
|
self.indented(run_end, end) == end
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One ordinary line: what its opening characters make it, and then its prose.
|
|
||||||
fn structure(&mut self, start: usize, end: usize) {
|
fn structure(&mut self, start: usize, end: usize) {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
// Quote markers come before everything else and can be several
|
|
||||||
// deep, and what follows one is an ordinary line again -- a heading
|
|
||||||
// inside a quote is still a heading.
|
|
||||||
while at < end && self.code[at] == '>' {
|
while at < end && self.code[at] == '>' {
|
||||||
at += 1;
|
at += 1;
|
||||||
self.emit(at - 1, at, Kind::Mark);
|
self.emit(at - 1, at, Kind::Mark);
|
||||||
@@ -238,8 +195,6 @@ impl MarkdownScanner {
|
|||||||
self.inline(text_start, end);
|
self.inline(text_start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `#` to `######` and a space. Without the space it is a word
|
|
||||||
/// beginning with a hash.
|
|
||||||
fn heading(&mut self, start: usize, end: usize) -> bool {
|
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end && self.code[at] == '#' {
|
while at < end && self.code[at] == '#' {
|
||||||
@@ -256,15 +211,13 @@ impl MarkdownScanner {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A line made of one repeated rule character and nothing else.
|
|
||||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
if !RULE_MARKERS.contains(marker) {
|
if !RULE_MARKERS.contains(marker) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut seen = 0usize;
|
let mut seen = 0usize;
|
||||||
for at in start..end {
|
for &c in &self.code[start..end] {
|
||||||
let c = self.code[at];
|
|
||||||
if c == marker {
|
if c == marker {
|
||||||
seen += 1;
|
seen += 1;
|
||||||
} else if !c.is_whitespace() {
|
} else if !c.is_whitespace() {
|
||||||
@@ -278,8 +231,6 @@ impl MarkdownScanner {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a list marker if the line opens with one, and answers where
|
|
||||||
/// the item's text starts.
|
|
||||||
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||||
@@ -305,16 +256,11 @@ impl MarkdownScanner {
|
|||||||
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inline forms, left to right. Every branch answers a position
|
|
||||||
/// strictly after `start` of its call, so this terminates.
|
|
||||||
fn inline(&mut self, start: usize, end: usize) {
|
fn inline(&mut self, start: usize, end: usize) {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end {
|
while at < end {
|
||||||
let c = self.code[at];
|
let c = self.code[at];
|
||||||
at = if c == '\\' {
|
at = if c == '\\' {
|
||||||
// A backslash takes the character after it out of the
|
|
||||||
// running entirely, which is how `\*` stays an asterisk
|
|
||||||
// rather than opening emphasis.
|
|
||||||
at + 2
|
at + 2
|
||||||
} else if c == '`' {
|
} else if c == '`' {
|
||||||
self.code_span(at, end)
|
self.code_span(at, end)
|
||||||
@@ -332,7 +278,6 @@ impl MarkdownScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
|
||||||
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||||
let mut open = start;
|
let mut open = start;
|
||||||
while open < end && self.code[open] == '`' {
|
while open < end && self.code[open] == '`' {
|
||||||
@@ -355,11 +300,9 @@ impl MarkdownScanner {
|
|||||||
}
|
}
|
||||||
at = close;
|
at = close;
|
||||||
}
|
}
|
||||||
// Nothing closes it on this line, so those were ordinary backticks.
|
|
||||||
open
|
open
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
|
||||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
let mut close = bracket;
|
let mut close = bracket;
|
||||||
@@ -398,8 +341,6 @@ impl MarkdownScanner {
|
|||||||
paren + 1
|
paren + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
|
||||||
/// destination they are.
|
|
||||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||||
let mut at = start + 1;
|
let mut at = start + 1;
|
||||||
let mut addressed = false;
|
let mut addressed = false;
|
||||||
@@ -423,8 +364,6 @@ impl MarkdownScanner {
|
|||||||
start + 1
|
start + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bare `scheme://...` written in prose, or `None` if one does not
|
|
||||||
/// start here.
|
|
||||||
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||||
if start > 0 && is_word(self.code[start - 1]) {
|
if start > 0 && is_word(self.code[start - 1]) {
|
||||||
return None;
|
return None;
|
||||||
@@ -466,8 +405,6 @@ impl MarkdownScanner {
|
|||||||
Some(at)
|
Some(at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
|
||||||
/// all.
|
|
||||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
let mut open = start;
|
let mut open = start;
|
||||||
@@ -1,24 +1,3 @@
|
|||||||
//! `code` read once, left to right, into the spans that carry a colour.
|
|
||||||
//! Ported from `app/.../Highlighter.kt`.
|
|
||||||
//!
|
|
||||||
//! One pass with a small state -- in a comment, in a string, or in ordinary
|
|
||||||
//! code -- rather than a locator per token kind over the whole text, which
|
|
||||||
//! is what the library this replaced did and is why it found comments
|
|
||||||
//! before it knew the language: a `#` inside a shell string, a `//` inside
|
|
||||||
//! a URL and a block-comment opener inside a shell glob each commented out
|
|
||||||
//! the rest of a line that was nothing of the sort.
|
|
||||||
//!
|
|
||||||
//! Every span is produced by advancing an index forward, so the result is
|
|
||||||
//! ordered, non-overlapping and inside the code by construction. Nothing
|
|
||||||
//! here panics: an unterminated string or comment runs to the end of the
|
|
||||||
//! code, which is also what it looks like while a fence is still being
|
|
||||||
//! written.
|
|
||||||
//!
|
|
||||||
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
|
||||||
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
|
||||||
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
|
||||||
//! back into the text it covers.
|
|
||||||
|
|
||||||
pub mod languages;
|
pub mod languages;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
|
||||||
@@ -26,7 +5,6 @@ pub use languages::{
|
|||||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a span of code is, in the terms a palette has a colour for.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Kind {
|
pub enum Kind {
|
||||||
Keyword,
|
Keyword,
|
||||||
@@ -38,7 +16,6 @@ pub enum Kind {
|
|||||||
Mark,
|
Mark,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub start: usize,
|
pub start: usize,
|
||||||
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
|||||||
Scanner::new(code, rules).run()
|
Scanner::new(code, rules).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
|
||||||
/// the library this replaced used.
|
|
||||||
const PUNCTUATION: &str = ",.:;";
|
const PUNCTUATION: &str = ",.:;";
|
||||||
const MARKS: &str = "()={}<>-+[]|&";
|
const MARKS: &str = "()={}<>-+[]|&";
|
||||||
|
|
||||||
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
|
|||||||
|
|
||||||
fn run(mut self) -> Vec<Span> {
|
fn run(mut self) -> Vec<Span> {
|
||||||
while self.at < self.code.len() {
|
while self.at < self.code.len() {
|
||||||
// Every branch that answers true has advanced `self.at`, so
|
|
||||||
// this terminates.
|
|
||||||
let consumed = self.block_comment()
|
let consumed = self.block_comment()
|
||||||
|| self.line_comment()
|
|| self.line_comment()
|
||||||
|| self.raw_string()
|
|| self.raw_string()
|
||||||
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
|
|||||||
starts_with_at(&self.code, self.at, token)
|
starts_with_at(&self.code, self.at, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a line comment token here opens one; see
|
|
||||||
/// [`Rules::line_comments_at_word_start`].
|
|
||||||
fn at_word_start(&self) -> bool {
|
fn at_word_start(&self) -> bool {
|
||||||
self.at == 0
|
self.at == 0
|
||||||
|| self.code[self.at - 1].is_whitespace()
|
|| self.code[self.at - 1].is_whitespace()
|
||||||
|| ";|&(".contains(self.code[self.at - 1])
|
|| ";|&(".contains(self.code[self.at - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether only whitespace stands between the start of this line and here.
|
|
||||||
fn at_line_start(&self) -> bool {
|
fn at_line_start(&self) -> bool {
|
||||||
let mut back = self.at as isize - 1;
|
let mut back = self.at as isize - 1;
|
||||||
while back >= 0 && self.code[back as usize] != '\n' {
|
while back >= 0 && self.code[back as usize] != '\n' {
|
||||||
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// From an open bracket through the one that matches it, or to the end
|
|
||||||
/// if none does.
|
|
||||||
fn advance_to_matching_bracket(&mut self) {
|
fn advance_to_matching_bracket(&mut self) {
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
while self.at < self.code.len() {
|
while self.at < self.code.len() {
|
||||||
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
|
|||||||
self.at += comment.open.chars().count();
|
self.at += comment.open.chars().count();
|
||||||
let mut depth = 1i32;
|
let mut depth = 1i32;
|
||||||
while self.at < self.code.len() && depth > 0 {
|
while self.at < self.code.len() && depth > 0 {
|
||||||
// The closer is tried first so that a language whose two
|
|
||||||
// delimiters are the same string -- CoffeeScript's `###` --
|
|
||||||
// closes rather than nesting forever.
|
|
||||||
if self.starts(comment.close) {
|
if self.starts(comment.close) {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
self.at += comment.close.chars().count();
|
self.at += comment.close.chars().count();
|
||||||
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
|
||||||
fn raw_string(&mut self) -> bool {
|
fn raw_string(&mut self) -> bool {
|
||||||
if !self.rules.raw_strings {
|
if !self.rules.raw_strings {
|
||||||
return false;
|
return false;
|
||||||
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn string(&mut self) -> bool {
|
fn string(&mut self) -> bool {
|
||||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
|
||||||
// than an empty string followed by a quote.
|
|
||||||
let mut quote: Option<Quote> = None;
|
let mut quote: Option<Quote> = None;
|
||||||
for candidate in &self.rules.quotes {
|
for candidate in &self.rules.quotes {
|
||||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||||
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A number is a run starting with a digit and carrying on through
|
|
||||||
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
|
|
||||||
/// and `3.14` without a grammar for any of them.
|
|
||||||
fn number(&mut self) -> bool {
|
fn number(&mut self) -> bool {
|
||||||
if !self.code[self.at].is_ascii_digit() {
|
if !self.code[self.at].is_ascii_digit() {
|
||||||
return false;
|
return false;
|
||||||
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
|
|||||||
c.is_alphanumeric() || c == '_'
|
c.is_alphanumeric() || c == '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `code[at..]` starts with `token`, both read as chars.
|
|
||||||
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||||
let token: Vec<char> = token.chars().collect();
|
let token: Vec<char> = token.chars().collect();
|
||||||
if at + token.len() > code.len() {
|
if at + token.len() > code.len() {
|
||||||
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
|||||||
code[at..at + token.len()] == token[..]
|
code[at..at + token.len()] == token[..]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first index at or after `from` where `code` contains `needle`, or
|
|
||||||
/// `None`.
|
|
||||||
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||||
if needle.is_empty() || from > code.len() {
|
if needle.is_empty() || from > code.len() {
|
||||||
return None;
|
return None;
|
||||||
@@ -640,10 +596,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The scanner must never panic and must never answer a span the code
|
|
||||||
/// does not contain: the library this replaced answered a reversed
|
|
||||||
/// range here, which crashed a card, and a fence still being written is
|
|
||||||
/// an unterminated string or comment on every keystroke.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||||
let nasty = [
|
let nasty = [
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||||
|
/// modes are different: a flood of short lines exhausts the count, and one
|
||||||
|
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||||
|
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
|
||||||
|
/// only by bytes can be emptied by a single line.
|
||||||
|
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||||
|
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LogLine {
|
||||||
|
pub seq: u64,
|
||||||
|
/// Milliseconds since the unix epoch, from the app's own clock. The
|
||||||
|
/// app's rather than the receiver's: a line is timestamped when it
|
||||||
|
/// happened, and an upload can be minutes later or never.
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub level: log::Level,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogLine {
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
self.target.len() + self.message.len() + 32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `12:34:56.789 INFO iris::android: the message`, the shape a
|
||||||
|
/// person skims. Time of day only -- the date is in the report's own
|
||||||
|
/// header, and a ring never spans one.
|
||||||
|
pub fn format(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {:<5} {}: {}",
|
||||||
|
clock_time(self.at_ms),
|
||||||
|
self.level,
|
||||||
|
self.target,
|
||||||
|
self.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock_time(at_ms: u64) -> String {
|
||||||
|
let ms = at_ms % 1000;
|
||||||
|
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||||
|
format!(
|
||||||
|
"{:02}:{:02}:{:02}.{:03}",
|
||||||
|
secs_of_day / 3600,
|
||||||
|
(secs_of_day % 3600) / 60,
|
||||||
|
secs_of_day % 60,
|
||||||
|
ms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
|
||||||
|
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
|
||||||
|
/// the app down for.
|
||||||
|
pub fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Inner {
|
||||||
|
lines: VecDeque<LogLine>,
|
||||||
|
bytes: usize,
|
||||||
|
max_lines: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
next_seq: u64,
|
||||||
|
dropped: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||||
|
|
||||||
|
impl LogRing {
|
||||||
|
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
|
||||||
|
assert!(
|
||||||
|
max_lines > 0 && max_bytes > 0,
|
||||||
|
"a ring with no room holds nothing"
|
||||||
|
);
|
||||||
|
Self(Arc::new(Mutex::new(Inner {
|
||||||
|
lines: VecDeque::new(),
|
||||||
|
bytes: 0,
|
||||||
|
max_lines,
|
||||||
|
max_bytes,
|
||||||
|
next_seq: 0,
|
||||||
|
dropped: 0,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_defaults() -> Self {
|
||||||
|
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A poisoned lock is a bug in a panicking logger, not a reason to
|
||||||
|
/// take the app down a second time -- the ring is a diagnostic, and
|
||||||
|
/// losing it must not be worse than the fault it was recording.
|
||||||
|
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
|
||||||
|
let mut guard = match self.0.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
f(&mut guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||||
|
self.with(|inner| {
|
||||||
|
let line = LogLine {
|
||||||
|
seq: inner.next_seq,
|
||||||
|
at_ms: now_ms(),
|
||||||
|
level,
|
||||||
|
target: target.to_string(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
inner.next_seq += 1;
|
||||||
|
inner.bytes += line.weight();
|
||||||
|
inner.lines.push_back(line);
|
||||||
|
// `!is_empty()` rather than `len() > 1`: one line larger than
|
||||||
|
// the whole byte bound is kept, because dropping it would
|
||||||
|
// leave the ring silently empty while lines were arriving.
|
||||||
|
while inner.lines.len() > inner.max_lines
|
||||||
|
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
|
||||||
|
{
|
||||||
|
if let Some(evicted) = inner.lines.pop_front() {
|
||||||
|
inner.bytes -= evicted.weight();
|
||||||
|
inner.dropped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||||
|
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||||
|
self.with(|inner| {
|
||||||
|
let lines: Vec<LogLine> = inner
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|line| line.seq >= seq)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
|
||||||
|
(lines, next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.with(|inner| inner.lines.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped(&self) -> u64 {
|
||||||
|
self.with(|inner| inner.dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sequence number of the newest line held, or `None` for a ring
|
||||||
|
/// nothing has been written to.
|
||||||
|
///
|
||||||
|
/// What a reader needs to notice that this process **restarted**: the
|
||||||
|
/// ring is in memory, so a new process starts again at zero, and a
|
||||||
|
/// reader holding a cursor from the previous one would otherwise ask
|
||||||
|
/// for lines after a number nothing will reach for hours and see
|
||||||
|
/// nothing at all -- silently, which is worse than seeing the log
|
||||||
|
/// begin again. Answering `None` rather than 0 for an empty ring is
|
||||||
|
/// the same distinction [`Self::summary`] draws: "nothing has been
|
||||||
|
/// logged" is not a sequence number.
|
||||||
|
pub fn newest_seq(&self) -> Option<u64> {
|
||||||
|
self.with(|inner| inner.lines.back().map(|line| line.seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the newest line was written, in unix milliseconds, or `None`
|
||||||
|
/// for a ring nothing has been written to.
|
||||||
|
pub fn last_at_ms(&self) -> Option<u64> {
|
||||||
|
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_text(&self) -> String {
|
||||||
|
self.snapshot()
|
||||||
|
.iter()
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest `max_lines` lines, formatted, or `None` if the ring is
|
||||||
|
/// locked at this instant.
|
||||||
|
///
|
||||||
|
/// For the one caller that must not block: **the panic hook**. A panic
|
||||||
|
/// raised while this ring's own lock was held -- an allocation failing
|
||||||
|
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
|
||||||
|
/// -- would deadlock the hook against the thread that is panicking,
|
||||||
|
/// and the process would hang instead of aborting, with nothing
|
||||||
|
/// written anywhere. Losing the context lines is the right trade
|
||||||
|
/// against that, and `None` says which happened rather than looking
|
||||||
|
/// like an empty log.
|
||||||
|
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||||
|
let guard = match self.0.try_lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||||
|
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||||
|
};
|
||||||
|
let lines = &guard.lines;
|
||||||
|
let from = lines.len().saturating_sub(max_lines);
|
||||||
|
Some(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.skip(from)
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for a diagnostics pane: how much is held, how much was
|
||||||
|
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||||
|
/// wording rather than a count of zero with a made-up time, because
|
||||||
|
/// "nothing has been logged" and "logging is not running" would
|
||||||
|
/// otherwise look the same.
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
let (len, dropped, last) = self.with(|inner| {
|
||||||
|
(
|
||||||
|
inner.lines.len(),
|
||||||
|
inner.dropped,
|
||||||
|
inner.lines.back().map(|line| line.at_ms),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
match last {
|
||||||
|
None => "app log: no lines yet".to_string(),
|
||||||
|
Some(at) => {
|
||||||
|
let dropped = if dropped > 0 {
|
||||||
|
format!(", {dropped} dropped")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"app log: {len} lines held{dropped}, last {}",
|
||||||
|
clock_time(at)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a target belongs to this app or to `iris` rather than to a
|
||||||
|
/// dependency -- `starts_with` guarded by an
|
||||||
|
/// exact match or a `::` so an unrelated crate that merely begins with the
|
||||||
|
/// same letters (there is no such crate today, but the check should not
|
||||||
|
/// rely on that) is never mistaken for one of ours.
|
||||||
|
fn is_own_target(target: &str) -> bool {
|
||||||
|
target == "iris"
|
||||||
|
|| target.starts_with("iris::")
|
||||||
|
|| target == "ai_app"
|
||||||
|
|| target.starts_with("ai_app::")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||||
|
/// asked for, applied once here rather than at each `debug!` call site:
|
||||||
|
/// Info and above always ring, from anything, because a real warning or
|
||||||
|
/// error from a dependency is worth keeping. Debug and Trace ring only
|
||||||
|
/// from this app's own targets, and only while tracing is switched on --
|
||||||
|
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
|
||||||
|
/// (the process logger's own level, set once at install and unrelated to
|
||||||
|
/// tracing), which is what filled the ring with 1339 lines of it and
|
||||||
|
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
|
||||||
|
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
|
||||||
|
/// (commit 992c472); this is the backstop for lines this crate does not
|
||||||
|
/// control.
|
||||||
|
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||||
|
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RingLogger {
|
||||||
|
ring: LogRing,
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingLogger {
|
||||||
|
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
|
||||||
|
Self {
|
||||||
|
ring,
|
||||||
|
inner,
|
||||||
|
trace_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl log::Log for RingLogger {
|
||||||
|
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
|
||||||
|
self.ring
|
||||||
|
.push(record.level(), record.target(), record.args().to_string());
|
||||||
|
}
|
||||||
|
if self.inner.enabled(record.metadata()) {
|
||||||
|
self.inner.log(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
self.inner.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fails only if a logger is already installed, which is a programmer
|
||||||
|
/// error (two initialisation paths) rather than a recoverable condition --
|
||||||
|
/// the caller is named in the error so it is findable.
|
||||||
|
pub fn install(
|
||||||
|
ring: LogRing,
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
max_level: log::LevelFilter,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
) -> Result<(), log::SetLoggerError> {
|
||||||
|
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
|
||||||
|
log::set_max_level(max_level);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A deliberate process-global, where this project's rules otherwise say
|
||||||
|
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||||
|
/// has exactly one backend per process, set once, and every `log::info!`
|
||||||
|
/// anywhere in the binary goes to it. A ring handed around as a parameter
|
||||||
|
/// would be a *second* answer to "which lines exist" -- the report would
|
||||||
|
/// show one ring while the logger filled another, and which one a caller
|
||||||
|
/// got would depend on how far down the call tree it was. The tests above
|
||||||
|
/// all use their own [`LogRing`], so nothing here needs this to be
|
||||||
|
/// testable.
|
||||||
|
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn process_ring() -> &'static LogRing {
|
||||||
|
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_process_logger(
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
max_level: log::LevelFilter,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
) -> Result<(), log::SetLoggerError> {
|
||||||
|
install(process_ring().clone(), inner, max_level, trace_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use log::Level;
|
||||||
|
|
||||||
|
fn fill(ring: &LogRing, count: usize) {
|
||||||
|
for n in 0..count {
|
||||||
|
ring.push(Level::Info, "test", format!("line {n}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_come_back_oldest_first() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(text, ["line 0", "line 1", "line 2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_line_bound_drops_the_oldest_and_says_how_many() {
|
||||||
|
let ring = LogRing::new(3, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
|
||||||
|
assert_eq!(ring.len(), 3);
|
||||||
|
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||||
|
let ring = LogRing::new(1000, 300);
|
||||||
|
for n in 0..10 {
|
||||||
|
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
ring.len() < 10,
|
||||||
|
"the byte bound evicted: {} held",
|
||||||
|
ring.len()
|
||||||
|
);
|
||||||
|
assert!(ring.dropped() > 0);
|
||||||
|
assert!(
|
||||||
|
ring.snapshot().last().unwrap().message.starts_with('9'),
|
||||||
|
"and it evicted from the old end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||||
|
let ring = LogRing::new(100, 64);
|
||||||
|
ring.push(Level::Error, "t", "y".repeat(5000));
|
||||||
|
assert_eq!(ring.len(), 1);
|
||||||
|
assert_eq!(ring.dropped(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_numbers_only_increase_and_survive_eviction() {
|
||||||
|
let ring = LogRing::new(2, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
|
||||||
|
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn since_returns_only_what_is_new_and_the_next_cursor() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let (first, cursor) = ring.since(0);
|
||||||
|
assert_eq!(first.len(), 3);
|
||||||
|
assert_eq!(cursor, 3);
|
||||||
|
|
||||||
|
let (none, cursor) = ring.since(cursor);
|
||||||
|
assert!(none.is_empty(), "nothing new yet");
|
||||||
|
assert_eq!(cursor, 3, "and the cursor does not move");
|
||||||
|
|
||||||
|
ring.push(Level::Warn, "test", "later".into());
|
||||||
|
let (more, cursor) = ring.since(cursor);
|
||||||
|
assert_eq!(more.len(), 1);
|
||||||
|
assert_eq!(more[0].message, "later");
|
||||||
|
assert_eq!(cursor, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
|
||||||
|
fill(&ring, 5);
|
||||||
|
assert_eq!(ring.newest_seq(), Some(4));
|
||||||
|
|
||||||
|
let restarted = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&restarted, 1);
|
||||||
|
assert_eq!(
|
||||||
|
restarted.newest_seq(),
|
||||||
|
Some(0),
|
||||||
|
"a fresh ring starts again, which is exactly what a reader has to notice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_does_not_consume() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 2);
|
||||||
|
let (sent, _) = ring.since(0);
|
||||||
|
assert_eq!(sent.len(), 2);
|
||||||
|
assert_eq!(ring.len(), 2, "the report still has them after an upload");
|
||||||
|
assert_eq!(ring.to_text().lines().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_gives_the_newest_lines_with_no_header() {
|
||||||
|
let ring = LogRing::new(1000, 1 << 20);
|
||||||
|
fill(&ring, 200);
|
||||||
|
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
|
||||||
|
let lines: Vec<&str> = tail.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
|
||||||
|
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
|
||||||
|
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let held = ring.0.lock().expect("fresh ring");
|
||||||
|
assert_eq!(ring.try_tail_text(80), None);
|
||||||
|
drop(held);
|
||||||
|
assert!(ring.try_tail_text(80).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
assert_eq!(ring.summary(), "app log: no lines yet");
|
||||||
|
assert_eq!(ring.last_at_ms(), None);
|
||||||
|
assert!(ring.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summary_names_dropped_lines_only_when_there_are_some() {
|
||||||
|
let ring = LogRing::new(2, 1 << 20);
|
||||||
|
fill(&ring, 2);
|
||||||
|
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
|
||||||
|
fill(&ring, 2);
|
||||||
|
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_formats_as_time_level_target_message() {
|
||||||
|
let line = LogLine {
|
||||||
|
seq: 0,
|
||||||
|
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||||
|
level: Level::Info,
|
||||||
|
target: "iris::android".into(),
|
||||||
|
message: "surface created".into(),
|
||||||
|
}
|
||||||
|
.format();
|
||||||
|
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||||
|
use log::Log;
|
||||||
|
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
|
||||||
|
impl Log for Collect {
|
||||||
|
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||||
|
metadata.level() <= self.1
|
||||||
|
}
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
self.0.lock().unwrap().push(record.args().to_string());
|
||||||
|
}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
let logger = RingLogger::new(
|
||||||
|
ring.clone(),
|
||||||
|
Box::new(Collect(seen.clone(), Level::Info)),
|
||||||
|
|| true,
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("kept"))
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("iris::test")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("filtered"))
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("iris::test")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*seen.lock().unwrap(),
|
||||||
|
["kept"],
|
||||||
|
"the inner logger's own filter still applies"
|
||||||
|
);
|
||||||
|
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(
|
||||||
|
held,
|
||||||
|
["kept", "filtered"],
|
||||||
|
"own-target debug still rings while tracing is on"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||||
|
use log::Log;
|
||||||
|
struct Discard;
|
||||||
|
impl Log for Discard {
|
||||||
|
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn log(&self, _: &log::Record) {}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("naga debug spam"))
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("naga::front")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("naga warning"))
|
||||||
|
.level(Level::Warn)
|
||||||
|
.target("wgpu_core::device")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(
|
||||||
|
held,
|
||||||
|
["naga warning"],
|
||||||
|
"Info-and-above always rings; foreign Debug never does"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_accepts_is_own_target_debug_only_while_tracing() {
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Info, "wgpu_core::device", false),
|
||||||
|
"Info+ from anything, tracing off"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Warn, "jni", true),
|
||||||
|
"Info+ from anything, tracing on"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ring_accepts(Level::Debug, "jni", true),
|
||||||
|
"foreign Debug, tracing on: still excluded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ring_accepts(Level::Debug, "iris::sense", false),
|
||||||
|
"own Debug, tracing off: excluded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Debug, "iris::sense", true),
|
||||||
|
"own Debug, tracing on: included"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Trace, "ai_app::api", true),
|
||||||
|
"own Trace, tracing on: included"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_own_target_matches_the_crate_or_its_modules_only() {
|
||||||
|
assert!(is_own_target("iris"));
|
||||||
|
assert!(is_own_target("iris::sense"));
|
||||||
|
assert!(is_own_target("ai_app"));
|
||||||
|
assert!(is_own_target("ai_app::log_ring"));
|
||||||
|
assert!(!is_own_target("iris_something_else"));
|
||||||
|
assert!(!is_own_target("naga::front"));
|
||||||
|
assert!(!is_own_target("jni"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||||
|
|
||||||
|
/// What a block is, for a renderer that wants to style or space blocks
|
||||||
|
/// differently. `Other` is deliberately present rather than a panic or a
|
||||||
|
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||||
|
/// list and more get added, and a renderer treating an unknown one as
|
||||||
|
/// prose is right, but it should be able to *tell* that is what it is
|
||||||
|
/// doing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BlockKind {
|
||||||
|
Paragraph,
|
||||||
|
Heading,
|
||||||
|
Code,
|
||||||
|
List,
|
||||||
|
Table,
|
||||||
|
Quote,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Block {
|
||||||
|
pub kind: BlockKind,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_of(tag: &Tag) -> BlockKind {
|
||||||
|
match tag {
|
||||||
|
Tag::Paragraph => BlockKind::Paragraph,
|
||||||
|
Tag::Heading { .. } => BlockKind::Heading,
|
||||||
|
Tag::CodeBlock(_) => BlockKind::Code,
|
||||||
|
Tag::List(_) => BlockKind::List,
|
||||||
|
Tag::Table(_) => BlockKind::Table,
|
||||||
|
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||||
|
_ => BlockKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options() -> Options {
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||||
|
let mut out: Vec<Block> = Vec::new();
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut kind = BlockKind::Other;
|
||||||
|
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => {
|
||||||
|
if depth == 0 {
|
||||||
|
kind = kind_of(&tag);
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
Event::End(_) => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
push(&mut out, kind, &src[range]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if depth == 0 {
|
||||||
|
push(&mut out, BlockKind::Other, &src[range]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||||
|
let source = source.trim_end();
|
||||||
|
if source.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push(Block {
|
||||||
|
kind,
|
||||||
|
source: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||||
|
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||||
|
/// this is a comparison rather than an assumption.
|
||||||
|
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||||
|
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||||
|
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_splits_into_its_top_level_blocks() {
|
||||||
|
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||||
|
assert_eq!(
|
||||||
|
kinds(src),
|
||||||
|
vec![
|
||||||
|
BlockKind::Heading,
|
||||||
|
BlockKind::Paragraph,
|
||||||
|
BlockKind::Code,
|
||||||
|
BlockKind::List
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks[1].source, "First para.");
|
||||||
|
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_input_has_no_blocks() {
|
||||||
|
assert!(split_blocks("").is_empty());
|
||||||
|
assert!(split_blocks(" \n\n ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||||
|
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||||
|
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||||
|
assert_eq!(common_prefix(&before, &after), 2);
|
||||||
|
assert_eq!(before.len(), 3);
|
||||||
|
assert_eq!(after.len(), 3);
|
||||||
|
assert_ne!(before[2], after[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||||
|
let before = split_blocks("First para.\n\nSecond para.");
|
||||||
|
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||||
|
assert_eq!(common_prefix(&before, &after), 2);
|
||||||
|
assert_eq!(after.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||||
|
for src in [
|
||||||
|
"Here:\n\n```rust\n",
|
||||||
|
"Here:\n\n```rust\nfn main() {\n",
|
||||||
|
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
kinds(src),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||||
|
"{src:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||||
|
let before = split_blocks("Not a heading\n\nsecond");
|
||||||
|
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||||
|
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||||
|
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||||
|
assert_eq!(
|
||||||
|
common_prefix(&before, &after),
|
||||||
|
1,
|
||||||
|
"the rewritten block must not be reported as keepable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thematic_break_is_its_own_block() {
|
||||||
|
assert_eq!(
|
||||||
|
kinds("one\n\n---\n\ntwo"),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||||
|
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||||
|
assert_eq!(
|
||||||
|
kinds(fence_with_blanks),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||||
|
"a blank line inside a fence is not a block boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("```\n---\n```"),
|
||||||
|
vec![BlockKind::Code],
|
||||||
|
"a thematic break inside a fence is code, not a break"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("- a\n - a1\n - a2\n- b"),
|
||||||
|
vec![BlockKind::List],
|
||||||
|
"a nested list is one top-level block"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("## Heading\n```sh\nls\n```"),
|
||||||
|
vec![BlockKind::Heading, BlockKind::Code],
|
||||||
|
"a fence directly under a heading, with no blank line"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||||
|
vec![BlockKind::Table]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("> quoted\n> more\n\nplain"),
|
||||||
|
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||||
|
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||||
|
let mut prev = Vec::new();
|
||||||
|
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||||
|
let now = split_blocks(&full[..end]);
|
||||||
|
let common = common_prefix(&prev, &now);
|
||||||
|
assert!(
|
||||||
|
prev.is_empty() || common + 1 >= prev.len(),
|
||||||
|
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||||
|
prev.len()
|
||||||
|
);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||||
|
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(
|
||||||
|
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||||
|
);
|
||||||
|
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||||
|
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||||
|
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||||
|
assert_eq!(before.len(), after.len());
|
||||||
|
assert_eq!(common_prefix(&before, &after), 1);
|
||||||
|
assert_ne!(before[1], after[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
//! The app's pure logic, shared between the server and any Rust client --
|
|
||||||
//! see `CLIENT_CORE.md` at the repo root for what lives here and what does
|
|
||||||
//! not yet.
|
|
||||||
|
|
||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod log_ring;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod text_cap;
|
||||||
|
pub mod tool_summary;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
|
pub mod transcript_source;
|
||||||
|
|
||||||
pub use event_model::*;
|
pub use event_model::*;
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||||
//! places, never both" describes. Ported from the parsing half of
|
//! places, never both" describes. Ported from the parsing half of
|
||||||
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||||
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
|
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
|
||||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||||
//! `Notification`/`NotificationKind`).
|
//! `Notification`/`NotificationKind`).
|
||||||
//!
|
|
||||||
//! What is deliberately **not** here, because it is a decision rather than
|
|
||||||
//! logic: whether a given notification is shown at all (the session on
|
|
||||||
//! screen gets nothing), handed to the app as a banner, or posted to the
|
|
||||||
//! platform's own notification drawer. That three-way choice reads
|
|
||||||
//! process-wide state (what screen is open, whether the app is in front)
|
|
||||||
//! that has no meaning to a pure crate with no UI and no Android in it --
|
|
||||||
//! see `android-shell` for where it lives for this port.
|
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::{ApiError, Transport};
|
use crate::client::api::{ApiError, Transport};
|
||||||
use crate::sse::SseReader;
|
use crate::client::sse::SseReader;
|
||||||
|
|
||||||
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
|
||||||
/// `Notification` field for field.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionNotification {
|
pub struct SessionNotification {
|
||||||
@@ -32,9 +22,6 @@ pub struct SessionNotification {
|
|||||||
pub at: f64,
|
pub at: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
|
||||||
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
|
||||||
/// `"finished"` directly rather than through a string match.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum NotificationKind {
|
pub enum NotificationKind {
|
||||||
@@ -93,7 +80,7 @@ pub fn follow_notifications(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
struct FixtureTransport {
|
struct FixtureTransport {
|
||||||
@@ -1,15 +1,3 @@
|
|||||||
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
|
|
||||||
//! `event:` lines accumulate until a blank line ends the frame, comments
|
|
||||||
//! start with `:`, and a frame is either named with no payload or a payload
|
|
||||||
//! with no name.
|
|
||||||
//!
|
|
||||||
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
|
|
||||||
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
|
|
||||||
//! caller here feeds lines from wherever they came from (a real connection,
|
|
||||||
//! a test fixture) and gets frames back with no I/O of its own -- which is
|
|
||||||
//! what lets this be tested with no server, per RUST.md's "pure logic
|
|
||||||
//! first" for this crate.
|
|
||||||
|
|
||||||
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
@@ -17,10 +5,6 @@ pub struct Frame {
|
|||||||
pub data: String,
|
pub data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accumulates lines into [`Frame`]s. One instance per connection --
|
|
||||||
/// `feed_line` is called for every line the transport reads (with line
|
|
||||||
/// endings already stripped), and answers a frame when a blank line closes
|
|
||||||
/// one.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SseReader {
|
pub struct SseReader {
|
||||||
data: String,
|
data: String,
|
||||||
@@ -32,8 +16,6 @@ impl SseReader {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feeds one line (no trailing `\n`). Answers the frame this line
|
|
||||||
/// completed, if any.
|
|
||||||
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
if self.name.is_some() || !self.data.is_empty() {
|
if self.name.is_some() || !self.data.is_empty() {
|
||||||
@@ -50,7 +32,6 @@ impl SseReader {
|
|||||||
} else if let Some(rest) = line.strip_prefix("event:") {
|
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||||
self.name = Some(rest.trim().to_string());
|
self.name = Some(rest.trim().to_string());
|
||||||
}
|
}
|
||||||
// `id:`, comments -- nothing to do.
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/// The default bound on a verbatim block -- a tool call's input or its
|
||||||
|
/// output. Short, because this text is a machine's and the reader is
|
||||||
|
/// looking for one line of it.
|
||||||
|
pub const VERBATIM_LINES: usize = 80;
|
||||||
|
pub const VERBATIM_BYTES: usize = 4096;
|
||||||
|
|
||||||
|
pub const MESSAGE_LINES: usize = 200;
|
||||||
|
pub const MESSAGE_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
|
||||||
|
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
|
||||||
|
|
||||||
|
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
|
||||||
|
/// count it was cut *from*; `None` when the whole of it fits.
|
||||||
|
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
|
||||||
|
debug_assert!(
|
||||||
|
max_lines > 0 && max_bytes > 0,
|
||||||
|
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
|
||||||
|
);
|
||||||
|
let by_lines = text
|
||||||
|
.char_indices()
|
||||||
|
.filter(|(_, c)| *c == '\n')
|
||||||
|
.nth(max_lines - 1)
|
||||||
|
.map(|(i, _)| i);
|
||||||
|
let by_bytes = (text.len() > max_bytes).then(|| {
|
||||||
|
let mut end = max_bytes;
|
||||||
|
// Back up to a character boundary: a cut inside a multi-byte
|
||||||
|
// character panics on the slice below, and a transcript is full of
|
||||||
|
// them.
|
||||||
|
while !text.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
end
|
||||||
|
});
|
||||||
|
let cut = match (by_lines, by_bytes) {
|
||||||
|
(Some(a), Some(b)) => a.min(b),
|
||||||
|
(a, b) => a.or(b)?,
|
||||||
|
};
|
||||||
|
Some((&text[..cut], text.lines().count()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_all_label(lines: usize) -> String {
|
||||||
|
format!("Show all {lines} lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_under_both_bounds_is_not_cut() {
|
||||||
|
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_line_bound_cuts_at_a_line_boundary() {
|
||||||
|
let text = "a\nb\nc\nd\n";
|
||||||
|
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
|
||||||
|
assert_eq!(shown, "a\nb");
|
||||||
|
assert_eq!(
|
||||||
|
lines, 4,
|
||||||
|
"the count is the whole text's, not the shown part's"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_byte_bound_cuts_one_long_line() {
|
||||||
|
let text = "x".repeat(5000);
|
||||||
|
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
|
||||||
|
assert_eq!(shown.len(), 4096);
|
||||||
|
assert_eq!(lines, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tighter_of_the_two_bounds_wins() {
|
||||||
|
let text = "aaaa\n".repeat(100);
|
||||||
|
let (shown, _) = cut(&text, 80, 100).expect("over both");
|
||||||
|
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
|
||||||
|
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
|
||||||
|
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
|
||||||
|
let text = "é".repeat(100);
|
||||||
|
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
|
||||||
|
assert_eq!(
|
||||||
|
shown,
|
||||||
|
"é".repeat(5),
|
||||||
|
"11 bytes lands mid-character; 10 is the cut"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use crate::client::durations::format_millis_text;
|
||||||
|
use crate::client::highlight::Language;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ToolInput {
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub language: Option<Language>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// How long the call may take, in the largest units it fits. Shown
|
||||||
|
/// apart because it is a limit on the call rather than part of what
|
||||||
|
/// the call does.
|
||||||
|
pub timeout: Option<String>,
|
||||||
|
/// Everything else, as `name: value` lines. Never dropped.
|
||||||
|
pub rest: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolInput {
|
||||||
|
pub fn title(&self) -> Option<&str> {
|
||||||
|
self.description
|
||||||
|
.as_deref()
|
||||||
|
.or(self.subject.as_deref())
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||||
|
("Bash", "command", Some(Language::Shell)),
|
||||||
|
("Read", "file_path", None),
|
||||||
|
("Write", "file_path", None),
|
||||||
|
("Edit", "file_path", None),
|
||||||
|
("Glob", "pattern", None),
|
||||||
|
("Grep", "pattern", None),
|
||||||
|
("WebFetch", "url", None),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||||
|
|
||||||
|
/// One function rather than two, because the same coercion decides both
|
||||||
|
/// what a subject reads as and what a leftover field's value reads as, and
|
||||||
|
/// two copies would eventually disagree about a number.
|
||||||
|
fn as_text(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||||
|
let text = as_text(value?);
|
||||||
|
(!text.trim().is_empty()).then_some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||||
|
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||||
|
return ToolInput {
|
||||||
|
rest: match input.trim().is_empty() {
|
||||||
|
true => Vec::new(),
|
||||||
|
false => vec![input.to_string()],
|
||||||
|
},
|
||||||
|
..ToolInput::default()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parse_object(tool, &json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||||
|
let (subject_key, language) = SUBJECTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, ..)| *name == tool)
|
||||||
|
.map(|(_, key, language)| (Some(*key), *language))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||||
|
let description = DESCRIPTIONS
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| non_blank(json.get(*key)));
|
||||||
|
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||||
|
|
||||||
|
let mut keys: Vec<&String> = json
|
||||||
|
.keys()
|
||||||
|
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||||
|
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||||
|
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||||
|
.collect();
|
||||||
|
keys.sort();
|
||||||
|
let rest = keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ToolInput {
|
||||||
|
subject,
|
||||||
|
language,
|
||||||
|
description,
|
||||||
|
timeout,
|
||||||
|
rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_tool_in_the_table_has_its_own_subject() {
|
||||||
|
let cases = [
|
||||||
|
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||||
|
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||||
|
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||||
|
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||||
|
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||||
|
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||||
|
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||||
|
];
|
||||||
|
for (tool, input, expected) in cases {
|
||||||
|
let parsed = parse_tool_input(tool, input);
|
||||||
|
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||||
|
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||||
|
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||||
|
Some(Language::Shell),
|
||||||
|
"a Bash command is shell, and is the one row that names a language"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||||
|
let parsed = parse_tool_input(
|
||||||
|
"Bash",
|
||||||
|
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||||
|
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||||
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||||
|
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||||
|
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||||
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||||
|
let parsed = parse_tool_input(
|
||||||
|
"Edit",
|
||||||
|
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec![
|
||||||
|
"new_string: y".to_string(),
|
||||||
|
"old_string: x".to_string(),
|
||||||
|
"replace_all: true".to_string(),
|
||||||
|
],
|
||||||
|
"sorted, and a non-string value written as JSON"
|
||||||
|
);
|
||||||
|
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||||
|
assert_eq!(unknown.subject, None);
|
||||||
|
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_that_is_not_an_object_is_still_the_input() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_input("Bash", "just a string").rest,
|
||||||
|
vec!["just a string".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||||
|
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||||
|
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||||
|
assert_eq!(parsed.subject, None);
|
||||||
|
assert_eq!(parsed.title(), None);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +1,17 @@
|
|||||||
//! This phone's copy of the transcripts it has already been sent, so
|
|
||||||
//! reopening a session does not download it again. Ported from
|
|
||||||
//! `app/.../TranscriptCache.kt`; see `TRANSCRIPT_CACHE.md` at the repo root
|
|
||||||
//! for the design and `CLIENT_CORE.md` for how this file corresponds to it.
|
|
||||||
//!
|
|
||||||
//! What is stored is the server's own JSON for one event per line, in
|
|
||||||
//! transcript order. Reading the cache means running the same [`seq_of`]
|
|
||||||
//! the network path runs, so a cached transcript and a fetched one cannot
|
|
||||||
//! draw differently, and an event type this build does not know keeps
|
|
||||||
//! every field it arrived with for the build that will. Rows are
|
|
||||||
//! deliberately *not* what is stored: a row is a rendering, and a cache of
|
|
||||||
//! rows would need throwing away on every update that touched the fold.
|
|
||||||
//!
|
|
||||||
//! Four rules run through all of it:
|
|
||||||
//! 1. what is on screen is what the server's transcript says, in order,
|
|
||||||
//! with nothing missing -- the cache is a copy and is never inferred,
|
|
||||||
//! folded or edited here;
|
|
||||||
//! 2. a cached line is never ahead of the live cursor, and the cursor never
|
|
||||||
//! ahead of the cache;
|
|
||||||
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
|
|
||||||
//! unwritable all degrade to a cold open, never to a blank or a wrong
|
|
||||||
//! screen;
|
|
||||||
//! 4. a line already on the phone is not fetched again.
|
|
||||||
//!
|
|
||||||
//! No JSON parser here: what it needs off a line is the sequence number and
|
|
||||||
//! whether the line is a streamed delta, both read with a regex-free scan
|
|
||||||
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
|
|
||||||
//! treated as damage.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// How much of this phone's cache directory all of one server's transcripts
|
|
||||||
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
|
|
||||||
/// for 24,000 events) and a small fraction of a phone. A number to revisit
|
|
||||||
/// against real use rather than a measurement of anything.
|
|
||||||
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
||||||
|
|
||||||
/// What the newest cached line says, which is what the probe checks against
|
|
||||||
/// the server.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CachedTail {
|
pub struct CachedTail {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub line: String,
|
pub line: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This phone's cache root for one server, holding one directory per session.
|
|
||||||
pub struct TranscriptCache {
|
pub struct TranscriptCache {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||||
@@ -68,8 +32,6 @@ impl TranscriptCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cache for one session, whether or not anything has been stored
|
|
||||||
/// for it yet.
|
|
||||||
pub fn session(&self, id: &str) -> SessionCache {
|
pub fn session(&self, id: &str) -> SessionCache {
|
||||||
SessionCache::new(self.root.join(id), self.warn.clone())
|
SessionCache::new(self.root.join(id), self.warn.clone())
|
||||||
}
|
}
|
||||||
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One session's cached lines, as a directory of chunks.
|
|
||||||
///
|
|
||||||
/// A chunk is a set of lines *and a claim about what they cover*, and the
|
|
||||||
/// two are not the same thing: a coalesced page joins each run of streamed
|
|
||||||
/// deltas into one event carrying the seq of the run's oldest delta, so a
|
|
||||||
/// page whose newest event is seq 1,200 may cover everything up to the
|
|
||||||
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
|
|
||||||
/// is the half-open range in the file's name:
|
|
||||||
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
|
|
||||||
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
|
|
||||||
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
|
|
||||||
/// its last line's seq + 1.
|
|
||||||
///
|
|
||||||
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
||||||
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
||||||
/// ever served: chunks behind a gap are kept, because the gap is usually
|
/// ever served: chunks behind a gap are kept, because the gap is usually
|
||||||
/// closed by paging back through it, but nothing is served across one.
|
/// closed by paging back through it, but nothing is served across one.
|
||||||
///
|
|
||||||
/// **The newest chunk is always raw**, which is what makes the stream
|
|
||||||
/// cursor and the probe well defined.
|
|
||||||
///
|
|
||||||
/// Nothing here is load-bearing. Every operation that touches the disk
|
|
||||||
/// answers as though the cache were empty when it cannot, and a write
|
|
||||||
/// failure disables writing for the rest of this instance's life so that a
|
|
||||||
/// full disk costs one log line rather than one per delta.
|
|
||||||
///
|
|
||||||
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
|
|
||||||
/// the stream appends live events from its own thread while a reader
|
|
||||||
/// scrolling back reads pages from another, and this is what keeps the open
|
|
||||||
/// chunk's name, its end and its writer from being read half-rotated.
|
|
||||||
pub struct SessionCache {
|
pub struct SessionCache {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||||
@@ -202,8 +138,6 @@ pub struct SessionCache {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct WriterState {
|
struct WriterState {
|
||||||
/// Set by the first write that fails: a second would fail the same way,
|
|
||||||
/// once per delta.
|
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
writer: Option<fs::File>,
|
writer: Option<fs::File>,
|
||||||
open_file: Option<PathBuf>,
|
open_file: Option<PathBuf>,
|
||||||
@@ -238,7 +172,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
|
|
||||||
pub fn newest(&self, limit: usize) -> Vec<String> {
|
pub fn newest(&self, limit: usize) -> Vec<String> {
|
||||||
self.guard(Vec::new(), |this, state| {
|
self.guard(Vec::new(), |this, state| {
|
||||||
let mut taken: VecDeque<String> = VecDeque::new();
|
let mut taken: VecDeque<String> = VecDeque::new();
|
||||||
@@ -262,11 +195,6 @@ impl SessionCache {
|
|||||||
/// below `before` -- and means the server has to be asked. Deliberately
|
/// below `before` -- and means the server has to be asked. Deliberately
|
||||||
/// not an empty list: an empty page is how the screen is told it has
|
/// not an empty list: an empty page is how the screen is told it has
|
||||||
/// reached the start of the conversation.
|
/// reached the start of the conversation.
|
||||||
///
|
|
||||||
/// With `rows` the count is rows rather than lines, mirroring the
|
|
||||||
/// server's `parse_coalesced`. The deltas are not joined here -- the
|
|
||||||
/// fold does that, and the joined row keeps the seq of its first delta
|
|
||||||
/// either way.
|
|
||||||
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
||||||
self.guard(None, |this, state| {
|
self.guard(None, |this, state| {
|
||||||
let suffix = this.suffix(state)?;
|
let suffix = this.suffix(state)?;
|
||||||
@@ -291,18 +219,12 @@ impl SessionCache {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.each_line(state, chunk, |line| {
|
this.each_line(state, chunk, |line| {
|
||||||
// The page is what is *before* the cursor; the rows at
|
|
||||||
// or above it are already on screen.
|
|
||||||
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
||||||
if seq >= before {
|
if seq >= before {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if rows {
|
if rows {
|
||||||
let delta = is_delta(line);
|
let delta = is_delta(line);
|
||||||
// Stop only between rows: a delta continuing the
|
|
||||||
// run being gathered is part of a row already
|
|
||||||
// counted, and breaking on it would drop the half
|
|
||||||
// of that row already taken.
|
|
||||||
if counted >= limit && !(delta && in_run) {
|
if counted >= limit && !(delta && in_run) {
|
||||||
wanting = false;
|
wanting = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -338,9 +260,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores a fetched page covering `[first, end)`; `false` when it was
|
|
||||||
/// not stored.
|
|
||||||
///
|
|
||||||
/// Refused when it overlaps a chunk already here, because there is no
|
/// Refused when it overlaps a chunk already here, because there is no
|
||||||
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
||||||
/// delta run. The caller keeps that from arising by bounding what it
|
/// delta run. The caller keeps that from arising by bounding what it
|
||||||
@@ -361,6 +280,10 @@ impl SessionCache {
|
|||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
debug_assert!(
|
||||||
|
lines.iter().all(|l| !l.contains('\n')),
|
||||||
|
"a stored page's lines must each be one line"
|
||||||
|
);
|
||||||
fs::create_dir_all(&this.dir)?;
|
fs::create_dir_all(&this.dir)?;
|
||||||
let kind = if rows { "rows" } else { "raw" };
|
let kind = if rows { "rows" } else { "raw" };
|
||||||
let mut content = lines.join("\n");
|
let mut content = lines.join("\n");
|
||||||
@@ -373,13 +296,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends one live event, which is also how a freshly fetched opening
|
|
||||||
/// window is stored.
|
|
||||||
///
|
|
||||||
/// A seq equal to the open chunk's end extends it. A larger one is a
|
|
||||||
/// gap -- which is what a `reset` looks like from here -- and closes
|
|
||||||
/// the open chunk under the end it turned out to have. A smaller one is
|
|
||||||
/// already covered and is ignored; the SSE contract is `seq > after`.
|
|
||||||
pub fn append(&self, line: &str, seq: u64) {
|
pub fn append(&self, line: &str, seq: u64) {
|
||||||
self.guard((), |this, state| {
|
self.guard((), |this, state| {
|
||||||
if state.disabled {
|
if state.disabled {
|
||||||
@@ -388,9 +304,10 @@ impl SessionCache {
|
|||||||
let Some(writer) = this.writer_for(state, seq)? else {
|
let Some(writer) = this.writer_for(state, seq)? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Written as it arrived. A newline inside it would split one
|
debug_assert!(
|
||||||
// event into two unreadable halves, but neither source can
|
!line.contains('\n'),
|
||||||
// produce one.
|
"a cached transcript line must be one line: {line}"
|
||||||
|
);
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
writer.write_all(line.as_bytes())?;
|
writer.write_all(line.as_bytes())?;
|
||||||
writer.write_all(b"\n")?;
|
writer.write_all(b"\n")?;
|
||||||
@@ -399,7 +316,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flushes what [`Self::append`] has buffered.
|
|
||||||
pub fn flush(&self) {
|
pub fn flush(&self) {
|
||||||
self.guard((), |_this, state| {
|
self.guard((), |_this, state| {
|
||||||
if let Some(writer) = state.writer.as_mut() {
|
if let Some(writer) = state.writer.as_mut() {
|
||||||
@@ -410,12 +326,10 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`Self::purge`] would discard, for the reload row in session settings.
|
|
||||||
pub fn bytes(&self) -> u64 {
|
pub fn bytes(&self) -> u64 {
|
||||||
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks this session as visited, which is what eviction ranks by.
|
|
||||||
pub fn touch(&self) {
|
pub fn touch(&self) {
|
||||||
self.guard((), |this, _state| {
|
self.guard((), |this, _state| {
|
||||||
if this.dir.is_dir() {
|
if this.dir.is_dir() {
|
||||||
@@ -436,8 +350,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- chunks ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Every chunk on disk, oldest first. A name this does not recognise is
|
/// Every chunk on disk, oldest first. A name this does not recognise is
|
||||||
/// not ours and is ignored. Recomputed per operation rather than kept:
|
/// not ours and is ignored. Recomputed per operation rather than kept:
|
||||||
/// another operation may have changed the directory.
|
/// another operation may have changed the directory.
|
||||||
@@ -463,9 +375,6 @@ impl SessionCache {
|
|||||||
} else {
|
} else {
|
||||||
end_str.parse::<u64>().ok()
|
end_str.parse::<u64>().ok()
|
||||||
};
|
};
|
||||||
// A chunk covering nothing is one that was created and never
|
|
||||||
// written to -- an append whose very first write failed. It
|
|
||||||
// says nothing, so it is not a chunk.
|
|
||||||
if let Some(end) = end
|
if let Some(end) = end
|
||||||
&& end > first
|
&& end > first
|
||||||
{
|
{
|
||||||
@@ -482,13 +391,6 @@ impl SessionCache {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open chunk's end: its last line's seq plus one, or the in-memory
|
|
||||||
/// end while this instance is the one writing it.
|
|
||||||
///
|
|
||||||
/// An open chunk whose last line cannot be read is this app having died
|
|
||||||
/// mid-write. That line is dropped and the file truncated to the last
|
|
||||||
/// good one, which is the one place damage is repaired rather than
|
|
||||||
/// discarded.
|
|
||||||
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
||||||
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
||||||
return Some(state.open_end);
|
return Some(state.open_end);
|
||||||
@@ -504,12 +406,6 @@ impl SessionCache {
|
|||||||
Some(end)
|
Some(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The contiguous run of adjacent chunks ending at the newest one,
|
|
||||||
/// oldest first.
|
|
||||||
///
|
|
||||||
/// A newest chunk that is not raw cannot happen while this code is the
|
|
||||||
/// only writer, and means the directory is not to be trusted -- so the
|
|
||||||
/// session is discarded.
|
|
||||||
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
||||||
let all = self.chunks(state)?;
|
let all = self.chunks(state)?;
|
||||||
let Some(newest) = all.last() else {
|
let Some(newest) = all.last() else {
|
||||||
@@ -528,8 +424,6 @@ impl SessionCache {
|
|||||||
Ok(run.into_iter().collect())
|
Ok(run.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each line of `chunk`, newest first, until `take` says stop.
|
|
||||||
///
|
|
||||||
/// Damage anywhere but at the tail of the open chunk was not written by
|
/// Damage anywhere but at the tail of the open chunk was not written by
|
||||||
/// this code, and there is no honest way to say what a chunk covers
|
/// this code, and there is no honest way to say what a chunk covers
|
||||||
/// with a line of it unreadable -- so it is treated as damage rather
|
/// with a line of it unreadable -- so it is treated as damage rather
|
||||||
@@ -553,10 +447,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- writing -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// The writer for the chunk `seq` belongs in, opening or rotating one
|
|
||||||
/// as it has to.
|
|
||||||
fn writer_for<'s>(
|
fn writer_for<'s>(
|
||||||
&self,
|
&self,
|
||||||
state: &'s mut WriterState,
|
state: &'s mut WriterState,
|
||||||
@@ -569,14 +459,10 @@ impl SessionCache {
|
|||||||
if seq < state.open_end {
|
if seq < state.open_end {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// A gap: what this instance has written covers up to `open_end`,
|
|
||||||
// and that is the name the chunk gets before a new one starts
|
|
||||||
// at the arriving seq.
|
|
||||||
let end = state.open_end;
|
let end = state.open_end;
|
||||||
self.close_open_chunk(state, end);
|
self.close_open_chunk(state, end);
|
||||||
}
|
}
|
||||||
fs::create_dir_all(&self.dir)?;
|
fs::create_dir_all(&self.dir)?;
|
||||||
// An open chunk left by an earlier instance, or by an earlier screen.
|
|
||||||
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
if seq < existing.end {
|
if seq < existing.end {
|
||||||
@@ -620,8 +506,6 @@ impl SessionCache {
|
|||||||
Ok(state.writer.as_mut())
|
Ok(state.writer.as_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames the open chunk to the range it turned out to cover, so it
|
|
||||||
/// stops being open.
|
|
||||||
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
||||||
let file = state.open_file.clone();
|
let file = state.open_file.clone();
|
||||||
close_writer(state);
|
close_writer(state);
|
||||||
@@ -635,31 +519,17 @@ impl SessionCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- failure -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Runs `body`, answering `if_broken` when the directory cannot give a
|
|
||||||
/// real answer. None of this is reported on screen: every read here has
|
|
||||||
/// a network path beside it producing the same result, and the reader
|
|
||||||
/// has nothing to do about it. Damage discards this session's cache,
|
|
||||||
/// which makes the next open an ordinary cold one.
|
|
||||||
fn guard<T>(
|
fn guard<T>(
|
||||||
&self,
|
&self,
|
||||||
if_broken: T,
|
if_broken: T,
|
||||||
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
||||||
) -> T {
|
) -> T {
|
||||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
// A disk that refused once will refuse again, once per delta, so
|
|
||||||
// the first refusal is also the last.
|
|
||||||
if state.disabled {
|
if state.disabled {
|
||||||
return if_broken;
|
return if_broken;
|
||||||
}
|
}
|
||||||
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
||||||
let result = body(self, &mut state);
|
let result = body(self, &mut state);
|
||||||
// Damage takes priority over whatever `body` returned, `Ok` or
|
|
||||||
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
|
|
||||||
// precisely so this check catches it before the branch below
|
|
||||||
// mistakes it for a real I/O failure and disables the whole cache
|
|
||||||
// over one corrupt session.
|
|
||||||
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
||||||
(self.warn)(&format!(
|
(self.warn)(&format!(
|
||||||
"transcript cache damaged at {}; discarding {}",
|
"transcript cache damaged at {}; discarding {}",
|
||||||
@@ -683,11 +553,6 @@ impl SessionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// How [`SessionCache::each_line`] reports a line it cannot make sense
|
|
||||||
/// of back up to [`SessionCache::guard`], since the callback it hands
|
|
||||||
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
|
|
||||||
/// rather than a field: the guard that reads it always runs on the same
|
|
||||||
/// call stack that could have set it, one `guard` call at a time.
|
|
||||||
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
|
|||||||
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
|
|
||||||
/// not ours.
|
|
||||||
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||||
let rest = name.strip_suffix(".jsonl")?;
|
let rest = name.strip_suffix(".jsonl")?;
|
||||||
let (rest, kind) = rest.rsplit_once('.')?;
|
let (rest, kind) = rest.rsplit_once('.')?;
|
||||||
@@ -732,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One line's sequence number, or `None` when the line is not one of ours.
|
/// One line's sequence number, or `None` when the line is not one of ours.
|
||||||
///
|
|
||||||
/// A hand-rolled scan rather than a JSON parse, so this module carries no
|
|
||||||
/// parser and stays testable with no server: the seq is the first field the
|
|
||||||
/// server writes, so the first match is the top-level one.
|
|
||||||
pub fn seq_of(line: &str) -> Option<u64> {
|
pub fn seq_of(line: &str) -> Option<u64> {
|
||||||
find_number_field(line, "seq")
|
find_number_field(line, "seq")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a line is one streamed piece of a reply, which is what makes a
|
|
||||||
/// run of them one row.
|
|
||||||
pub fn is_delta(line: &str) -> bool {
|
pub fn is_delta(line: &str) -> bool {
|
||||||
find_string_field(line, "type").as_deref() == Some("assistantText")
|
find_string_field(line, "type").as_deref() == Some("assistantText")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `"key":N` (any amount of whitespace around the colon), or
|
|
||||||
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
|
|
||||||
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||||
let pattern = format!("\"{key}\"");
|
let pattern = format!("\"{key}\"");
|
||||||
let at = line.find(&pattern)?;
|
let at = line.find(&pattern)?;
|
||||||
@@ -765,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `"key":"..."`, or `None`. Mirrors
|
|
||||||
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
|
|
||||||
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||||
let pattern = format!("\"{key}\"");
|
let pattern = format!("\"{key}\"");
|
||||||
let at = line.find(&pattern)?;
|
let at = line.find(&pattern)?;
|
||||||
@@ -777,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
|
|||||||
Some(after_quote[..end].to_string())
|
Some(after_quote[..end].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of a file is read at a time when walking it backwards. One
|
|
||||||
/// block covers a page of a transcript comfortably, and the walk stops as
|
|
||||||
/// soon as the caller has what it asked for.
|
|
||||||
const READ_BLOCK: usize = 64 * 1024;
|
const READ_BLOCK: usize = 64 * 1024;
|
||||||
|
|
||||||
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
||||||
/// along with the byte offset it starts at, until `on_line` answers false.
|
/// along with the byte offset it starts at, until `on_line` answers false.
|
||||||
///
|
///
|
||||||
/// Every question the cache is asked is about the newest end of a chunk,
|
|
||||||
/// and a live run reaches the size of the conversation, so reading forwards
|
|
||||||
/// means reading a transcript to answer with the last eighty lines of it.
|
|
||||||
///
|
|
||||||
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
||||||
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
||||||
/// missing file yields nothing.
|
/// missing file yields nothing.
|
||||||
@@ -811,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
|||||||
}
|
}
|
||||||
let mut buffer = block;
|
let mut buffer = block;
|
||||||
buffer.extend_from_slice(&pending);
|
buffer.extend_from_slice(&pending);
|
||||||
// `buffer` is now `block` followed by `pending`; walk it backwards.
|
|
||||||
let mut line_end = buffer.len();
|
let mut line_end = buffer.len();
|
||||||
let mut at = buffer.len() as isize - 1;
|
let mut at = buffer.len() as isize - 1;
|
||||||
while at >= 0 {
|
while at >= 0 {
|
||||||
@@ -828,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
|||||||
pending = buffer[..line_end].to_vec();
|
pending = buffer[..line_end].to_vec();
|
||||||
unread = start;
|
unread = start;
|
||||||
}
|
}
|
||||||
// The first line of a file has no newline before it to be found.
|
|
||||||
let first = String::from_utf8_lossy(&pending);
|
let first = String::from_utf8_lossy(&pending);
|
||||||
if !first.trim().is_empty() {
|
if !first.trim().is_empty() {
|
||||||
on_line(0, &first);
|
on_line(0, &first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a final line that is not one of ours, by truncating the file to
|
|
||||||
/// where it starts.
|
|
||||||
///
|
|
||||||
/// This app having died mid-write is the one kind of damage that is
|
|
||||||
/// repaired rather than discarded: the tail of an append-only file is the
|
|
||||||
/// only place a partial line can be. A second bad line is not this, and is
|
|
||||||
/// left for the read path to notice.
|
|
||||||
fn repair_tail(file: &Path) -> io::Result<()> {
|
fn repair_tail(file: &Path) -> io::Result<()> {
|
||||||
let mut truncate_to: Option<u64> = None;
|
let mut truncate_to: Option<u64> = None;
|
||||||
each_line_backwards(file, |offset, line| {
|
each_line_backwards(file, |offset, line| {
|
||||||
@@ -857,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `body`, translating an I/O or permission failure into `if_broken`
|
|
||||||
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
|
|
||||||
/// [`TranscriptCache`]'s own maintenance.
|
|
||||||
fn guard_io<T>(
|
fn guard_io<T>(
|
||||||
if_broken: T,
|
if_broken: T,
|
||||||
warn: &(impl Fn(&str) + ?Sized),
|
warn: &(impl Fn(&str) + ?Sized),
|
||||||
@@ -874,10 +708,6 @@ fn guard_io<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a path's modified time, without pulling in a crate for it: a single
|
|
||||||
/// `utimensat`-backed call would be one more platform-specific dependency
|
|
||||||
/// for one call site, so this touches the file instead, which every
|
|
||||||
/// filesystem this runs on updates the mtime for.
|
|
||||||
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
// Rewriting a marker file's contents (rather than the directory itself,
|
// Rewriting a marker file's contents (rather than the directory itself,
|
||||||
@@ -908,7 +738,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `cache`, but also hands back the messages it warned with.
|
|
||||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
||||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
||||||
let said2 = said.clone();
|
let said2 = said.clone();
|
||||||
@@ -984,8 +813,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
||||||
// More than there is is what there is, which is a short opening
|
|
||||||
// window and not a failure.
|
|
||||||
assert_eq!(session.newest(80).len(), 3);
|
assert_eq!(session.newest(80).len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,8 +824,6 @@ mod tests {
|
|||||||
for seq in 1..=3u64 {
|
for seq in 1..=3u64 {
|
||||||
session.append(&tool_line(seq), seq);
|
session.append(&tool_line(seq), seq);
|
||||||
}
|
}
|
||||||
// What a `reset` looks like from here: the next event is not the
|
|
||||||
// one after the last.
|
|
||||||
session.append(&tool_line(90), 90);
|
session.append(&tool_line(90), 90);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
@@ -1040,14 +865,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// Adjacent: its end is the open chunk's first.
|
|
||||||
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
||||||
assert!(session.store_page(&page, 60, 100, true));
|
assert!(session.store_page(&page, 60, 100, true));
|
||||||
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
||||||
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
||||||
|
|
||||||
// Behind a gap: kept on disk, because paging usually closes the
|
|
||||||
// gap, but never served across it.
|
|
||||||
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
||||||
assert!(session.store_page(&page2, 1, 10, true));
|
assert!(session.store_page(&page2, 1, 10, true));
|
||||||
assert_eq!(session.page(10, 5, false), None);
|
assert_eq!(session.page(10, 5, false), None);
|
||||||
@@ -1083,8 +905,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// At or below where the run starts, so what the reader is
|
|
||||||
// scrolling into is the server's.
|
|
||||||
assert_eq!(session.page(100, 40, true), None);
|
assert_eq!(session.page(100, 40, true), None);
|
||||||
assert_eq!(session.page(40, 40, true), None);
|
assert_eq!(session.page(40, 40, true), None);
|
||||||
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
||||||
@@ -1109,8 +929,6 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let cache = cache(temp.path());
|
let cache = cache(temp.path());
|
||||||
let session = cache.session("s");
|
let session = cache.session("s");
|
||||||
// Two replies of three deltas each, split by a tool call: the same
|
|
||||||
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
|
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
delta(1),
|
delta(1),
|
||||||
delta(2),
|
delta(2),
|
||||||
@@ -1125,14 +943,8 @@ mod tests {
|
|||||||
session.append(&tool_line(9), 9);
|
session.append(&tool_line(9), 9);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// Three rows: the tool call at 8, the run 5..7, and the tool call
|
|
||||||
// at 4. The cut lands between rows, so the older run is not
|
|
||||||
// started.
|
|
||||||
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
||||||
// One row is one whole run, however many deltas it is made of.
|
|
||||||
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
||||||
// A page of lines counts lines, which is what the anchor restore
|
|
||||||
// asks for.
|
|
||||||
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,10 +963,7 @@ mod tests {
|
|||||||
session.append(&tool_line(10), 10);
|
session.append(&tool_line(10), 10);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// A run straddling the boundary is one row, as it will be once folded.
|
|
||||||
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
||||||
// Asking for more rows than the suffix holds is a short page, not a
|
|
||||||
// failure and not a claim that the conversation starts here.
|
|
||||||
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,13 +987,9 @@ mod tests {
|
|||||||
session.append(&tool_line(90), 90);
|
session.append(&tool_line(90), 90);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// The run behind the gap, which is what makes the fetched page
|
|
||||||
// adjacent to it.
|
|
||||||
assert_eq!(session.covered_up_to(90), Some(40));
|
assert_eq!(session.covered_up_to(90), Some(40));
|
||||||
assert_eq!(session.covered_up_to(41), Some(40));
|
assert_eq!(session.covered_up_to(41), Some(40));
|
||||||
assert_eq!(session.covered_up_to(10), Some(10));
|
assert_eq!(session.covered_up_to(10), Some(10));
|
||||||
// Nothing at or below the oldest chunk's start, so the page is
|
|
||||||
// bounded only by its limit.
|
|
||||||
assert_eq!(session.covered_up_to(9), None);
|
assert_eq!(session.covered_up_to(9), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1200,9 +1005,6 @@ mod tests {
|
|||||||
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only reachable by dying between closing one live run and opening
|
|
||||||
// the next, and there is no cursor to be read off a coalesced line
|
|
||||||
// -- so the open is a cold one.
|
|
||||||
assert_eq!(session.tail(), None);
|
assert_eq!(session.tail(), None);
|
||||||
assert!(!dir_of(temp.path(), "s").exists());
|
assert!(!dir_of(temp.path(), "s").exists());
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1033,6 @@ mod tests {
|
|||||||
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
||||||
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
||||||
);
|
);
|
||||||
// And the run continues from where the good tail left off.
|
|
||||||
session.append(&tool_line(3), 3);
|
session.append(&tool_line(3), 3);
|
||||||
session.flush();
|
session.flush();
|
||||||
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
||||||
@@ -1249,7 +1050,6 @@ mod tests {
|
|||||||
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Not seen by the tail, which reads the newest line and stops.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session.tail(),
|
session.tail(),
|
||||||
Some(CachedTail {
|
Some(CachedTail {
|
||||||
@@ -1257,8 +1057,6 @@ mod tests {
|
|||||||
line: tool_line(3)
|
line: tool_line(3)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Reached by a read that walks past it: what is served is nothing,
|
|
||||||
// and the session opens cold from here on.
|
|
||||||
assert_eq!(session.newest(80), Vec::<String>::new());
|
assert_eq!(session.newest(80), Vec::<String>::new());
|
||||||
assert!(!dir_of(temp.path(), "s").exists());
|
assert!(!dir_of(temp.path(), "s").exists());
|
||||||
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
||||||
@@ -1286,9 +1084,6 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let cache = cache(temp.path());
|
let cache = cache(temp.path());
|
||||||
let session = cache.session("s");
|
let session = cache.session("s");
|
||||||
// Well past the 64 kB block the backwards reader takes at a time,
|
|
||||||
// so a page has to be stitched across several of them -- including
|
|
||||||
// a line that straddles a boundary.
|
|
||||||
let padding = "x".repeat(300);
|
let padding = "x".repeat(300);
|
||||||
let lines: Vec<String> = (1..=500u64)
|
let lines: Vec<String> = (1..=500u64)
|
||||||
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
||||||
@@ -1298,8 +1093,6 @@ mod tests {
|
|||||||
assert_eq!(session.tail().unwrap().seq, 500);
|
assert_eq!(session.tail().unwrap().seq, 500);
|
||||||
assert_eq!(session.newest(80), lines[420..].to_vec());
|
assert_eq!(session.newest(80), lines[420..].to_vec());
|
||||||
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
||||||
// And a non-ASCII line, whose bytes a naive split could cut through
|
|
||||||
// a character.
|
|
||||||
let accented =
|
let accented =
|
||||||
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
||||||
session.append(&accented, 501);
|
session.append(&accented, 501);
|
||||||
@@ -1321,15 +1114,10 @@ mod tests {
|
|||||||
let when =
|
let when =
|
||||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
||||||
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
||||||
// The mtime touch above always sets "now", not `when` (see its
|
|
||||||
// own doc) -- space the three writes out in real time instead,
|
|
||||||
// since only relative order matters to eviction.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
let each = dir_size(&dir_of(temp.path(), "old"));
|
let each = dir_size(&dir_of(temp.path(), "old"));
|
||||||
|
|
||||||
// Room for two of the three, so the oldest goes -- and the session
|
|
||||||
// being read never does, however long ago it was last touched.
|
|
||||||
cache.evict_to_budget("open", each * 2);
|
cache.evict_to_budget("open", each * 2);
|
||||||
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1382,8 +1170,6 @@ mod tests {
|
|||||||
session.purge();
|
session.purge();
|
||||||
assert_eq!(session.bytes(), 0);
|
assert_eq!(session.bytes(), 0);
|
||||||
assert_eq!(session.tail(), None);
|
assert_eq!(session.tail(), None);
|
||||||
// And the session is usable again straight afterwards, which is
|
|
||||||
// what a reload does next.
|
|
||||||
session.append(&tool_line(9), 9);
|
session.append(&tool_line(9), 9);
|
||||||
session.flush();
|
session.flush();
|
||||||
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
||||||
@@ -1,32 +1,5 @@
|
|||||||
//! What the transcript renders: the event stream folded into displayable
|
|
||||||
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
|
||||||
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
|
||||||
//!
|
|
||||||
//! Events are the only data source, and there is deliberately no second
|
|
||||||
//! shape for history to drift from: a page fetched backwards, a live
|
|
||||||
//! frame, and a line read out of the transcript cache are all the same
|
|
||||||
//! events through the same fold.
|
|
||||||
//!
|
|
||||||
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
|
||||||
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
|
||||||
//! exists to bound how much a lazy list composes per frame, which is a
|
|
||||||
//! fact about the UI framework drawing it, not about the transcript. See
|
|
||||||
//! `CLIENT_CORE.md`.
|
|
||||||
//!
|
|
||||||
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
|
||||||
//! deserializes straight into [`event_model::Event`], which has no
|
|
||||||
//! `Unknown` catch-all -- an event type this build does not recognise
|
|
||||||
//! fails to parse rather than degrading to a placeholder row. Closing that
|
|
||||||
//! gap means giving `event_model::Event` its own forward-compatible
|
|
||||||
//! variant, which is a shared-model decision for both sides of the wire
|
|
||||||
//! and is deliberately left for whoever picks this up next (see
|
|
||||||
//! `CLIENT_CORE.md`).
|
|
||||||
|
|
||||||
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||||
|
|
||||||
/// A question this build has already asked the reader about, with what was
|
|
||||||
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
|
||||||
/// be chosen.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct QuestionCard {
|
pub struct QuestionCard {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
@@ -38,24 +11,14 @@ pub struct QuestionCard {
|
|||||||
pub answers: Vec<String>,
|
pub answers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
|
||||||
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
|
||||||
/// logic name it explicitly.
|
|
||||||
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||||
|
|
||||||
/// This item's identity in the list: a `Seq` for everything with no
|
|
||||||
/// identity of its own, `RunId` for a tool call (which keeps one across
|
|
||||||
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
|
||||||
/// in the Kotlin original.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub enum ItemKey {
|
pub enum ItemKey {
|
||||||
Seq(u64),
|
Seq(u64),
|
||||||
RunId(String),
|
RunId(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
|
||||||
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
|
||||||
/// doc only says what changed in translation.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum TranscriptItem {
|
pub enum TranscriptItem {
|
||||||
UserMsg {
|
UserMsg {
|
||||||
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
|
|||||||
AssistantMsg {
|
AssistantMsg {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
|
||||||
/// Kotlin doc for why the split it licenses matters.
|
|
||||||
settled: bool,
|
settled: bool,
|
||||||
},
|
},
|
||||||
ToolRun {
|
ToolRun {
|
||||||
@@ -78,6 +39,11 @@ pub enum TranscriptItem {
|
|||||||
input: String,
|
input: String,
|
||||||
output: String,
|
output: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
/// Whether the result that arrived said the call failed
|
||||||
|
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||||
|
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||||
|
/// pair, so the two cannot be combined wrongly at a call site.
|
||||||
|
failed: bool,
|
||||||
asks: Vec<QuestionCard>,
|
asks: Vec<QuestionCard>,
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -90,9 +56,6 @@ pub enum TranscriptItem {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
r#ref: String,
|
r#ref: String,
|
||||||
},
|
},
|
||||||
/// A message from another agent. `arrived` is this row's own identity
|
|
||||||
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
|
||||||
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
|
||||||
PeerNote {
|
PeerNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
from: String,
|
from: String,
|
||||||
@@ -103,8 +66,6 @@ pub enum TranscriptItem {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// Placeholder for an event kind this build could not fold -- see the
|
|
||||||
/// module doc's "known gap".
|
|
||||||
Note {
|
Note {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
@@ -112,6 +73,13 @@ pub enum TranscriptItem {
|
|||||||
ClearedNote {
|
ClearedNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
},
|
},
|
||||||
|
/// The account's usage limit stopped the turn; `resets_at` is epoch
|
||||||
|
/// seconds when the dialect said when it lifts (`LimitNote` in
|
||||||
|
/// `TranscriptItems.kt`).
|
||||||
|
LimitNote {
|
||||||
|
seq: u64,
|
||||||
|
resets_at: Option<f64>,
|
||||||
|
},
|
||||||
CompactedNote {
|
CompactedNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
pre_tokens: Option<u64>,
|
pre_tokens: Option<u64>,
|
||||||
@@ -131,6 +99,7 @@ impl TranscriptItem {
|
|||||||
| Self::CommandRow { seq, .. }
|
| Self::CommandRow { seq, .. }
|
||||||
| Self::Note { seq, .. }
|
| Self::Note { seq, .. }
|
||||||
| Self::ClearedNote { seq }
|
| Self::ClearedNote { seq }
|
||||||
|
| Self::LimitNote { seq, .. }
|
||||||
| Self::CompactedNote { seq, .. } => *seq,
|
| Self::CompactedNote { seq, .. } => *seq,
|
||||||
Self::QuestionCard(card) => card.seq,
|
Self::QuestionCard(card) => card.seq,
|
||||||
}
|
}
|
||||||
@@ -188,14 +157,10 @@ fn update_tool(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a status means the session is still doing something, mirroring
|
|
||||||
/// `sessionWorking` in `Events.kt`.
|
|
||||||
pub fn session_working(status: SessionStatus) -> bool {
|
pub fn session_working(status: SessionStatus) -> bool {
|
||||||
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A status saying the session stopped working is the moment its newest
|
|
||||||
/// reply is finished.
|
|
||||||
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||||
if session_working(status) {
|
if session_working(status) {
|
||||||
return items.to_vec();
|
return items.to_vec();
|
||||||
@@ -210,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A peer message goes above the turn it started, not where it happened to
|
|
||||||
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
|
||||||
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
|
||||||
fn place_peer_note(
|
fn place_peer_note(
|
||||||
items: &[TranscriptItem],
|
items: &[TranscriptItem],
|
||||||
seq: u64,
|
seq: u64,
|
||||||
@@ -251,8 +213,6 @@ fn place_peer_note(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The calls the note now sits in front of, renamed if they were sharing a
|
|
||||||
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
|
||||||
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||||
let Some(TranscriptItem::ToolRun {
|
let Some(TranscriptItem::ToolRun {
|
||||||
run_id: first_run_id,
|
run_id: first_run_id,
|
||||||
@@ -286,10 +246,160 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||||
/// at the parse layer).
|
/// -- correctly, because a call that renders as nothing is indistinguishable
|
||||||
|
/// from one that never happened. When the older page arrives it brings the
|
||||||
|
/// real `ToolStart`, and concatenating the two lists left *both*: the same
|
||||||
|
/// call twice.
|
||||||
|
///
|
||||||
|
/// Merged by the call's own id rather than by position, because position is
|
||||||
|
/// exactly what a page boundary destroys. The older row wins on what a
|
||||||
|
/// start knows and the newer on what an end knows, which is the only way
|
||||||
|
/// round that loses nothing.
|
||||||
|
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
|
let (older, newer) = heal_split_message(earlier, later);
|
||||||
|
let started_earlier: std::collections::HashSet<&str> = older
|
||||||
|
.iter()
|
||||||
|
.filter_map(TranscriptItem::as_tool_run)
|
||||||
|
.collect();
|
||||||
|
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||||
|
.filter(|(id, _)| started_earlier.contains(id.as_str()))
|
||||||
|
.collect();
|
||||||
|
let healed: Vec<TranscriptItem> = older
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| match row {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
asks: row_asks,
|
||||||
|
images: row_images,
|
||||||
|
..
|
||||||
|
} if ended_later.contains_key(id.as_str()) => {
|
||||||
|
let &TranscriptItem::ToolRun {
|
||||||
|
ref output,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
asks: ref half_asks,
|
||||||
|
images: ref half_images,
|
||||||
|
..
|
||||||
|
} = &ended_later[id.as_str()]
|
||||||
|
else {
|
||||||
|
unreachable!("filtered to ToolRun above");
|
||||||
|
};
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output: output.clone(),
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
||||||
|
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let kept: Vec<TranscriptItem> = newer
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| match item.as_tool_run() {
|
||||||
|
Some(id) => !ended_later.contains_key(id),
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut out = adopt_run(&healed, &kept);
|
||||||
|
out.extend(kept);
|
||||||
|
// What this function exists to prevent, checked rather than assumed: the same
|
||||||
|
// call drawn twice, once from the page that saw its start and once from the page
|
||||||
|
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
|
||||||
|
// seq its turn began at, which can be older than the page it arrived in, so the
|
||||||
|
// two pages' seqs legitimately interleave at the boundary.
|
||||||
|
debug_assert!(
|
||||||
|
{
|
||||||
|
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
|
||||||
|
let before = ids.len();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
ids.len() == before
|
||||||
|
},
|
||||||
|
"join_pages left the same tool call in both halves"
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fold_event` never leaves two assistant messages next to each other
|
||||||
|
/// inside one page, so two meeting at a join are always the two halves of
|
||||||
|
/// one reply, and leaving them apart drew a single answer as two with a
|
||||||
|
/// paragraph break through the middle of a sentence.
|
||||||
|
fn heal_split_message(
|
||||||
|
earlier: &[TranscriptItem],
|
||||||
|
later: &[TranscriptItem],
|
||||||
|
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
|
||||||
|
let (
|
||||||
|
Some(TranscriptItem::AssistantMsg {
|
||||||
|
text: head_text, ..
|
||||||
|
}),
|
||||||
|
Some(TranscriptItem::AssistantMsg {
|
||||||
|
seq: tail_seq,
|
||||||
|
text: tail_text,
|
||||||
|
settled: tail_settled,
|
||||||
|
}),
|
||||||
|
) = (earlier.last(), later.first())
|
||||||
|
else {
|
||||||
|
return (earlier.to_vec(), later.to_vec());
|
||||||
|
};
|
||||||
|
let merged = TranscriptItem::AssistantMsg {
|
||||||
|
seq: *tail_seq,
|
||||||
|
text: format!("{head_text}{tail_text}"),
|
||||||
|
settled: *tail_settled,
|
||||||
|
};
|
||||||
|
let mut newer = vec![merged];
|
||||||
|
newer.extend(later[1..].iter().cloned());
|
||||||
|
(earlier[..earlier.len() - 1].to_vec(), newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
|
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||||
|
return earlier.to_vec();
|
||||||
|
};
|
||||||
|
if tool == ASK_USER_QUESTION {
|
||||||
|
return earlier.to_vec();
|
||||||
|
}
|
||||||
|
let joining = run_id.clone();
|
||||||
|
let tail_len = earlier
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
|
||||||
|
.count();
|
||||||
|
if tail_len == 0 {
|
||||||
|
return earlier.to_vec();
|
||||||
|
}
|
||||||
|
let split = earlier.len() - tail_len;
|
||||||
|
let mut out = earlier[..split].to_vec();
|
||||||
|
out.extend(earlier[split..].iter().cloned().map(|mut item| {
|
||||||
|
// `take_while` above already restricted this slice to non-question tool calls;
|
||||||
|
// this just guards the invariant rather than trusting it silently.
|
||||||
|
debug_assert!(
|
||||||
|
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
|
||||||
|
"adopt_run must never rename a question's own run"
|
||||||
|
);
|
||||||
|
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
|
||||||
|
*run_id = joining.clone();
|
||||||
|
}
|
||||||
|
item
|
||||||
|
}));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||||
let seq = entry.seq;
|
let seq = entry.seq;
|
||||||
match &entry.event {
|
match &entry.event {
|
||||||
@@ -304,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
});
|
});
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
// `MessageTaken` is folded into `UserMessage` by the manager before
|
|
||||||
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
|
||||||
// (a raw transcript line, say), it reads the same way.
|
|
||||||
Event::MessageTaken {
|
Event::MessageTaken {
|
||||||
text, attachments, ..
|
text, attachments, ..
|
||||||
} => {
|
} => {
|
||||||
@@ -319,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
Event::AssistantText { delta } => {
|
Event::AssistantText { delta } => {
|
||||||
// Deltas accumulate into the message they're streaming, which
|
|
||||||
// keeps the seq of the *first* of them: a row whose identity
|
|
||||||
// changed with every delta would be a new row every frame.
|
|
||||||
// "A message growing again is not finished" -- whatever a
|
|
||||||
// status said in between -- is why this always clears
|
|
||||||
// `settled` rather than preserving it.
|
|
||||||
if let Some(TranscriptItem::AssistantMsg {
|
if let Some(TranscriptItem::AssistantMsg {
|
||||||
seq: first_seq,
|
seq: first_seq,
|
||||||
text,
|
text,
|
||||||
@@ -361,6 +462,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -371,15 +473,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Event::ToolEnd { id, output } => {
|
Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
update_tool(items, id, |item| {
|
update_tool(items, id, |item| {
|
||||||
if let TranscriptItem::ToolRun {
|
if let TranscriptItem::ToolRun {
|
||||||
output: out, done, ..
|
output: out,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
*done = true;
|
*done = true;
|
||||||
|
*failed = *is_error;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -393,6 +503,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: *is_error,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -449,6 +560,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
images,
|
images,
|
||||||
} if asks.iter().any(|a| &a.id == id) => {
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
for ask in asks.iter_mut() {
|
for ask in asks.iter_mut() {
|
||||||
@@ -464,6 +576,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -484,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
});
|
});
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
// Screen-level state, not transcript rows.
|
|
||||||
Event::CommandQueued { .. }
|
Event::CommandQueued { .. }
|
||||||
| Event::MessageQueued { .. }
|
| Event::MessageQueued { .. }
|
||||||
| Event::MessageDropped { .. }
|
| Event::MessageDropped { .. }
|
||||||
@@ -525,6 +637,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
items.push(TranscriptItem::ClearedNote { seq });
|
items.push(TranscriptItem::ClearedNote { seq });
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
Event::LimitReached { resets_at } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::LimitNote {
|
||||||
|
seq,
|
||||||
|
resets_at: *resets_at,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
Event::Compacted {
|
Event::Compacted {
|
||||||
pre_tokens,
|
pre_tokens,
|
||||||
post_tokens,
|
post_tokens,
|
||||||
@@ -541,14 +661,55 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
/// and a call whose result never arrived both leave an empty `output`,
|
||||||
/// of this crate.
|
/// and drawing them the same way states a verdict nobody reached: "it
|
||||||
|
/// worked and said nothing" reads as a fact, where the truth is that the
|
||||||
|
/// turn ended before anything came back.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolState {
|
||||||
|
Running,
|
||||||
|
/// Stopped on the reader: a permission or question this call carries
|
||||||
|
/// has not been answered, so nothing is happening until somebody
|
||||||
|
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||||
|
/// is differs, which is the Compose card's "your turn".
|
||||||
|
Deciding,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
NoResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolState {
|
||||||
|
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
done, failed, asks, ..
|
||||||
|
} = item
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
debug_assert!(
|
||||||
|
!failed || *done,
|
||||||
|
"a call cannot have failed before its result arrived"
|
||||||
|
);
|
||||||
|
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||||
|
Self::Deciding
|
||||||
|
} else if !*done {
|
||||||
|
match session_working {
|
||||||
|
true => Self::Running,
|
||||||
|
false => Self::NoResult,
|
||||||
|
}
|
||||||
|
} else if *failed {
|
||||||
|
Self::Failed
|
||||||
|
} else {
|
||||||
|
Self::Succeeded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum TranscriptRow {
|
pub enum TranscriptRow {
|
||||||
Single(TranscriptItem),
|
Single(TranscriptItem),
|
||||||
/// Two or more calls with nothing between them.
|
|
||||||
Tools(Vec<TranscriptItem>),
|
Tools(Vec<TranscriptItem>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,9 +729,6 @@ impl TranscriptRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs of adjacent tool calls become one row; everything else passes
|
|
||||||
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
|
||||||
/// run each call names rather than by adjacency worked out here.
|
|
||||||
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut run: Vec<TranscriptItem> = Vec::new();
|
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||||
@@ -606,6 +764,21 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for value in values {
|
||||||
|
let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| {
|
||||||
|
format!("the server sent a transcript line this build couldn't parse: {e}")
|
||||||
|
})?;
|
||||||
|
items = fold_event(&items, &event);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||||
|
value.get("seq")?.as_u64()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -749,6 +922,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "x".to_string(),
|
id: "x".to_string(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -761,6 +935,7 @@ mod tests {
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}]
|
}]
|
||||||
@@ -824,4 +999,339 @@ mod tests {
|
|||||||
other => panic!("expected a QuestionCard, got {other:?}"),
|
other => panic!("expected a QuestionCard, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn line(seq: u64, json: serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut obj = json;
|
||||||
|
obj["seq"] = serde_json::json!(seq);
|
||||||
|
obj["ts"] = serde_json::json!(1.0);
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "a"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "b"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
4,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "c"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let after = raw_seq(values.last().unwrap()).unwrap();
|
||||||
|
assert_eq!(after, 4);
|
||||||
|
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
let assistant_seq = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| matches!(i, TranscriptItem::AssistantMsg { .. }))
|
||||||
|
.unwrap()
|
||||||
|
.seq();
|
||||||
|
assert_eq!(assistant_seq, 2);
|
||||||
|
assert_ne!(
|
||||||
|
after, assistant_seq,
|
||||||
|
"the fixed bug: these must differ here"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_folds_into_one_settled_assistant_message() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "hel"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "lo"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
settled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_line_fails_the_whole_page() {
|
||||||
|
let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})];
|
||||||
|
let err = fold_page(&values).unwrap_err();
|
||||||
|
assert!(err.contains("couldn't parse"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
seq,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
seq,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
||||||
|
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
||||||
|
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
let run_ids: Vec<_> = joined
|
||||||
|
.iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
|
||||||
|
other => panic!("expected only ToolRun items, got {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
run_ids,
|
||||||
|
vec!["b", "b"],
|
||||||
|
"the older call must adopt the newer, already-on-screen run's name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_split_across_the_boundary_merges_into_one_row() {
|
||||||
|
let older = fold_all(&[tool_start(1, "x", "Bash")]);
|
||||||
|
let newer = fold_all(&[tool_end(2, "x", "the result")]);
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
assert_eq!(
|
||||||
|
joined,
|
||||||
|
vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 1,
|
||||||
|
id: "x".to_string(),
|
||||||
|
run_id: "x".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
output: "the result".to_string(),
|
||||||
|
done: true,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}],
|
||||||
|
"the older half's tool/input and the newer half's output/done must both survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
|
||||||
|
let older = vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "Hel".to_string(),
|
||||||
|
settled: false,
|
||||||
|
}];
|
||||||
|
let newer = vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "lo".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 3,
|
||||||
|
text: "next".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
assert_eq!(
|
||||||
|
joined,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "Hello".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 3,
|
||||||
|
text: "next".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adopt_run_never_renames_into_a_question_row() {
|
||||||
|
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
||||||
|
let newer = vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: "q".to_string(),
|
||||||
|
run_id: "q".to_string(),
|
||||||
|
tool: ASK_USER_QUESTION.to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}];
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
match &joined[0] {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
|
||||||
|
other => panic!("expected a ToolRun, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tool_state_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, e: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 0.0,
|
||||||
|
event: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(id: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({"command": "ls"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
|
||||||
|
let items = fold_all(events);
|
||||||
|
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_arrived_is_read_from_is_error() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "ok", false)], false),
|
||||||
|
ToolState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "No such file", true)], false),
|
||||||
|
ToolState::Failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "", false)], false),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"a result arrived; it was empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a")], false),
|
||||||
|
ToolState::NoResult,
|
||||||
|
"no result, and the session is not working any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_result_while_the_session_works_is_still_running() {
|
||||||
|
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
|
||||||
|
let asking = event(
|
||||||
|
3,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "Allow?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption {
|
||||||
|
label: "Allow".to_string(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let answered = event(
|
||||||
|
4,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
answers: vec!["Allow".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], true),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], false),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(
|
||||||
|
&[start("a"), asking, answered, end("a", "ok", false)],
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"once it is answered the call is an ordinary one again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_a_tool_call_has_a_tool_state() {
|
||||||
|
assert_eq!(
|
||||||
|
ToolState::of(
|
||||||
|
&TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
|
use crate::client::api::{ApiClient, ApiError, Transport};
|
||||||
|
use crate::client::event_stream::{self, StreamItem};
|
||||||
|
use crate::client::transcript_cache::SessionCache;
|
||||||
|
|
||||||
|
/// The server's own default page size, named here because the cached
|
||||||
|
/// opening has to be the same size as the fetched one -- a reader must not
|
||||||
|
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||||
|
/// in the Kotlin original).
|
||||||
|
pub const OPENING_WINDOW: u32 = 80;
|
||||||
|
|
||||||
|
/// A transcript-line parse failure, told apart from [`ApiError`] so a
|
||||||
|
/// caller can tell "the server is unreachable" from "the server (or this
|
||||||
|
/// phone's own disk) sent something this build cannot read" -- the two
|
||||||
|
/// mean different things to a reader (retry, versus a build that is
|
||||||
|
/// behind).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParseError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for ParseError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for ParseError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PageError {
|
||||||
|
Api(ApiError),
|
||||||
|
Parse(ParseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiError> for PageError {
|
||||||
|
fn from(e: ApiError) -> Self {
|
||||||
|
Self::Api(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseError> for PageError {
|
||||||
|
fn from(e: ParseError) -> Self {
|
||||||
|
Self::Parse(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum OlderPage {
|
||||||
|
Events(Vec<SeqEvent>),
|
||||||
|
NothingLoaded,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||||
|
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TranscriptSource<T: Transport> {
|
||||||
|
api: ApiClient<T>,
|
||||||
|
session_id: String,
|
||||||
|
pub cache: SessionCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> TranscriptSource<T> {
|
||||||
|
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
|
||||||
|
Self {
|
||||||
|
api,
|
||||||
|
session_id: session_id.into(),
|
||||||
|
cache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cached opening window, or `None` when there is nothing usable
|
||||||
|
/// to draw.
|
||||||
|
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||||
|
self.cache.tail()?;
|
||||||
|
let lines = self.cache.newest(limit);
|
||||||
|
if lines.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||||
|
Ok(events) => Some(events),
|
||||||
|
Err(ParseError(_)) => {
|
||||||
|
self.cache.purge();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A caller must not resume a live stream from a cached seq unless it
|
||||||
|
/// is the same conversation: a transcript is append-only in ordinary
|
||||||
|
/// use, but the file backing it can be replaced or truncated (a
|
||||||
|
/// sandbox re-seeded with the same ids, a backup restored, a session
|
||||||
|
/// re-imported), and the server's catch-up on such a file would hand
|
||||||
|
/// this phone a continuation of a *different* conversation, spliced
|
||||||
|
/// onto the cached one with no seam. Caught with one request of a few
|
||||||
|
/// hundred bytes.
|
||||||
|
///
|
||||||
|
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||||
|
/// server not being askable, which is neither: the cached rows stay
|
||||||
|
/// on screen and the caller tries again on its own reconnect schedule.
|
||||||
|
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||||
|
let Some(tail) = self.cache.tail() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let page = self.api.fetch_transcript_lines(
|
||||||
|
&self.session_id,
|
||||||
|
Some(tail.seq + 1),
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let matches = page.len() == 1
|
||||||
|
&& parse_line(&tail.line)
|
||||||
|
.map(|cached| cached == page[0].1)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !matches {
|
||||||
|
self.cache.purge();
|
||||||
|
}
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||||
|
let page =
|
||||||
|
self.api
|
||||||
|
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
|
||||||
|
for (line, event) in &page {
|
||||||
|
self.cache.append(line, event.seq);
|
||||||
|
}
|
||||||
|
self.cache.flush();
|
||||||
|
Ok(page.into_iter().map(|(_, event)| event).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page before `before`: from the cache when it holds it,
|
||||||
|
/// otherwise from the server bounded by what the cache already has.
|
||||||
|
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||||
|
if before == 0 {
|
||||||
|
return Ok(OlderPage::NothingLoaded);
|
||||||
|
}
|
||||||
|
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||||
|
let events: Vec<SeqEvent> = lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| parse_line(l).map_err(PageError::from))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
return Ok(OlderPage::Events(events));
|
||||||
|
}
|
||||||
|
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||||
|
let page = self.api.fetch_transcript_lines(
|
||||||
|
&self.session_id,
|
||||||
|
Some(before),
|
||||||
|
limit,
|
||||||
|
coalesce,
|
||||||
|
after,
|
||||||
|
)?;
|
||||||
|
if let Some((_, first_event)) = page.first() {
|
||||||
|
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||||
|
self.cache
|
||||||
|
.store_page(&lines, first_event.seq, before, coalesce);
|
||||||
|
}
|
||||||
|
Ok(OlderPage::Events(
|
||||||
|
page.into_iter().map(|(_, event)| event).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before, so that an event held back for a reader who is scrolled
|
||||||
|
/// away is already on disk -- what the cache holds is what the server
|
||||||
|
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||||
|
/// status change, which is a turn's boundary and the granularity a
|
||||||
|
/// crash may as well lose, and once more when the stream ends.
|
||||||
|
pub fn follow(
|
||||||
|
&self,
|
||||||
|
after: u64,
|
||||||
|
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let cache = &self.cache;
|
||||||
|
let result = event_stream::follow_session_events(
|
||||||
|
self.api.transport(),
|
||||||
|
&self.session_id,
|
||||||
|
after,
|
||||||
|
|item| {
|
||||||
|
if let StreamItem::Event { raw, event } = &item {
|
||||||
|
cache.append(raw, event.seq);
|
||||||
|
if matches!(event.event, event_model::Event::Status { .. }) {
|
||||||
|
cache.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on_item(item)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cache.flush();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaves the cache with everything it was given -- called once a
|
||||||
|
/// caller is done with this source, mirroring the Kotlin `close`'s
|
||||||
|
/// final flush (that method's stream cancellation itself is the
|
||||||
|
/// runtime concern the module doc says is not ported here).
|
||||||
|
pub fn close(&self) {
|
||||||
|
self.cache.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::api::{Body, RawResponse};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ScriptedTransport {
|
||||||
|
responses: Mutex<VecDeque<(u16, String)>>,
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedTransport {
|
||||||
|
fn respond(&self, status: u16, body: impl Into<String>) {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back((status, body.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_count(&self) -> usize {
|
||||||
|
self.calls.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for ScriptedTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
self.calls.lock().unwrap().push(path.to_string());
|
||||||
|
let (status, body) = self
|
||||||
|
.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
|
||||||
|
Ok(RawResponse {
|
||||||
|
status,
|
||||||
|
body: body.into_bytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||||
|
self.calls.lock().unwrap().push(path.to_string());
|
||||||
|
let (_, body) = self
|
||||||
|
.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("ScriptedTransport got an unscripted stream request: {path}")
|
||||||
|
});
|
||||||
|
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(
|
||||||
|
transport: ScriptedTransport,
|
||||||
|
cache_root: &std::path::Path,
|
||||||
|
) -> TranscriptSource<ScriptedTransport> {
|
||||||
|
let api = ApiClient::new(transport);
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||||
|
TranscriptSource::new(api, "s1", cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_line(seq: u64) -> String {
|
||||||
|
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
|
||||||
|
assert_eq!(source.cached_opening(80), None);
|
||||||
|
let opening = source.fetch_opening().unwrap();
|
||||||
|
assert_eq!(opening.len(), 1);
|
||||||
|
assert_eq!(opening[0].seq, 1);
|
||||||
|
assert!(source.cache.tail().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
assert!(source2.probe().unwrap());
|
||||||
|
assert!(source2.cache.tail().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_mismatching_the_cached_tail_purges_the_cache() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||||
|
transport2.respond(200, format!("[{different}]"));
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
assert!(!source2.probe().unwrap());
|
||||||
|
assert!(source2.cache.tail().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_finding_no_server_leaves_the_cache_untouched() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
transport2.respond(500, "server on fire");
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
assert!(source2.probe().is_err());
|
||||||
|
assert!(
|
||||||
|
source2.cache.tail().is_some(),
|
||||||
|
"an unreachable server must not be treated as a mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||||
|
assert_eq!(source.api.transport().call_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let calls_before = source.api.transport().call_count();
|
||||||
|
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||||
|
panic!("a cursor of 2 is a real question about the conversation");
|
||||||
|
};
|
||||||
|
assert_eq!(page.len(), 1);
|
||||||
|
assert_eq!(page[0].seq, 1);
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().call_count(),
|
||||||
|
calls_before,
|
||||||
|
"a cache hit must not touch the network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(5)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
source2.page(5, 10, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source2.api.transport().calls.lock().unwrap()[0],
|
||||||
|
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||||
|
assert!(cache.store_page(&lines, 3, 6, true));
|
||||||
|
cache.append(&status_line(6), 6);
|
||||||
|
cache.append(&status_line(7), 7);
|
||||||
|
cache.flush();
|
||||||
|
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(9)));
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
source.page(10, 10, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().calls.lock().unwrap()[0],
|
||||||
|
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||||
|
"the fetch must stop one seq below where this phone's copy ends"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(500, "server on fire");
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
cache.store_page(
|
||||||
|
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
cache.append(&status_line(4), 4);
|
||||||
|
cache.flush();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().call_count(),
|
||||||
|
0,
|
||||||
|
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
cache.append("not json at all", 1);
|
||||||
|
cache.flush();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
assert_eq!(source.cached_opening(80), None);
|
||||||
|
assert!(
|
||||||
|
source.cache.tail().is_none(),
|
||||||
|
"a damaged line purges the cache"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
source
|
||||||
|
.follow(0, |item| {
|
||||||
|
if let StreamItem::Event { event, .. } = item {
|
||||||
|
seen.push(event.seq);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seen, vec![1]);
|
||||||
|
assert_eq!(source.cache.tail().unwrap().seq, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_frame(data: &str) -> String {
|
||||||
|
format!("data:{data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
|
||||||
|
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||||
|
use crate::client::transcript_fold::{
|
||||||
|
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
||||||
|
};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
const LIST_WIDTH: f32 = 260.0;
|
||||||
|
|
||||||
|
enum AppEvent {
|
||||||
|
Sessions(Result<Vec<SessionSummary>, String>),
|
||||||
|
TranscriptLoaded {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
result: Result<Vec<TranscriptItem>, String>,
|
||||||
|
},
|
||||||
|
StreamEvent {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
event: SeqEvent,
|
||||||
|
},
|
||||||
|
StreamEnded {
|
||||||
|
session_id: String,
|
||||||
|
generation: u64,
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
SendFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
DefaultApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
struct Client {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
api: Arc<ApiClient<UreqTransport>>,
|
||||||
|
stream_transport: Arc<UreqTransport>,
|
||||||
|
proxy: Proxy<AppEvent>,
|
||||||
|
sessions: Vec<SessionSummary>,
|
||||||
|
selected: Option<String>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
list_ptr: WeakWidget<WidgetPtr>,
|
||||||
|
transcript_ptr: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
|
generation: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for Client {
|
||||||
|
type Event = AppEvent;
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
proxy: Proxy<AppEvent>,
|
||||||
|
) -> Self {
|
||||||
|
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
|
||||||
|
eprintln!("desktop-app: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
});
|
||||||
|
let build_transport =
|
||||||
|
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
|
||||||
|
let (rest_transport, stream_transport) = build_transport()
|
||||||
|
.and_then(|rest| build_transport().map(|stream| (rest, stream)))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"desktop-app: couldn't set up TLS to {}: {e}",
|
||||||
|
server.base_url()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
let api = Arc::new(ApiClient::new(rest_transport));
|
||||||
|
let stream_transport = Arc::new(stream_transport);
|
||||||
|
|
||||||
|
let list_ptr = WidgetPtr::new().add(rsc);
|
||||||
|
let transcript_ptr = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading sessions...");
|
||||||
|
transcript_ptr(rsc).set(loading);
|
||||||
|
|
||||||
|
(list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1)))
|
||||||
|
.span(Dir::RIGHT)
|
||||||
|
.set_root(rsc, &mut ui_state);
|
||||||
|
|
||||||
|
let client = Self {
|
||||||
|
ui_state,
|
||||||
|
api,
|
||||||
|
stream_transport,
|
||||||
|
proxy,
|
||||||
|
sessions: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
list_ptr,
|
||||||
|
transcript_ptr,
|
||||||
|
screen: None,
|
||||||
|
generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
};
|
||||||
|
client.spawn_fetch_sessions();
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc<Self>, _render: &mut UiRenderState) {
|
||||||
|
match event {
|
||||||
|
AppEvent::Sessions(Ok(sessions)) => {
|
||||||
|
self.sessions = sessions;
|
||||||
|
self.rebuild_list(rsc);
|
||||||
|
if self.selected.is_none() {
|
||||||
|
self.show_message(rsc, "Select a session.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::Sessions(Err(message)) => {
|
||||||
|
self.show_message(rsc, &format!("Couldn't list sessions: {message}"));
|
||||||
|
}
|
||||||
|
AppEvent::TranscriptLoaded {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
if self.current(&session_id, generation) {
|
||||||
|
match result {
|
||||||
|
Ok(items) => {
|
||||||
|
self.items = items;
|
||||||
|
self.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
self.show_message(
|
||||||
|
rsc,
|
||||||
|
&format!("Couldn't load {session_id}: {message}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::StreamEvent {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
event,
|
||||||
|
} => {
|
||||||
|
if self.current(&session_id, generation) {
|
||||||
|
let old_items = self.items.clone();
|
||||||
|
self.items = fold_event(&self.items, &event);
|
||||||
|
match &self.screen {
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||||
|
None => self.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::StreamEnded {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
message: Some(message),
|
||||||
|
} => {
|
||||||
|
if self.current(&session_id, generation) {
|
||||||
|
eprintln!("desktop-app: {session_id}'s live connection ended: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppEvent::StreamEnded { .. } => {}
|
||||||
|
AppEvent::SendFailed(message) => {
|
||||||
|
eprintln!("desktop-app: couldn't send: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
fn current(&self, session_id: &str, generation: u64) -> bool {
|
||||||
|
self.selected.as_deref() == Some(session_id)
|
||||||
|
&& self.generation.load(Ordering::SeqCst) == generation
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.transcript_ptr)(rsc).set(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_fetch_sessions(&self) {
|
||||||
|
let api = self.api.clone();
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = api.fetch_sessions().map_err(|e| e.to_string());
|
||||||
|
let _ = proxy.send_event(AppEvent::Sessions(result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_list(&mut self, rsc: &mut DefaultRsc<Self>) {
|
||||||
|
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
|
||||||
|
for session in &self.sessions {
|
||||||
|
let selected = self.selected.as_deref() == Some(session.id.as_str());
|
||||||
|
let row = session_row(rsc, session, selected);
|
||||||
|
list(rsc).push(row);
|
||||||
|
}
|
||||||
|
let tree = list
|
||||||
|
.background(rect(Srgba8::rgb(24, 24, 28)))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
(self.list_ptr)(rsc).set(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
|
||||||
|
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
self.selected = Some(session_id.clone());
|
||||||
|
self.items.clear();
|
||||||
|
self.screen = None;
|
||||||
|
self.rebuild_list(rsc);
|
||||||
|
self.show_message(rsc, "Loading transcript...");
|
||||||
|
|
||||||
|
let api = self.api.clone();
|
||||||
|
let stream_transport = self.stream_transport.clone();
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
let live_generation = self.generation.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
let after = page
|
||||||
|
.as_ref()
|
||||||
|
.ok()
|
||||||
|
.and_then(|values| raw_seq(values.last()?))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let result = page.and_then(|values| fold_page(&values));
|
||||||
|
let _ = proxy.send_event(AppEvent::TranscriptLoaded {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
generation,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
||||||
|
if stop() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let outcome =
|
||||||
|
follow_session_events(&*stream_transport, &session_id, after, |item| match item {
|
||||||
|
StreamItem::Open | StreamItem::Reset => !stop(),
|
||||||
|
StreamItem::Event { event, .. } => {
|
||||||
|
if stop() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let _ = proxy.send_event(AppEvent::StreamEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
generation,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = proxy.send_event(AppEvent::StreamEnded {
|
||||||
|
session_id,
|
||||||
|
generation,
|
||||||
|
message: outcome.err().map(|e| e.to_string()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
|
let api = self.api.clone();
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = api.send_message(&session_id, &text, &[]) {
|
||||||
|
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc<Self>) {
|
||||||
|
let in_progress = self
|
||||||
|
.screen
|
||||||
|
.as_ref()
|
||||||
|
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
|
let rows = group_tool_runs(&self.items);
|
||||||
|
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
|
if let Some(text) = in_progress {
|
||||||
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
|
}
|
||||||
|
if let Some(session_id) = self.selected.clone() {
|
||||||
|
let field = screen.composer.field;
|
||||||
|
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||||
|
let text = field.edit(rsc).take();
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
ctx.state.send_message(session_id.clone(), text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(self.transcript_ptr)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_row(
|
||||||
|
rsc: &mut DefaultRsc<Client>,
|
||||||
|
session: &SessionSummary,
|
||||||
|
selected: bool,
|
||||||
|
) -> StrongWidget {
|
||||||
|
let bg = if selected {
|
||||||
|
Srgba8::rgb(58, 90, 138)
|
||||||
|
} else {
|
||||||
|
Srgba8::rgb(38, 38, 44)
|
||||||
|
};
|
||||||
|
let id = session.id.clone();
|
||||||
|
let label = format!("{}\n{}", session.title, session.status);
|
||||||
|
wtext(label)
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(10)
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(bg))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
move |ctx, rsc: &mut DefaultRsc<Client>| {
|
||||||
|
ctx.state.select_session(rsc, id.clone());
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::client::config::EnrollmentStore;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let home = std::env::var_os("HOME").expect("HOME must be set");
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
});
|
||||||
|
base.join("ai-app-desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store() -> EnrollmentStore {
|
||||||
|
EnrollmentStore::new(config_dir())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod config;
|
||||||
|
pub mod startup;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! The desktop binary's command line and the enrolment it resolves --
|
||||||
|
//! `--link`/`--ca`, parsed once at startup and again from `app.rs`'s
|
||||||
|
//! `Client::new`. Here rather than in `src/bin_desktop.rs` because both
|
||||||
|
//! callers are in the library; the binary is only `fn main`.
|
||||||
|
|
||||||
|
use crate::client::config::EnrolledServer;
|
||||||
|
|
||||||
|
use super::config;
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
ca_path: Option<std::path::PathBuf>,
|
||||||
|
link: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut ca_path = None;
|
||||||
|
let mut link = None;
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--ca" => {
|
||||||
|
ca_path = Some(std::path::PathBuf::from(
|
||||||
|
args.next().ok_or("--ca needs a path")?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"--link" => link = Some(args.next().ok_or("--link needs a value")?),
|
||||||
|
other => return Err(format!("unrecognised argument '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Args { ca_path, link })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||||
|
let args = parse_args()?;
|
||||||
|
let store = config::store();
|
||||||
|
let server = match args.link {
|
||||||
|
Some(link) => {
|
||||||
|
let server = EnrolledServer::parse_link(&link)?;
|
||||||
|
store
|
||||||
|
.save(&server)
|
||||||
|
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||||
|
server
|
||||||
|
}
|
||||||
|
None => store
|
||||||
|
.load()
|
||||||
|
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \
|
||||||
|
once (app/ui-sandbox.sh's start banner prints one)",
|
||||||
|
config::config_dir().display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
// `--ca` wins where it was given, so a caller can point a link's
|
||||||
|
// server at a certificate it did not carry -- and so the flag still
|
||||||
|
// means what it did before the link could carry one.
|
||||||
|
let ca_pem = match (&args.ca_path, &server.ca_pem) {
|
||||||
|
(Some(path), _) => std::fs::read(path)
|
||||||
|
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
|
||||||
|
(None, Some(pem)) => pem.clone().into_bytes(),
|
||||||
|
(None, None) => {
|
||||||
|
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
|
||||||
|
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
|
||||||
|
minted by a server that includes one"
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((server, ca_pem))
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
pub mod client;
|
||||||
|
|
||||||
|
#[cfg(feature = "screens")]
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "screens", not(target_os = "android")))]
|
||||||
|
pub mod desktop;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "screens", target_os = "android"))]
|
||||||
|
pub mod android;
|
||||||
|
|
||||||
|
// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`,
|
||||||
|
// which this toolchain deprecates in favour of `try_update`. The call is
|
||||||
|
// inside the macro, so there is nothing here to migrate -- the fix is a
|
||||||
|
// `jni` release, and this allow comes out when one lands. Scoped to the
|
||||||
|
// module the macro is used in rather than the crate, so a deprecation in
|
||||||
|
// our own code is still a warning.
|
||||||
|
#[cfg(feature = "shell")]
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub mod shell;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
||||||
|
use jni::refs::{Global, LoaderContext};
|
||||||
|
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
||||||
|
use jni::strings::JNIString;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||||
|
if CLASS_LOADER.get().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
||||||
|
let loader_obj = call_method(
|
||||||
|
env,
|
||||||
|
&class_obj,
|
||||||
|
"getClassLoader",
|
||||||
|
"()Ljava/lang/ClassLoader;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
||||||
|
let global = env.new_global_ref(&loader)?;
|
||||||
|
// Lost the race with another entry point calling this concurrently --
|
||||||
|
// both loaders name the same app, so either one is fine and there is
|
||||||
|
// nothing to reconcile.
|
||||||
|
let _ = CLASS_LOADER.set(global);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
||||||
|
/// through the cached app classloader when one has been remembered, and
|
||||||
|
/// through the ordinary default otherwise -- which is every call made
|
||||||
|
/// before any entry point has run, and is also correct for a main-thread
|
||||||
|
/// caller, so there is no case this makes worse.
|
||||||
|
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||||
|
match CLASS_LOADER.get() {
|
||||||
|
Some(loader) => {
|
||||||
|
let binary_name = name.replace('/', ".");
|
||||||
|
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
||||||
|
}
|
||||||
|
None => env.find_class(JNIString::new(name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||||
|
resolve_class(env, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||||
|
Ok(env.new_string(text)?.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_object<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JObject<'local>> {
|
||||||
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.new_object(class, sig.method_signature(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call_method<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
obj: &JObject,
|
||||||
|
method: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||||
|
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call_static_method<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
method: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_static_field<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
field: &str,
|
||||||
|
sig: &str,
|
||||||
|
) -> Result<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||||
|
}
|
||||||
@@ -1,23 +1,3 @@
|
|||||||
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
|
||||||
//! package comment for what this crate is and RUST.md's E3 entry for the
|
|
||||||
//! design decisions.
|
|
||||||
//!
|
|
||||||
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
|
||||||
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
|
||||||
//! the macro derives the mangled export name and the JNI signature from the
|
|
||||||
//! Rust function itself, so the two cannot drift apart the way a
|
|
||||||
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
|
||||||
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
|
||||||
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
|
||||||
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
|
||||||
//! as an exception that would crash the app over something recoverable.
|
|
||||||
//!
|
|
||||||
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
|
||||||
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
|
||||||
//! side-effecting const (here, generating the `#[export_name]`d function
|
|
||||||
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
|
||||||
//! warning for a binding nothing reads.
|
|
||||||
|
|
||||||
mod jcall;
|
mod jcall;
|
||||||
mod notify;
|
mod notify;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -28,15 +8,6 @@ use jni::objects::{JClass, JObject};
|
|||||||
use jni::sys::jint;
|
use jni::sys::jint;
|
||||||
use jni::{Env, NativeMethod, native_method};
|
use jni::{Env, NativeMethod, native_method};
|
||||||
|
|
||||||
/// Installs the `log` backend that routes to logcat, once per process.
|
|
||||||
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
|
||||||
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
|
||||||
/// call into the `log` facade's default no-op logger, and a real failure
|
|
||||||
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
|
||||||
/// crashing, since nothing on screen or in the log says a notification was
|
|
||||||
/// dropped. Called from every entry point below rather than a Java-side
|
|
||||||
/// `Application.onCreate`, since this crate deliberately has no such class
|
|
||||||
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
|
||||||
fn ensure_logger() {
|
fn ensure_logger() {
|
||||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
ONCE.call_once(|| {
|
ONCE.call_once(|| {
|
||||||
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
|
||||||
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
|
||||||
fn native_handle_intent<'local>(
|
fn native_handle_intent<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
|
||||||
/// enrollment may have just landed) and from `NotificationService.sync`
|
|
||||||
/// itself. See `notify::sync`.
|
|
||||||
fn native_sync<'local>(
|
fn native_sync<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
|
||||||
fn native_on_start_command<'local>(
|
fn native_on_start_command<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
|
||||||
fn native_on_destroy<'local>(
|
fn native_on_destroy<'local>(
|
||||||
_env: &mut Env<'local>,
|
_env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -12,20 +12,19 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client_core::api::UreqTransport;
|
use crate::client::api::UreqTransport;
|
||||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
use crate::client::notifications::{SessionNotification, follow_notifications};
|
||||||
use jni::Env;
|
use jni::Env;
|
||||||
use jni::errors::Result;
|
use jni::errors::Result;
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
use jni::sys::{JNI_TRUE, jint};
|
use jni::sys::{JNI_TRUE, jint};
|
||||||
|
|
||||||
use crate::settings::{self, ServerSettings};
|
use crate::shell::settings::{self, ServerSettings};
|
||||||
|
|
||||||
const ALERT_CHANNEL: &str = "sessions";
|
const ALERT_CHANNEL: &str = "sessions";
|
||||||
const ONGOING_CHANNEL: &str = "connection";
|
const ONGOING_CHANNEL: &str = "connection";
|
||||||
const ONGOING_ID: i32 = 1;
|
const ONGOING_ID: i32 = 1;
|
||||||
const ALERT_ID: i32 = 2;
|
const ALERT_ID: i32 = 2;
|
||||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
|
||||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||||
|
|
||||||
/// Whether the follow-loop thread is already running. **A deviation from
|
/// Whether the follow-loop thread is already running. **A deviation from
|
||||||
@@ -45,25 +44,14 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
|||||||
/// same guard back to `Notifications.kt` separately.
|
/// same guard back to `Notifications.kt` separately.
|
||||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
|
||||||
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
|
||||||
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
|
||||||
/// can interrupt a `ureq` read already blocked inside one connection --
|
|
||||||
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
|
||||||
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
|
||||||
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
|
||||||
/// practice this is a bounded wait rather than a hang; closing that gap
|
|
||||||
/// for real means adding a cancellation point to `client_core::Transport`,
|
|
||||||
/// which is a decision affecting every caller of that trait, not just this
|
|
||||||
/// one -- left for whoever next depends on prompt shutdown.
|
|
||||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationManagerCompat",
|
"androidx/core/app/NotificationManagerCompat",
|
||||||
"from",
|
"from",
|
||||||
@@ -80,22 +68,22 @@ fn create_channel(
|
|||||||
name: &str,
|
name: &str,
|
||||||
importance: i32,
|
importance: i32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||||
"(Ljava/lang/String;I)V",
|
"(Ljava/lang/String;I)V",
|
||||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||||
)?;
|
)?;
|
||||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"setName",
|
"setName",
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||||
&[JValue::Object(&name_j)],
|
&[JValue::Object(&name_j)],
|
||||||
)?;
|
)?;
|
||||||
let channel = crate::jcall::call_method(
|
let channel = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"build",
|
"build",
|
||||||
@@ -103,7 +91,7 @@ fn create_channel(
|
|||||||
&[],
|
&[],
|
||||||
)?
|
)?
|
||||||
.l()?;
|
.l()?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
manager,
|
manager,
|
||||||
"createNotificationChannel",
|
"createNotificationChannel",
|
||||||
@@ -146,8 +134,8 @@ fn new_intent_for<'l>(
|
|||||||
context: &JObject,
|
context: &JObject,
|
||||||
class_name: &str,
|
class_name: &str,
|
||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
let target_class = crate::shell::jcall::find_class(env, class_name)?;
|
||||||
crate::jcall::new_object(
|
crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"android/content/Intent",
|
"android/content/Intent",
|
||||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||||
@@ -165,41 +153,42 @@ fn session_intent<'l>(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&intent,
|
&intent,
|
||||||
"setAction",
|
"setAction",
|
||||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||||
&[JValue::Object(&action_view)],
|
&[JValue::Object(&action_view)],
|
||||||
)?;
|
)?;
|
||||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"scheme",
|
"scheme",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&scheme)],
|
&[JValue::Object(&scheme)],
|
||||||
)?;
|
)?;
|
||||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"authority",
|
"authority",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&authority)],
|
&[JValue::Object(&authority)],
|
||||||
)?;
|
)?;
|
||||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"appendPath",
|
"appendPath",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&path)],
|
&[JValue::Object(&path)],
|
||||||
)?;
|
)?;
|
||||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
|
||||||
crate::jcall::call_method(
|
.l()?;
|
||||||
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&intent,
|
&intent,
|
||||||
"setData",
|
"setData",
|
||||||
@@ -216,7 +205,7 @@ fn pending_activity<'l>(
|
|||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"android/app/PendingIntent",
|
"android/app/PendingIntent",
|
||||||
"getActivity",
|
"getActivity",
|
||||||
@@ -238,7 +227,7 @@ fn builder_call<'l>(
|
|||||||
sig: &str,
|
sig: &str,
|
||||||
args: &[JValue],
|
args: &[JValue],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +248,14 @@ fn foreground_type(env: &mut Env) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
)?;
|
)?;
|
||||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -297,7 +286,8 @@ fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObj
|
|||||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Int(priority_min)],
|
&[JValue::Int(priority_min)],
|
||||||
)?;
|
)?;
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||||
|
.l()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the service if there is a server to connect to, and stops it
|
/// Starts the service if there is a server to connect to, and stops it
|
||||||
@@ -306,7 +296,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
let service_intent =
|
let service_intent =
|
||||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||||
if settings::load(env, context)?.is_none() {
|
if settings::load(env, context)?.is_none() {
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
context,
|
context,
|
||||||
"stopService",
|
"stopService",
|
||||||
@@ -316,7 +306,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
create_channels(env, context)?;
|
create_channels(env, context)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/content/ContextCompat",
|
"androidx/core/content/ContextCompat",
|
||||||
"startForegroundService",
|
"startForegroundService",
|
||||||
@@ -326,16 +316,11 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `Service.onStartCommand` body -- loads settings, starts the
|
|
||||||
/// foreground notification, and spawns the follow-loop thread. Answers the
|
|
||||||
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
|
||||||
/// framework rather than hardcoded so a wrong guess at their values cannot
|
|
||||||
/// silently pick the other behaviour.
|
|
||||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||||
match try_start(env, &service) {
|
match try_start(env, &service) {
|
||||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -352,7 +337,7 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
let ca = settings::load_pinned_ca(env)?;
|
let ca = settings::load_pinned_ca(env)?;
|
||||||
let notification = ongoing_notification(env, service)?;
|
let notification = ongoing_notification(env, service)?;
|
||||||
let fg_type = foreground_type(env)?;
|
let fg_type = foreground_type(env)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/ServiceCompat",
|
"androidx/core/app/ServiceCompat",
|
||||||
"startForeground",
|
"startForeground",
|
||||||
@@ -379,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("ai-app-notifications".to_string())
|
.name("ai-app-notifications".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// Requests a *permanent* attachment (detached only when this thread
|
|
||||||
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
|
||||||
// this is the long-lived follow loop, not a one-shot callback.
|
|
||||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
follow_loop(env, &context, settings, &ca);
|
follow_loop(env, &context, settings, &ca);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -391,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follows the backend's notification stream, reconnecting until stopped
|
|
||||||
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
|
||||||
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
|
||||||
/// it cannot connect, for the same reason as the Kotlin original: a
|
|
||||||
/// notification saying "I could not tell you whether anything happened" is
|
|
||||||
/// noise about a condition nobody can act on.
|
|
||||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||||
while !STOPPING.load(Ordering::SeqCst) {
|
while !STOPPING.load(Ordering::SeqCst) {
|
||||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||||
@@ -414,9 +390,6 @@ fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One notification per session, replacing that session's previous one --
|
|
||||||
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
|
||||||
/// branches this module's doc comment explains.
|
|
||||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||||
let manager = notification_manager(env, context)?;
|
let manager = notification_manager(env, context)?;
|
||||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||||
@@ -424,13 +397,14 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
let allowed = if sdk < tiramisu {
|
let allowed = if sdk < tiramisu {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
let permission =
|
||||||
|
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||||
let granted = static_int(
|
let granted = static_int(
|
||||||
env,
|
env,
|
||||||
"android/content/pm/PackageManager",
|
"android/content/pm/PackageManager",
|
||||||
"PERMISSION_GRANTED",
|
"PERMISSION_GRANTED",
|
||||||
)?;
|
)?;
|
||||||
let result = crate::jcall::call_static_method(
|
let result = crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/content/ContextCompat",
|
"androidx/core/content/ContextCompat",
|
||||||
"checkSelfPermission",
|
"checkSelfPermission",
|
||||||
@@ -441,20 +415,21 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
result == granted
|
result == granted
|
||||||
};
|
};
|
||||||
let enabled =
|
let enabled =
|
||||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
|
||||||
|
.z()?;
|
||||||
if !allowed || !enabled {
|
if !allowed || !enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||||
let pending = pending_activity(env, context, &intent)?;
|
let pending = pending_activity(env, context, &intent)?;
|
||||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
)?;
|
)?;
|
||||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
let title = crate::shell::jcall::jstr_obj(env, ¬ification.title)?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -462,7 +437,7 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Object(&title)],
|
&[JValue::Object(&title)],
|
||||||
)?;
|
)?;
|
||||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -507,11 +482,16 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Bool(JNI_TRUE)],
|
&[JValue::Bool(JNI_TRUE)],
|
||||||
)?;
|
)?;
|
||||||
let built =
|
let built = crate::shell::jcall::call_method(
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
env,
|
||||||
.l()?;
|
&builder,
|
||||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
"build",
|
||||||
crate::jcall::call_method(
|
"()Landroid/app/Notification;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let tag = crate::shell::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||||
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&manager,
|
&manager,
|
||||||
"notify",
|
"notify",
|
||||||
@@ -525,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
|
||||||
/// the gap this module's `STOPPING` doc explains.
|
|
||||||
pub fn on_destroy() {
|
pub fn on_destroy() {
|
||||||
STOPPING.store(true, Ordering::SeqCst);
|
STOPPING.store(true, Ordering::SeqCst);
|
||||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||||
@@ -542,9 +520,9 @@ pub fn on_destroy() {
|
|||||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||||
let message = format!("android-shell: {where_}: {error}");
|
let message = format!("android-shell: {where_}: {error}");
|
||||||
let _ = (|| -> Result<()> {
|
let _ = (|| -> Result<()> {
|
||||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
|
||||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"android/util/Log",
|
"android/util/Log",
|
||||||
"e",
|
"e",
|
||||||
@@ -28,20 +28,15 @@ impl ServerSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This experiment's own scheme and Keystore alias -- distinct from the
|
|
||||||
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
|
||||||
/// installed side by side on the same development device without
|
|
||||||
/// colliding over which one a scanned QR or a deep link resolves to. See
|
|
||||||
/// RUST.md's E3 entry for why they are not the same value.
|
|
||||||
pub(crate) const SCHEME: &str = "aiappshell";
|
pub(crate) const SCHEME: &str = "aiappshell";
|
||||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||||
|
|
||||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
|
||||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||||
crate::jcall::new_object(
|
crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
STORE_CLASS,
|
STORE_CLASS,
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||||
@@ -51,13 +46,14 @@ fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
|||||||
|
|
||||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||||
let host = get_string(env, settings_obj, "getHost")?;
|
let host = get_string(env, settings_obj, "getHost")?;
|
||||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||||
let token = get_string(env, settings_obj, "getToken")?;
|
let token = get_string(env, settings_obj, "getToken")?;
|
||||||
Ok(ServerSettings { host, port, token })
|
Ok(ServerSettings { host, port, token })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||||
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
let value =
|
||||||
|
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
jstr.try_to_string(env)
|
jstr.try_to_string(env)
|
||||||
}
|
}
|
||||||
@@ -66,7 +62,7 @@ fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
|||||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let settings_obj = crate::jcall::call_method(
|
let settings_obj = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"load",
|
"load",
|
||||||
@@ -80,12 +76,11 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
|
|||||||
Ok(Some(read_settings(env, &settings_obj)?))
|
Ok(Some(read_settings(env, &settings_obj)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
|
||||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
|
||||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
|
||||||
let settings_obj = crate::jcall::new_object(
|
let settings_obj = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
SETTINGS_CLASS,
|
SETTINGS_CLASS,
|
||||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||||
@@ -95,7 +90,7 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
|||||||
JValue::Object(&token),
|
JValue::Object(&token),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"save",
|
"save",
|
||||||
@@ -105,12 +100,9 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
|
||||||
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
|
||||||
/// query-parameter rules rather than re-deriving them here.
|
|
||||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let settings_obj = crate::jcall::call_method(
|
let settings_obj = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"parseEnrollmentUri",
|
"parseEnrollmentUri",
|
||||||
@@ -129,7 +121,7 @@ pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<Serve
|
|||||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||||
/// own to generate into.
|
/// own to generate into.
|
||||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||||
let value = crate::jcall::get_static_field(
|
let value = crate::shell::jcall::get_static_field(
|
||||||
env,
|
env,
|
||||||
"com/example/aiapp/shell/PinnedCa",
|
"com/example/aiapp/shell/PinnedCa",
|
||||||
"PINNED_CA_PEM",
|
"PINNED_CA_PEM",
|
||||||
@@ -1,30 +1,10 @@
|
|||||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
use crate::client::api::{ApiClient, UreqTransport};
|
||||||
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
|
||||||
//!
|
|
||||||
//! **Scope cut, recorded rather than silent**: only shared *text*
|
|
||||||
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
|
||||||
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
|
||||||
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
|
||||||
//! ported here, because `client-core`'s `ApiClient` does not have the
|
|
||||||
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
|
||||||
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
|
||||||
//! `content://` stream and no text falls through to a toast saying so,
|
|
||||||
//! rather than silently doing nothing. Closing this gap is the same
|
|
||||||
//! `client-core` work whichever caller needs it next.
|
|
||||||
//!
|
|
||||||
//! **Which session a share lands in** is also a placeholder: with no
|
|
||||||
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
|
||||||
//! to whichever session has the latest `last_activity` -- the one most
|
|
||||||
//! likely to be what somebody meant. Worth revisiting once a real screen
|
|
||||||
//! exists to ask instead of guessing.
|
|
||||||
|
|
||||||
use client_core::api::{ApiClient, UreqTransport};
|
|
||||||
use jni::Env;
|
use jni::Env;
|
||||||
use jni::errors::Result;
|
use jni::errors::Result;
|
||||||
use jni::objects::{JObject, JString, JValue};
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
|
||||||
use crate::notify;
|
use crate::shell::notify;
|
||||||
use crate::settings;
|
use crate::shell::settings;
|
||||||
|
|
||||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||||
@@ -32,7 +12,8 @@ const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
|||||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||||
|
|
||||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||||
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
let value =
|
||||||
|
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
if value.is_null() {
|
if value.is_null() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -41,8 +22,8 @@ fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||||
let message = crate::jcall::jstr_obj(env, message)?;
|
let message = crate::shell::jcall::jstr_obj(env, message)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"com/example/aiapp/shell/MainActivity",
|
"com/example/aiapp/shell/MainActivity",
|
||||||
"toast",
|
"toast",
|
||||||
@@ -52,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one place an incoming intent is sorted into what it means -- mirrors
|
|
||||||
/// `MainActivity.kt`'s `handleIntent`.
|
|
||||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
let action = get_string_method(env, intent, "getAction")?;
|
let action = get_string_method(env, intent, "getAction")?;
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -65,7 +44,8 @@ pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Res
|
|||||||
if action.as_deref() != Some(ACTION_VIEW) {
|
if action.as_deref() != Some(ACTION_VIEW) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
|
||||||
|
.l()?;
|
||||||
if uri.is_null() {
|
if uri.is_null() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -84,9 +64,6 @@ fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Resu
|
|||||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// There is no session screen yet (E4's job); the toast is this
|
|
||||||
// experiment's stand-in proof that the tap was routed to the right
|
|
||||||
// session id.
|
|
||||||
toast(env, activity, &format!("Opened session {session_id}"))
|
toast(env, activity, &format!("Opened session {session_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +82,9 @@ fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
|
||||||
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
|
||||||
/// what happens to it, minus attachments per this module's doc comment.
|
|
||||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||||
let text = crate::jcall::call_method(
|
let text = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
intent,
|
intent,
|
||||||
"getStringExtra",
|
"getStringExtra",
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const MAX_LINES: f32 = 6.0;
|
||||||
|
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||||
|
const FIELD_PAD_DP: f32 = 12.0;
|
||||||
|
|
||||||
|
/// `field` is exposed so the caller can read its content on submit
|
||||||
|
/// (`field.edit(rsc).text()`) and clear it afterward
|
||||||
|
/// (`field.edit(rsc).set("")`).
|
||||||
|
pub struct Composer {
|
||||||
|
pub field: WeakWidget<TextEdit>,
|
||||||
|
/// The bar's own outer padding -- only `bottom` is ever changed, by
|
||||||
|
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
|
||||||
|
/// a rebuilt tree, because `field` lives inside it and cannot be
|
||||||
|
/// re-added to a new wrapper once it is strongly owned here.
|
||||||
|
outer_pad: WeakWidget<Pad>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Composer {
|
||||||
|
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
|
||||||
|
/// whenever the space below the bar changes: the IME's own inset while
|
||||||
|
/// it is open, the navigation-bar inset otherwise. Takes a plain
|
||||||
|
/// `f32` in the caller's own physical-pixel units rather than an
|
||||||
|
/// Android-specific insets type, so this crate stays usable from the
|
||||||
|
/// winit backend too, which has no navigation bar to report.
|
||||||
|
/// Rewrites the existing `Pad` in place (marking it dirty through the
|
||||||
|
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
|
||||||
|
/// so the field's focus, selection and in-progress text are untouched.
|
||||||
|
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
||||||
|
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||||
|
pad.padding.bottom = Len::abs(inset);
|
||||||
|
pad.exact_region = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the composer plus its own bar as a **weak** id -- the caller
|
||||||
|
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
|
||||||
|
/// `set_root` performs the one real strong registration. Calling
|
||||||
|
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
|
||||||
|
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
|
||||||
|
/// mistake this box's `row.rs` first made with its sender-label header, see
|
||||||
|
/// that file's comment for the fuller account.
|
||||||
|
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
let field = wtext("")
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(true)
|
||||||
|
.size(18)
|
||||||
|
.color(theme.text.clone())
|
||||||
|
.attr::<Selectable>(())
|
||||||
|
.label("Message")
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// Without any mask at all the overflow paints *above* the bar, over
|
||||||
|
// the transcript: measured at 58px of stray text for a 475px message
|
||||||
|
// in a 417px box.
|
||||||
|
let content = field
|
||||||
|
.width(rest(1))
|
||||||
|
.scrollable(Axis::Y, Pin::End)
|
||||||
|
.pad(dp(FIELD_PAD_DP))
|
||||||
|
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||||
|
.width(rest(1))
|
||||||
|
.masked_by(rect(theme.composer_surface.clone()))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||||
|
|
||||||
|
(Composer { field, outer_pad }, outer_pad)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
pub const BACKLOG_COUNT: usize = 3202;
|
||||||
|
|
||||||
|
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||||
|
|
||||||
|
pub const PHONE_WIDTH: f32 = 1080.0;
|
||||||
|
pub const PHONE_HEIGHT: f32 = 2424.0;
|
||||||
|
pub const PHONE_SCALE: f32 = 2.55;
|
||||||
|
pub const PHONE_FRAME_MS: u64 = 8;
|
||||||
|
|
||||||
|
pub fn phone_size() -> Vec2 {
|
||||||
|
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Fixture {
|
||||||
|
pub backlog: Vec<serde_json::Value>,
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
/// Parses the whole fixture. Panics on malformed input: this is a
|
||||||
|
/// generated file compiled into the binary, so a parse failure is a
|
||||||
|
/// broken build rather than a condition a caller could recover from
|
||||||
|
/// (CODE_RULES: separate recoverable conditions from programmer
|
||||||
|
/// error).
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
|
||||||
|
let mut stream_tail = Vec::new();
|
||||||
|
for (i, line) in FIXTURE_JSONL
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||||
|
if i < BACKLOG_COUNT {
|
||||||
|
backlog.push(value);
|
||||||
|
} else {
|
||||||
|
stream_tail.push(
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.expect("bench fixture event matches event-model's SeqEvent"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
backlog,
|
||||||
|
stream_tail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening page folded into transcript items -- the same
|
||||||
|
/// `fold_page` a real first load runs. `Err` carries the fold's own
|
||||||
|
/// message, which a caller shows on screen rather than panicking, so
|
||||||
|
/// a fixture that stops folding is visible in the app instead of
|
||||||
|
/// being a crash on launch.
|
||||||
|
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
fold_page(&self.backlog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
|
group_tool_runs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a caller needs to run the fixture as an app screen would:
|
||||||
|
/// the screen, the folded items behind it, and the events not yet
|
||||||
|
/// streamed. The tree itself comes back separately from
|
||||||
|
/// [`build_screen`], since whoever takes it owns it.
|
||||||
|
pub struct Opened {
|
||||||
|
pub screen: crate::ui::TranscriptScreen,
|
||||||
|
pub items: Vec<TranscriptItem>,
|
||||||
|
/// The tail, for a caller that goes on replaying it one event at a
|
||||||
|
/// time through `fold_event`/`TranscriptScreen::apply` -- the
|
||||||
|
/// streaming phase of either app's benchmark.
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the transcript screen over the fixture's opening page, without
|
||||||
|
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
|
||||||
|
/// for a caller (the Android bench) that puts the screen inside a shell
|
||||||
|
/// of its own.
|
||||||
|
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
let items = fixture.backlog_items()?;
|
||||||
|
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
|
||||||
|
Ok((
|
||||||
|
Opened {
|
||||||
|
screen,
|
||||||
|
items,
|
||||||
|
stream_tail: fixture.stream_tail,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ui_state: &mut impl HasRoot<Rsc>,
|
||||||
|
) -> Result<Opened, String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (opened, tree) = build_screen(rsc)?;
|
||||||
|
ui_state.set_root(rsc, tree);
|
||||||
|
Ok(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
|
||||||
|
assert!(
|
||||||
|
fixture.stream_tail.len() >= 400,
|
||||||
|
"the stream phase replays 400 events; the fixture has {}",
|
||||||
|
fixture.stream_tail.len()
|
||||||
|
);
|
||||||
|
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
use crate::client::highlight::{self, Kind, Language};
|
||||||
|
use crate::client::markdown_blocks::{Block, BlockKind};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
|
||||||
|
match kind {
|
||||||
|
Kind::Keyword => theme.syntax_keyword.clone(),
|
||||||
|
Kind::String => theme.syntax_string.clone(),
|
||||||
|
Kind::Literal => theme.syntax_literal.clone(),
|
||||||
|
Kind::Comment => theme.syntax_comment.clone(),
|
||||||
|
Kind::Metadata => theme.syntax_metadata.clone(),
|
||||||
|
Kind::Punctuation => theme.syntax_punctuation.clone(),
|
||||||
|
Kind::Mark => theme.syntax_mark.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum BlockFrame {
|
||||||
|
Plain,
|
||||||
|
Verbatim { fill: PaintId },
|
||||||
|
Quote,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
|
||||||
|
match kind {
|
||||||
|
BlockKind::Code => BlockFrame::Verbatim {
|
||||||
|
fill: theme.verbatim_surface.clone(),
|
||||||
|
},
|
||||||
|
BlockKind::Table => BlockFrame::Verbatim {
|
||||||
|
fill: theme.table_surface.clone(),
|
||||||
|
},
|
||||||
|
BlockKind::Quote => BlockFrame::Quote,
|
||||||
|
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||||
|
BlockFrame::Plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Link {
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Rendered {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<SpanStyle>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rendered {
|
||||||
|
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||||
|
self.links.iter().find(|l| l.range.contains(&byte))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||||
|
/// text and descends, because these are headings inside a chat message
|
||||||
|
/// rather than the top of a document. The numbers are Material's
|
||||||
|
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||||
|
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||||
|
/// -- kept as literals rather than derived from `base_size` so the two
|
||||||
|
/// apps agree exactly.
|
||||||
|
fn heading_size(level: HeadingLevel) -> f32 {
|
||||||
|
match level {
|
||||||
|
HeadingLevel::H1 => 24.0,
|
||||||
|
HeadingLevel::H2 => 22.0,
|
||||||
|
HeadingLevel::H3 => 16.0,
|
||||||
|
HeadingLevel::H4 => 14.0,
|
||||||
|
HeadingLevel::H5 => 12.0,
|
||||||
|
HeadingLevel::H6 => 11.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||||
|
|
||||||
|
/// A block-level separator inside one block's own text (a list item's
|
||||||
|
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||||
|
/// an empty `out` gets no leading blank.
|
||||||
|
fn ensure_blank_line(out: &mut String) {
|
||||||
|
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_line(out: &mut String) {
|
||||||
|
if !out.is_empty() && !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
|
||||||
|
match block.kind {
|
||||||
|
BlockKind::Table => table_text(&block.source, theme),
|
||||||
|
_ => render_markdown(&block.source, base_size, theme),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One markdown source string rendered into plain text plus the spans that
|
||||||
|
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
||||||
|
/// only so a heading's override is relative to it rather than a hardcoded
|
||||||
|
/// absolute the caller cannot retune.
|
||||||
|
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
|
||||||
|
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let mut links = Vec::new();
|
||||||
|
// Stack of start byte offsets for whatever inline/block styling is
|
||||||
|
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||||
|
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||||
|
// plain offset stack (rather than a tree, or repeating the kind here
|
||||||
|
// too) is enough. A link's destination rides along beside its offset,
|
||||||
|
// since `TagEnd::Link` does not carry it.
|
||||||
|
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||||
|
// One entry per open list: `Some(next number)` for an ordered list,
|
||||||
|
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||||
|
// what picks the bullet glyph.
|
||||||
|
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||||
|
let mut fence_language: Option<Language> = None;
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(src, options());
|
||||||
|
for event in parser {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => match tag {
|
||||||
|
Tag::Heading { .. }
|
||||||
|
| Tag::Emphasis
|
||||||
|
| Tag::Strong
|
||||||
|
| Tag::Strikethrough
|
||||||
|
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||||
|
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||||
|
Tag::CodeBlock(kind) => {
|
||||||
|
fence_language = match &kind {
|
||||||
|
CodeBlockKind::Fenced(info) => {
|
||||||
|
highlight::fence_language(info.split_whitespace().next())
|
||||||
|
}
|
||||||
|
CodeBlockKind::Indented => None,
|
||||||
|
};
|
||||||
|
ensure_blank_line(&mut out);
|
||||||
|
open.push((out.len(), None));
|
||||||
|
}
|
||||||
|
Tag::Item => {
|
||||||
|
ensure_line(&mut out);
|
||||||
|
let depth = lists.len().max(1);
|
||||||
|
out.push_str(&" ".repeat(depth - 1));
|
||||||
|
let start = out.len();
|
||||||
|
match lists.last_mut() {
|
||||||
|
Some(Some(n)) => {
|
||||||
|
out.push_str(&format!("{n}. "));
|
||||||
|
*n += 1;
|
||||||
|
}
|
||||||
|
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||||
|
}
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||||
|
}
|
||||||
|
Tag::List(first) => lists.push(first),
|
||||||
|
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::End(
|
||||||
|
tag_end @ (TagEnd::Heading(_)
|
||||||
|
| TagEnd::Emphasis
|
||||||
|
| TagEnd::Strong
|
||||||
|
| TagEnd::Strikethrough
|
||||||
|
| TagEnd::Link
|
||||||
|
| TagEnd::Image
|
||||||
|
| TagEnd::CodeBlock),
|
||||||
|
) => {
|
||||||
|
let Some((start, dest)) = open.pop() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let range = start..out.len();
|
||||||
|
if range.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tag_end {
|
||||||
|
TagEnd::Heading(level) => {
|
||||||
|
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
|
||||||
|
}
|
||||||
|
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
|
||||||
|
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
|
||||||
|
TagEnd::Strikethrough => {
|
||||||
|
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
|
||||||
|
}
|
||||||
|
// An image draws as its alt text until the port has a
|
||||||
|
// transcript image widget (IRIS_TODO's "scaled
|
||||||
|
// thumbnail"); marked as a link so it is at least
|
||||||
|
// followable rather than silently inert.
|
||||||
|
TagEnd::Link | TagEnd::Image => {
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.clone())
|
||||||
|
.color(theme.link.clone())
|
||||||
|
.underline(),
|
||||||
|
);
|
||||||
|
if let Some(url) = dest {
|
||||||
|
links.push(Link { range, url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TagEnd::CodeBlock => {
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.clone())
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(theme.code.clone()),
|
||||||
|
);
|
||||||
|
if let Some(language) = fence_language.take() {
|
||||||
|
highlight_into(&mut spans, &out, range, language, theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("filtered by the outer match arm"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) => out.push_str(&text),
|
||||||
|
Event::Code(text) => {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(&text);
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(start..out.len())
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(theme.code.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Event::SoftBreak => out.push(' '),
|
||||||
|
Event::HardBreak => out.push('\n'),
|
||||||
|
Event::Rule => {
|
||||||
|
ensure_line(&mut out);
|
||||||
|
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||||
|
}
|
||||||
|
Event::TaskListMarker(done) => {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::List(_)) => {
|
||||||
|
lists.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
spans.retain(|s| s.range.end <= out.len());
|
||||||
|
links.retain(|l| l.range.end <= out.len());
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options() -> Options {
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn highlight_into(
|
||||||
|
spans: &mut Vec<SpanStyle>,
|
||||||
|
text: &str,
|
||||||
|
range: Range<usize>,
|
||||||
|
language: Language,
|
||||||
|
theme: &Theme,
|
||||||
|
) {
|
||||||
|
let code = &text[range.clone()];
|
||||||
|
// char index -> byte offset within `code`, plus the end, so a span's
|
||||||
|
// `end` is always in range.
|
||||||
|
let bytes: Vec<usize> = code
|
||||||
|
.char_indices()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.chain(std::iter::once(code.len()))
|
||||||
|
.collect();
|
||||||
|
for span in highlight::spans_of(code, language) {
|
||||||
|
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"highlight span {}..{} outside {} chars of code",
|
||||||
|
span.start,
|
||||||
|
span.end,
|
||||||
|
bytes.len() - 1
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.start + start..range.start + end)
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(syntax_color(span.kind, theme)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_MAX_COL: usize = 28;
|
||||||
|
|
||||||
|
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
|
||||||
|
let rows = table_cells(src);
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Rendered::default();
|
||||||
|
}
|
||||||
|
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||||
|
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||||
|
.collect();
|
||||||
|
let widths: Vec<usize> = (0..columns)
|
||||||
|
.map(|c| {
|
||||||
|
wrapped
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.get(c))
|
||||||
|
.flat_map(|lines| lines.iter())
|
||||||
|
.map(|l| l.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (r, row) in wrapped.iter().enumerate() {
|
||||||
|
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||||
|
let start = out.len();
|
||||||
|
for line in 0..height {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for (c, width) in widths.iter().enumerate() {
|
||||||
|
if c > 0 {
|
||||||
|
out.push_str(" ");
|
||||||
|
}
|
||||||
|
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||||
|
let text = text.unwrap_or("");
|
||||||
|
out.push_str(text);
|
||||||
|
if c + 1 < widths.len() {
|
||||||
|
for _ in text.chars().count()..*width {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||||
|
out.push('\n');
|
||||||
|
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||||
|
let rule_start = out.len();
|
||||||
|
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||||
|
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||||
|
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||||
|
let mut cell = String::new();
|
||||||
|
let mut in_cell = false;
|
||||||
|
for event in Parser::new_ext(src, options()) {
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||||
|
Event::Start(Tag::TableCell) => {
|
||||||
|
cell.clear();
|
||||||
|
in_cell = true;
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::TableCell) => {
|
||||||
|
in_cell = false;
|
||||||
|
if let Some(row) = rows.last_mut() {
|
||||||
|
row.push(cell.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||||
|
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.retain(|r| !r.is_empty());
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
let extra = if line.is_empty() { 0 } else { 1 };
|
||||||
|
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||||
|
lines.push(std::mem::take(&mut line));
|
||||||
|
}
|
||||||
|
if !line.is_empty() {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
line.push_str(word);
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::markdown_blocks::split_blocks;
|
||||||
|
|
||||||
|
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
|
||||||
|
let mut paints = Paints::new();
|
||||||
|
let theme = Theme::new(&mut paints);
|
||||||
|
f(&theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||||
|
with_theme(|theme| super::render_markdown(src, base_size, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_block(block: &Block, base_size: f32) -> Rendered {
|
||||||
|
with_theme(|theme| super::render_block(block, base_size, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||||
|
with_theme(|theme| super::frame_of(kind, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syntax_color(kind: Kind) -> PaintId {
|
||||||
|
with_theme(|theme| super::syntax_color(kind, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_color() -> PaintId {
|
||||||
|
with_theme(|theme| theme.code.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker_color() -> PaintId {
|
||||||
|
with_theme(|theme| theme.marker.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block(src: &str) -> Rendered {
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||||
|
render_block(&blocks[0], 16.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_paragraph_has_no_spans() {
|
||||||
|
let r = render_markdown("just some words", 16.0);
|
||||||
|
assert_eq!(r.text, "just some words");
|
||||||
|
assert!(r.spans.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||||
|
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||||
|
assert_eq!(r.text, "a bold and italic word");
|
||||||
|
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||||
|
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||||
|
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||||
|
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_gets_a_bigger_font_size_span() {
|
||||||
|
let r = render_markdown("# A Title", 16.0);
|
||||||
|
assert!(r.text.starts_with("A Title"));
|
||||||
|
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||||
|
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||||
|
assert_eq!(heading.font_size, Some(24.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_heading_level_is_a_different_size() {
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for level in 1..=6 {
|
||||||
|
let src = format!("{} h", "#".repeat(level));
|
||||||
|
let r = render_markdown(&src, 16.0);
|
||||||
|
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||||
|
}
|
||||||
|
let mut sorted = sizes.clone();
|
||||||
|
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||||
|
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||||
|
assert!(r.text.contains("the docs"));
|
||||||
|
assert!(
|
||||||
|
!r.text.contains("example.com"),
|
||||||
|
"the URL should not leak into the visible text"
|
||||||
|
);
|
||||||
|
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||||
|
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||||
|
let at = r.text.find("docs").unwrap();
|
||||||
|
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||||
|
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||||
|
let past = r.text.find("for").unwrap();
|
||||||
|
assert!(r.link_at(past).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||||
|
let r = block("```rust\nlet x = 1; // note\n```");
|
||||||
|
assert_eq!(r.text, "let x = 1; // note");
|
||||||
|
let keyword = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
.expect("a rust fence colours its keywords");
|
||||||
|
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||||
|
let comment = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||||
|
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||||
|
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert_eq!(r.spans.len(), 1);
|
||||||
|
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||||
|
assert_eq!(r.spans[0].color, Some(code_color()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||||
|
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||||
|
for span in &r.spans {
|
||||||
|
assert!(
|
||||||
|
r.text.is_char_boundary(span.range.start)
|
||||||
|
&& r.text.is_char_boundary(span.range.end),
|
||||||
|
"span {:?} is not on a char boundary of {:?}",
|
||||||
|
span.range,
|
||||||
|
r.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let string = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||||
|
let r = block("```rust\nlet x = 1;");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert!(
|
||||||
|
r.spans
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||||
|
let r = block("- one\n- two\n - deep");
|
||||||
|
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(marker_color()))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||||
|
let r = block("3. three\n4. four");
|
||||||
|
assert_eq!(r.text, "3. three\n4. four");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(marker_color()))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["3. ", "4. "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||||
|
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||||
|
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||||
|
let r = render_block(&blocks[0], 16.0);
|
||||||
|
assert_eq!(r.text, "quoted words still quoted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||||
|
use BlockKind::*;
|
||||||
|
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||||
|
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||||
|
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||||
|
assert_ne!(
|
||||||
|
frame_of(Code),
|
||||||
|
frame_of(Table),
|
||||||
|
"a fence and a table sit on different fills"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||||
|
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||||
|
let lines: Vec<&str> = r.text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a bb");
|
||||||
|
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||||
|
assert_eq!(lines[2], "cccc d");
|
||||||
|
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||||
|
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_table_cell_wraps_inside_its_column() {
|
||||||
|
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||||
|
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||||
|
for line in r.text.lines() {
|
||||||
|
assert!(
|
||||||
|
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||||
|
"line too wide: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(r.text.contains("twelve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_list_marks_its_boxes() {
|
||||||
|
let r = block("- [x] done\n- [ ] not");
|
||||||
|
assert!(r.text.contains("[x] done"));
|
||||||
|
assert!(r.text.contains("[ ] not"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
pub mod composer;
|
||||||
|
// The checked-in bench fixture opened as a real screen -- 1.9 MB of
|
||||||
|
// `include_str!`, so it is a feature rather than always present: a build
|
||||||
|
// meant for a phone must not carry it. `bench` turns it on; so does the
|
||||||
|
// default, which is what makes `cargo test` here run the harness tests.
|
||||||
|
#[cfg(feature = "fixture")]
|
||||||
|
pub mod fixture;
|
||||||
|
pub mod markdown;
|
||||||
|
pub mod row;
|
||||||
|
pub(crate) mod tap;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod tool;
|
||||||
|
|
||||||
|
use crate::client::transcript_fold::TranscriptRow as FoldedRow;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
use theme::Theme;
|
||||||
|
|
||||||
|
pub struct TranscriptScreen {
|
||||||
|
/// The transcript's own `LazySpan` -- the layout *and* the scroll
|
||||||
|
/// position, since a lazy span owns a `ScrollController` of its own
|
||||||
|
/// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`).
|
||||||
|
/// Exposed so a caller can read `.extent()`, drive it through
|
||||||
|
/// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call
|
||||||
|
/// `.jump_to_end()` directly.
|
||||||
|
pub list: WeakWidget<LazySpan>,
|
||||||
|
pub composer: composer::Composer,
|
||||||
|
rebuilds: std::cell::Cell<usize>,
|
||||||
|
tail: RefCell<Option<(RowKey, row::TailRow)>>,
|
||||||
|
session_working: std::cell::Cell<bool>,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptScreen {
|
||||||
|
/// Append one more folded row at the live end of the transcript --
|
||||||
|
/// what a caller's SSE loop or a sent message calls as new events
|
||||||
|
/// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the
|
||||||
|
/// newest content when it already was (I3).
|
||||||
|
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
// Capped like any other row (`row::build_row`'s `cap`). A reply
|
||||||
|
// that goes on to *grow* past the cap is never capped, because it
|
||||||
|
// grows through `RowBlocks::apply_delta`, which appends to what is
|
||||||
|
// already drawn -- so the cap only ever catches a row that arrived
|
||||||
|
// long, which is the one nobody is watching arrive.
|
||||||
|
let (key, widget, tail) = row::build_row(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
row,
|
||||||
|
self.session_working.get(),
|
||||||
|
true,
|
||||||
|
self.theme.clone(),
|
||||||
|
);
|
||||||
|
(self.list)(rsc).push_back(LazyItem::new(key, widget));
|
||||||
|
*self.tail.borrow_mut() = tail.map(|t| (key, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.session_working.replace(working) == working {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut tail = self.tail.borrow_mut();
|
||||||
|
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
|
||||||
|
let calls = tools.calls();
|
||||||
|
tools.apply_calls(rsc, &calls, working);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn tail_card_count(&self) -> usize {
|
||||||
|
match self.tail.borrow().as_ref() {
|
||||||
|
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open or close the newest row's tool run, when it is one -- what a
|
||||||
|
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
|
||||||
|
/// displayless machine, and the tests below). Answers whether there
|
||||||
|
/// was such a row to act on, so a caller that expected one can say so
|
||||||
|
/// rather than silently producing the collapsed picture.
|
||||||
|
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let tail = self.tail.borrow();
|
||||||
|
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
tools.set_group_expanded(rsc, expanded);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `ReplaceLast` fast path: update the tail row in place if this
|
||||||
|
/// really is a change to the same row, and say whether that worked.
|
||||||
|
/// `false` for anything the caller must rebuild instead.
|
||||||
|
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let mut tail = self.tail.borrow_mut();
|
||||||
|
let Some((tail_key, kept)) = tail.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *tail_key != key {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (kept, row) {
|
||||||
|
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
|
||||||
|
let (sender, markdown_src) = row::item_content(item);
|
||||||
|
// A tool call is drawn as a card, never as markdown, so a
|
||||||
|
// row that kept blocks and now holds one is a different
|
||||||
|
// row -- rebuild it.
|
||||||
|
if matches!(
|
||||||
|
item,
|
||||||
|
crate::client::transcript_fold::TranscriptItem::ToolRun { .. }
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
blocks.apply_delta(rsc, sender, &markdown_src)
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
|
||||||
|
tools.apply_calls(rsc, calls, self.session_working.get())
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||||
|
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
|
||||||
|
}
|
||||||
|
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything else -- a row *before* the tail changed, which only
|
||||||
|
/// happens when `group_tool_runs` regroups already-seen items (a tool
|
||||||
|
/// run's calls that used to be separate rows join once the run closes)
|
||||||
|
/// -- falls back to a full rebuild: every row is dropped
|
||||||
|
/// (`LazySpan::clear`) and rebuilt from `new`. Counted in
|
||||||
|
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
|
||||||
|
/// often the fallback actually fires rather than assuming it never
|
||||||
|
/// does.
|
||||||
|
pub fn apply<Rsc: HasEvents>(
|
||||||
|
&self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
old: &[crate::client::transcript_fold::TranscriptItem],
|
||||||
|
new: &[crate::client::transcript_fold::TranscriptItem],
|
||||||
|
) where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
use crate::client::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
|
let old_rows = group_tool_runs(old);
|
||||||
|
let new_rows = group_tool_runs(new);
|
||||||
|
|
||||||
|
match diff_rows(&old_rows, &new_rows) {
|
||||||
|
RowDiff::Unchanged => {}
|
||||||
|
RowDiff::Appended { common } => {
|
||||||
|
for row in &new_rows[common..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowDiff::ReplaceLast { common } => {
|
||||||
|
let old_key = row::row_key(&old_rows[common].key());
|
||||||
|
let new_key = row::row_key(&new_rows[common].key());
|
||||||
|
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||||
|
for row in &new_rows[common + 1..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (new_key, widget, kept) = row::build_row(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
&new_rows[common],
|
||||||
|
self.session_working.get(),
|
||||||
|
false,
|
||||||
|
self.theme.clone(),
|
||||||
|
);
|
||||||
|
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
|
||||||
|
drop(evicted); // frees the old row's widget, same as a pop would
|
||||||
|
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
|
||||||
|
for row in &new_rows[common + 1..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowDiff::Rebuild => {
|
||||||
|
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||||
|
(self.list)(rsc).clear();
|
||||||
|
*self.tail.borrow_mut() = None;
|
||||||
|
for row in &new_rows {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_rebuilds(&self) -> usize {
|
||||||
|
self.rebuilds.replace(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The semantic paint IDs used by this screen. A caller can replace
|
||||||
|
/// their entries through `rsc.ui_mut().paints.set(...)`; retained text
|
||||||
|
/// and rect primitives keep the IDs and need no widget rebuild.
|
||||||
|
pub fn theme(&self) -> &Theme {
|
||||||
|
&self.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concatenated text of whatever is currently selected across one
|
||||||
|
/// or more rows, `None` if nothing is -- what a copy command reads.
|
||||||
|
pub fn selected_text<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
|
||||||
|
let id = rsc
|
||||||
|
.events()
|
||||||
|
.controllers
|
||||||
|
.id::<SelectionController>(self.list.id())?;
|
||||||
|
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
|
||||||
|
selection.selected_text(rsc)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ui_state: &mut impl HasRoot<Rsc>,
|
||||||
|
rows: Vec<FoldedRow>,
|
||||||
|
) -> TranscriptScreen
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (screen, tree) = build_tree(rsc, rows);
|
||||||
|
ui_state.set_root(rsc, tree);
|
||||||
|
screen
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_tree<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
rows: Vec<FoldedRow>,
|
||||||
|
) -> (TranscriptScreen, StrongWidget)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints));
|
||||||
|
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
|
||||||
|
list.controller(
|
||||||
|
SelectionController::new()
|
||||||
|
.with_scroll(list)
|
||||||
|
.separator("\n\n"),
|
||||||
|
)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// The last row's block widgets are kept for the same reason
|
||||||
|
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||||
|
// screen is built takes its next delta through `apply`, and a `None`
|
||||||
|
// here would send that delta down the rebuild path instead -- the
|
||||||
|
// whole message re-shaped, which is exactly what the per-block column
|
||||||
|
// exists to avoid, and nothing on screen or in `take_rebuilds` would
|
||||||
|
// say so.
|
||||||
|
let mut tail = None;
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
// `false`: a row built here is history until the caller says the
|
||||||
|
// session is working (`TranscriptScreen::set_session_working`),
|
||||||
|
// and claiming a call is running because the screen happens to be
|
||||||
|
// opening is exactly the inferred-as-measured mistake.
|
||||||
|
// `cap`: every row but the last. The last is the tail, which may
|
||||||
|
// be a reply already streaming when this screen opened, and a
|
||||||
|
// capped row cannot take a delta (`RowBlocks::capped`).
|
||||||
|
let cap = i + 1 < rows.len();
|
||||||
|
let (key, widget, kept) = row::build_row(rsc, list, row, false, cap, theme.clone());
|
||||||
|
list(rsc).push_back(LazyItem::new(key, widget));
|
||||||
|
tail = kept.map(|t| (key, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller host covers gaps as well as text, so a tap anywhere in
|
||||||
|
// the transcript can dismiss a selection. Text and link listeners may
|
||||||
|
// see the same physical sample; `SelectionController` deduplicates it by
|
||||||
|
// the sample's own timestamp while still returning the same tap decision
|
||||||
|
// to whichever leaf owns the link action.
|
||||||
|
{
|
||||||
|
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||||
|
let input = &ctx.data;
|
||||||
|
rsc.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||||
|
selection.drag(id, rsc, input)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.on(CursorSense::Scroll(Axis::Y), |ctx, rsc| {
|
||||||
|
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||||
|
ctx.widget(rsc).scroll(delta);
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let (composer, composer_bar) = composer::build_composer(rsc, &theme);
|
||||||
|
|
||||||
|
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
|
||||||
|
(
|
||||||
|
TranscriptScreen {
|
||||||
|
tail: RefCell::new(tail),
|
||||||
|
session_working: std::cell::Cell::new(false),
|
||||||
|
list,
|
||||||
|
composer,
|
||||||
|
rebuilds: std::cell::Cell::new(0),
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What changed at the tail between two folded row lists -- the decision
|
||||||
|
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
|
||||||
|
/// widget and no `Rsc`, so the three cases can be tested directly against
|
||||||
|
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
|
||||||
|
/// harness to exercise logic that never touches one.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum RowDiff {
|
||||||
|
Unchanged,
|
||||||
|
Appended { common: usize },
|
||||||
|
ReplaceLast { common: usize },
|
||||||
|
Rebuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
|
||||||
|
let common = old
|
||||||
|
.iter()
|
||||||
|
.zip(new.iter())
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if common == old.len() && common == new.len() {
|
||||||
|
RowDiff::Unchanged
|
||||||
|
} else if common == old.len() {
|
||||||
|
RowDiff::Appended { common }
|
||||||
|
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
|
||||||
|
RowDiff::ReplaceLast { common }
|
||||||
|
} else {
|
||||||
|
RowDiff::Rebuild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod diff_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::transcript_fold::TranscriptItem;
|
||||||
|
|
||||||
|
fn user(seq: u64, text: &str) -> FoldedRow {
|
||||||
|
FoldedRow::Single(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
|
||||||
|
FoldedRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: format!("id{seq}"),
|
||||||
|
run_id: run_id.to_string(),
|
||||||
|
tool: "grep".to_string(),
|
||||||
|
input: "x".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_lists_are_unchanged() {
|
||||||
|
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
|
||||||
|
let old: Vec<FoldedRow> = Vec::new();
|
||||||
|
let new = vec![user(1, "hi")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
|
||||||
|
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
|
||||||
|
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shrinking_the_list_is_a_rebuild() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
let new = vec![user(1, "hi")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod apply_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::transcript_fold::TranscriptItem;
|
||||||
|
|
||||||
|
struct TestFocus {
|
||||||
|
focus: Option<WeakWidget<TextEdit>>,
|
||||||
|
}
|
||||||
|
impl OpenUrl for TestFocus {
|
||||||
|
fn open_url(&mut self, _url: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusHost for TestFocus {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.focus = id;
|
||||||
|
}
|
||||||
|
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.focus == Some(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestRsc {
|
||||||
|
ui: UiData,
|
||||||
|
events: EventManager<TestRsc>,
|
||||||
|
}
|
||||||
|
impl UiRsc for TestRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl HasState for TestRsc {
|
||||||
|
type State = TestFocus;
|
||||||
|
}
|
||||||
|
impl HasEvents for TestRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user(seq: u64, text: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(seq: u64, text: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(paragraphs: usize, tail: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for i in 0..paragraphs {
|
||||||
|
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
|
||||||
|
}
|
||||||
|
out.push_str(tail);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
|
||||||
|
let new_items = vec![assistant(
|
||||||
|
1,
|
||||||
|
&reply(paragraphs, "and the last one is still going."),
|
||||||
|
)];
|
||||||
|
let (screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
crate::client::transcript_fold::group_tool_runs(&old_items),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &old_items, &new_items);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
||||||
|
let (draws, _, _, shapes) = render.take_counters();
|
||||||
|
(draws, shapes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
|
||||||
|
assert!(
|
||||||
|
reply(100, "").len() > 3_000,
|
||||||
|
"the long case must actually be a long message"
|
||||||
|
);
|
||||||
|
let (short_draws, short_shapes) = cost_of_one_delta(1);
|
||||||
|
let (long_draws, long_shapes) = cost_of_one_delta(100);
|
||||||
|
assert_eq!(
|
||||||
|
short_draws, long_draws,
|
||||||
|
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
|
||||||
|
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(short_shapes, long_shapes),
|
||||||
|
(1, 1),
|
||||||
|
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
|
||||||
|
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 1,
|
||||||
|
id: id.to_string(),
|
||||||
|
run_id: "run".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
|
||||||
|
output: output.to_string(),
|
||||||
|
done,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| call(&format!("t{i}"), output, done))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_run(
|
||||||
|
rsc: &mut TestRsc,
|
||||||
|
items: &[TranscriptItem],
|
||||||
|
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
|
||||||
|
let (screen, tree) =
|
||||||
|
build_tree(rsc, crate::client::transcript_fold::group_tool_runs(items));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, rsc);
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(rsc, true),
|
||||||
|
"the fixture's only row must be the tool run"
|
||||||
|
);
|
||||||
|
render.update(&tree, rsc);
|
||||||
|
render.take_counters();
|
||||||
|
(screen, tree, render)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shapes_to_open(output: &str) -> u64 {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let items = run_of(3, output, true);
|
||||||
|
let (screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
crate::client::transcript_fold::group_tool_runs(&items),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(&mut rsc, true),
|
||||||
|
"the fixture's only row must be the tool run"
|
||||||
|
);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
let (_, _, _, shapes) = render.take_counters();
|
||||||
|
shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsed_cards_shape_only_their_summary_lines() {
|
||||||
|
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
|
||||||
|
assert!(long.len() > 80_000, "the long case must actually be long");
|
||||||
|
|
||||||
|
let short_shapes = shapes_to_open("ok\n");
|
||||||
|
let long_shapes = shapes_to_open(&long);
|
||||||
|
assert!(
|
||||||
|
short_shapes > 0,
|
||||||
|
"opening a group must shape something, or this compares two zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
short_shapes, long_shapes,
|
||||||
|
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
|
||||||
|
against {short_shapes} over three bytes -- a collapsed card is laying out \
|
||||||
|
something it does not draw"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shapes_for_message(text: &str) -> u64 {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let items = vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "ok".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (_screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
crate::client::transcript_fold::group_tool_runs(&items),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
let (_, _, _, shapes) = render.take_counters();
|
||||||
|
shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
|
||||||
|
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
|
||||||
|
let capped = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 4));
|
||||||
|
let bigger = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 40));
|
||||||
|
assert!(
|
||||||
|
capped > 0,
|
||||||
|
"the screen shaped nothing, so this compares zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capped, bigger,
|
||||||
|
"a message ten times longer cost {bigger} text layouts against {capped} -- the cap \
|
||||||
|
is not bounding what gets laid out",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_of_one_result(count: usize) -> u64 {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(count, "", false);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after[0] = call("t0", "the result", true);
|
||||||
|
|
||||||
|
let (screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"a result arriving must not rebuild the whole screen"
|
||||||
|
);
|
||||||
|
let (draws, _, _, _) = render.take_counters();
|
||||||
|
draws
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
|
||||||
|
let small = cost_of_one_result(3);
|
||||||
|
let large = cost_of_one_result(12);
|
||||||
|
assert!(
|
||||||
|
small > 0,
|
||||||
|
"a result must redraw *something*, or this compares two zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
small, large,
|
||||||
|
"one result redrew {large} widgets in a twelve-call run against {small} in a \
|
||||||
|
three-call one -- the other cards are being rebuilt with it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(3, "", false);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after[1] = call("t1", "done", true);
|
||||||
|
|
||||||
|
let (screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
crate::client::transcript_fold::group_tool_runs(&before),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
assert!(screen.expand_tail_tools(&mut rsc, true));
|
||||||
|
assert_eq!(screen.tail_card_count(), 3);
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(screen.take_rebuilds(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
screen.tail_card_count(),
|
||||||
|
3,
|
||||||
|
"the group closed under a result"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(screen.expand_tail_tools(&mut rsc, false));
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_joining_an_open_run_appends_one_card() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(2, "ok", true);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.push(call("t2", "", false));
|
||||||
|
|
||||||
|
let (screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||||
|
assert_eq!(screen.tail_card_count(), 2);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"an appended call is not a rebuild"
|
||||||
|
);
|
||||||
|
assert_eq!(screen.tail_card_count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = vec![user(1, "stable"), call("t0", "", false)];
|
||||||
|
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
|
||||||
|
let (screen, _tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
crate::client::transcript_fold::group_tool_runs(&before),
|
||||||
|
);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"this is a ReplaceLast, not a whole-screen rebuild"
|
||||||
|
);
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
|
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
|
||||||
|
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
|
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||||
|
use crate::ui::tap::{hold_edge, on_tap};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use crate::ui::tool::ToolRow;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
const BLOCK_GAP_DP: f32 = 8.0;
|
||||||
|
|
||||||
|
pub const BASE_SIZE: f32 = 16.0;
|
||||||
|
|
||||||
|
/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants.
|
||||||
|
/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into
|
||||||
|
/// one -- collisions are not a correctness risk worth guarding against here
|
||||||
|
/// (a `DefaultHasher` collision across the run ids one session produces is
|
||||||
|
/// astronomically unlikely, and the consequence of one would only be two
|
||||||
|
/// tool-call rows sharing a list slot, not data loss), and the high bit is
|
||||||
|
/// forced on so a hashed key can never collide with a real sequence number
|
||||||
|
/// (this build never produces 2^63 events).
|
||||||
|
pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey {
|
||||||
|
use crate::client::transcript_fold::ItemKey;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
match key {
|
||||||
|
ItemKey::Seq(seq) => *seq,
|
||||||
|
ItemKey::RunId(id) => {
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
id.hash(&mut h);
|
||||||
|
h.finish() | (1 << 63)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sender label shown above a row's text, and the markdown source to
|
||||||
|
/// render below it. `None` for a system-style note that has no sender.
|
||||||
|
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
||||||
|
match item {
|
||||||
|
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
||||||
|
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
|
||||||
|
TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()),
|
||||||
|
TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")),
|
||||||
|
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
|
||||||
|
TranscriptItem::Note { text, .. } => (None, text.clone()),
|
||||||
|
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
|
||||||
|
// Epoch seconds as-is until the port has a relative-time formatter
|
||||||
|
// (P1); the Compose `LimitRow` draws it as a countdown.
|
||||||
|
TranscriptItem::LimitNote { resets_at, .. } => (
|
||||||
|
None,
|
||||||
|
match resets_at {
|
||||||
|
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
|
||||||
|
None => "_Usage limit reached._".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TranscriptItem::CompactedNote {
|
||||||
|
pre_tokens,
|
||||||
|
post_tokens,
|
||||||
|
..
|
||||||
|
} => (
|
||||||
|
None,
|
||||||
|
match (pre_tokens, post_tokens) {
|
||||||
|
(Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"),
|
||||||
|
_ => "_Compacted._".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")),
|
||||||
|
TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)),
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} => (Some(tool.as_str()), tool_call_markdown(tool, input, output)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn question_markdown(card: &QuestionCard) -> String {
|
||||||
|
let mut out = card.prompt.clone();
|
||||||
|
for opt in &card.options {
|
||||||
|
out.push_str(&format!("\n- {}", opt.label));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
|
||||||
|
let mut out = format!("**{tool}**\n\n```\n{input}\n```");
|
||||||
|
if !output.is_empty() {
|
||||||
|
out.push_str(&format!("\n\n```\n{output}\n```"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RowBlocks {
|
||||||
|
blocks: Vec<Block>,
|
||||||
|
fields: Vec<WeakWidget<Text>>,
|
||||||
|
links: Vec<Rc<RefCell<Vec<Link>>>>,
|
||||||
|
column: WeakWidget<Span>,
|
||||||
|
sender: Option<String>,
|
||||||
|
/// Whether this row draws less than the whole message
|
||||||
|
/// ([`cap_message`]). A delta cannot be appended to a capped row --
|
||||||
|
/// the new text would go on *below* the "Show all" that says it is
|
||||||
|
/// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller
|
||||||
|
/// rebuilds instead.
|
||||||
|
///
|
||||||
|
/// Never `true` for the row a reply is actually streaming into: the
|
||||||
|
/// live tail is built uncapped ([`build_row`]'s `cap`), which is what
|
||||||
|
/// keeps the refusal from costing anything in practice. This field is
|
||||||
|
/// the belt to that braces.
|
||||||
|
capped: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split for display: never empty, so a row with nothing in it yet is
|
||||||
|
/// still one (empty) text widget rather than no widget at all -- an empty
|
||||||
|
/// column reports a zero size and the row would vanish from the list.
|
||||||
|
fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
||||||
|
let blocks = split_blocks(markdown_src);
|
||||||
|
if blocks.is_empty() {
|
||||||
|
vec![Block {
|
||||||
|
kind: BlockKind::Paragraph,
|
||||||
|
source: markdown_src.to_string(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `blocks` cut to what a row draws, with the line count of the **whole**
|
||||||
|
/// message; `None` when all of it fits.
|
||||||
|
///
|
||||||
|
/// The cut prefers a **block boundary**, because a message is markdown and
|
||||||
|
/// a whole paragraph is a smaller version of a message in a way that half
|
||||||
|
/// a paragraph is not. Where one block is over the bound by itself -- the
|
||||||
|
/// reply that is one enormous fence -- that block is truncated instead of
|
||||||
|
/// being dropped or drawn whole: dropping it would leave a row saying
|
||||||
|
/// nothing, and a truncated fence still renders as a fence, since the
|
||||||
|
/// renderer already knows the block's kind and pulldown-cmark closes an
|
||||||
|
/// unterminated one at the end of its input.
|
||||||
|
fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
|
||||||
|
let total = || blocks.iter().map(|b| b.source.lines().count()).sum();
|
||||||
|
if !cap {
|
||||||
|
return (blocks, None);
|
||||||
|
}
|
||||||
|
let mut kept = Vec::with_capacity(blocks.len());
|
||||||
|
let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES);
|
||||||
|
for block in &blocks {
|
||||||
|
if lines_left == 0 || bytes_left == 0 {
|
||||||
|
return (kept, Some(total()));
|
||||||
|
}
|
||||||
|
match cut(&block.source, lines_left, bytes_left) {
|
||||||
|
Some((head, _)) if kept.is_empty() => {
|
||||||
|
kept.push(Block {
|
||||||
|
kind: block.kind,
|
||||||
|
source: head.to_string(),
|
||||||
|
});
|
||||||
|
return (kept, Some(total()));
|
||||||
|
}
|
||||||
|
Some(_) => return (kept, Some(total())),
|
||||||
|
None => {
|
||||||
|
lines_left -= block.source.lines().count().min(lines_left);
|
||||||
|
bytes_left -= block.source.len().min(bytes_left);
|
||||||
|
kept.push(block.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(kept, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message's own text, kept so that asking for the whole of a capped row
|
||||||
|
/// can rebuild it. `Rc` rather than a copy per closure: the source of a
|
||||||
|
/// long message is the largest string in the row, and the tap handler
|
||||||
|
/// would otherwise hold a second one for the lifetime of the row.
|
||||||
|
struct RowSource {
|
||||||
|
sender: Option<String>,
|
||||||
|
markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAME_PAD_DP: f32 = 10.0;
|
||||||
|
const QUOTE_BAR_DP: f32 = 3.0;
|
||||||
|
const FRAME_RADIUS_DP: f32 = 8.0;
|
||||||
|
|
||||||
|
fn build_block<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
block: &Block,
|
||||||
|
theme: &Theme,
|
||||||
|
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let frame = frame_of(block.kind, theme);
|
||||||
|
let rendered = render_block(block, BASE_SIZE, theme);
|
||||||
|
let links = Rc::new(RefCell::new(rendered.links));
|
||||||
|
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
|
||||||
|
let field = wtext(rendered.text)
|
||||||
|
.spans(rendered.spans)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(!verbatim)
|
||||||
|
.family(if verbatim {
|
||||||
|
Family::Monospace
|
||||||
|
} else {
|
||||||
|
Family::SansSerif
|
||||||
|
})
|
||||||
|
.size(BASE_SIZE)
|
||||||
|
.color(match frame {
|
||||||
|
BlockFrame::Quote => theme.quote_text.clone(),
|
||||||
|
_ => theme.text.clone(),
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let tap_links = links.clone();
|
||||||
|
field
|
||||||
|
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||||
|
let (pos, size) = (ctx.data.pos, ctx.data.size);
|
||||||
|
let input = &ctx.data;
|
||||||
|
let outcome = rsc
|
||||||
|
.with_nearest_controller::<SelectionController, _>(field, |id, selection, rsc| {
|
||||||
|
selection.drag(id, rsc, input)
|
||||||
|
})
|
||||||
|
.unwrap_or(SelectionInput::Tapped);
|
||||||
|
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||||
|
// the selection are: a gesture that panned the list past
|
||||||
|
// this link, or held long enough to select, must not also
|
||||||
|
// follow it (`GestureOutcome::Tapped`'s doc).
|
||||||
|
if outcome == SelectionInput::Tapped {
|
||||||
|
let byte = field.selection(rsc).byte_at(pos, size);
|
||||||
|
let url = tap_links
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.range.contains(&byte))
|
||||||
|
.map(|l| l.url.clone());
|
||||||
|
if let Some(url) = url {
|
||||||
|
log::info!("iris link: opening {url}");
|
||||||
|
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let framed = match frame {
|
||||||
|
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||||
|
BlockFrame::Verbatim { fill } => field
|
||||||
|
.scrollable(Axis::X, Pin::Start)
|
||||||
|
.pad(dp(FRAME_PAD_DP))
|
||||||
|
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
BlockFrame::Quote => field
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(Padding {
|
||||||
|
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
|
||||||
|
..Padding::ZERO
|
||||||
|
})
|
||||||
|
.background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
};
|
||||||
|
(field, framed, links)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_text_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
sender: Option<&str>,
|
||||||
|
markdown_src: &str,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, RowBlocks)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let source = Rc::new(RowSource {
|
||||||
|
sender: sender.map(str::to_string),
|
||||||
|
markdown: markdown_src.to_string(),
|
||||||
|
});
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
let (content, blocks) = row_content(rsc, list, key, source, ptr, cap, theme);
|
||||||
|
ptr(rsc).set(content);
|
||||||
|
(strong.any(), blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate from [`build_text_row`] because the tap calls it a second
|
||||||
|
/// time, with `cap` false, and writes the result back into the same
|
||||||
|
/// `WidgetPtr`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn row_content<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
source: Rc<RowSource>,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, RowBlocks)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap);
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
||||||
|
let mut fields = Vec::with_capacity(blocks.len());
|
||||||
|
let mut links = Vec::with_capacity(blocks.len());
|
||||||
|
for block in &blocks {
|
||||||
|
let (field, framed, block_links) = build_block(rsc, block, &theme);
|
||||||
|
fields.push(field);
|
||||||
|
links.push(block_links);
|
||||||
|
column.push(framed);
|
||||||
|
}
|
||||||
|
if let Some(lines) = hidden {
|
||||||
|
column.push(show_all(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
key,
|
||||||
|
source.clone(),
|
||||||
|
ptr,
|
||||||
|
lines,
|
||||||
|
theme.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let column = column.add(rsc);
|
||||||
|
|
||||||
|
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
|
||||||
|
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
|
||||||
|
// what performs the *one* real strong registration each child gets.
|
||||||
|
// Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()`
|
||||||
|
// copy into that composition, tried to strong-register the same id
|
||||||
|
// twice and panicked with "was already added"
|
||||||
|
// (`core/src/widget/like.rs:12`) -- found running this crate's own
|
||||||
|
// `run-headless.sh` example, the first real render of a row.
|
||||||
|
let header: WeakWidget = match &source.sender {
|
||||||
|
Some(name) => wtext(name.clone())
|
||||||
|
.size(13.0)
|
||||||
|
.color(theme.secondary_text.clone())
|
||||||
|
.add(rsc),
|
||||||
|
None => Span::empty(Dir::DOWN).add(rsc),
|
||||||
|
};
|
||||||
|
|
||||||
|
let widget = (header, column.width(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.gap(dp(4))
|
||||||
|
.pad(dp(10))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
(
|
||||||
|
widget,
|
||||||
|
RowBlocks {
|
||||||
|
blocks,
|
||||||
|
fields,
|
||||||
|
links,
|
||||||
|
column,
|
||||||
|
sender: source.sender.clone(),
|
||||||
|
capped: hidden.is_some(),
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `RowBlocks` the rebuild produces is **discarded**, because a capped
|
||||||
|
/// row is never the row a reply is streaming into (`build_row`'s `cap`) --
|
||||||
|
/// so nothing is holding one for it, and there is nothing to keep in step.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn show_all<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
source: Rc<RowSource>,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
lines: usize,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let label = show_all_label(lines);
|
||||||
|
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let more = more_strong.weak();
|
||||||
|
let words = wtext(label.clone())
|
||||||
|
.size(13.0)
|
||||||
|
.color(theme.secondary_text.clone())
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.label(label)
|
||||||
|
.add_strong(rsc);
|
||||||
|
more(rsc).set(words);
|
||||||
|
on_tap(rsc, more, list, move |rsc| {
|
||||||
|
hold_edge(rsc, list, key);
|
||||||
|
let (content, _blocks) =
|
||||||
|
row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
});
|
||||||
|
more_strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowBlocks {
|
||||||
|
/// Bring this row up to date with `markdown_src` **without** re-laying
|
||||||
|
/// out the blocks that did not change, and say whether that was
|
||||||
|
/// possible. `false` means the caller must rebuild the row the
|
||||||
|
/// ordinary way: an earlier block was rewritten (markdown allows it --
|
||||||
|
/// a trailing `---` turns the paragraph above into a heading), the
|
||||||
|
/// sender changed, or the message got shorter.
|
||||||
|
pub fn apply_delta<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
sender: Option<&str>,
|
||||||
|
markdown_src: &str,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.sender.as_deref() != sender {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A capped row draws less than the message it was built from, so
|
||||||
|
// appending to it would put the new text *below* the "Show all"
|
||||||
|
// saying the rest is hidden. The caller rebuilds instead, and
|
||||||
|
// rebuilds uncapped (`TranscriptScreen::apply`), so this refusal
|
||||||
|
// costs one rebuild per message rather than one per delta.
|
||||||
|
if self.capped {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let new_blocks = display_blocks(markdown_src);
|
||||||
|
let common = common_prefix(&self.blocks, &new_blocks);
|
||||||
|
// Everything already drawn must either be kept whole (`common ==
|
||||||
|
// len`, a pure append) or be kept except for the last block, which
|
||||||
|
// is the one a delta lands in. Anything else means an already
|
||||||
|
// laid-out block is no longer what it was.
|
||||||
|
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if new_blocks.len() == self.blocks.len()
|
||||||
|
&& common < self.blocks.len()
|
||||||
|
&& new_blocks[common].kind != self.blocks[common].kind
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
debug_assert!(
|
||||||
|
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
|
||||||
|
"one field and one link list per block: {} fields, {} links, {} blocks",
|
||||||
|
self.fields.len(),
|
||||||
|
self.links.len(),
|
||||||
|
self.blocks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
||||||
|
match (self.fields.get(i), self.links.get(i)) {
|
||||||
|
(Some(field), Some(links)) => {
|
||||||
|
let rendered = render_block(block, BASE_SIZE, &self.theme);
|
||||||
|
field(rsc).set_with_spans(rendered.text, rendered.spans);
|
||||||
|
*links.borrow_mut() = rendered.links;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let (field, framed, links) = build_block(rsc, block, &self.theme);
|
||||||
|
self.fields.push(field);
|
||||||
|
self.links.push(links);
|
||||||
|
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||||
|
column.push(framed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.blocks = new_blocks;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_single<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
item: &TranscriptItem,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, RowBlocks)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (sender, markdown_src) = item_content(item);
|
||||||
|
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two mechanisms would have been two answers to the same question ("what
|
||||||
|
/// can this row do cheaply?"), so the caller holds one of these for its
|
||||||
|
/// tail row and asks it, rather than holding a `RowBlocks` and a
|
||||||
|
/// `ToolRow` and choosing between them at each call site.
|
||||||
|
pub enum TailRow {
|
||||||
|
Blocks(RowBlocks),
|
||||||
|
Tools(ToolRow),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cap` draws a long message as [`cap_message`]'s worth of it behind a
|
||||||
|
/// "Show all"; the caller passes `false` for the **live tail**, the row a
|
||||||
|
/// reply is streaming into, because a row that grows while it is capped
|
||||||
|
/// would appear to stop growing (`RowBlocks::capped`). Every other row is
|
||||||
|
/// capped.
|
||||||
|
pub fn build_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
row: &FoldedRow,
|
||||||
|
working: bool,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (RowKey, StrongWidget, Option<TailRow>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
// A lone tool call is a card too, not a message with markdown in it:
|
||||||
|
// `group_tool_runs` leaves one call as a `Single` because "Called 1
|
||||||
|
// tool" hides a card to say the same thing in more words, and the
|
||||||
|
// *card* is what both cases draw (`ToolRows.kt`).
|
||||||
|
let calls = match row {
|
||||||
|
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
|
||||||
|
Some(std::slice::from_ref(item))
|
||||||
|
}
|
||||||
|
FoldedRow::Tools(calls) => Some(calls.as_slice()),
|
||||||
|
FoldedRow::Single(_) => None,
|
||||||
|
};
|
||||||
|
if let Some(calls) = calls {
|
||||||
|
let key = row_key(&calls[0].key());
|
||||||
|
let (widget, tools) =
|
||||||
|
crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
|
||||||
|
return (key, widget, Some(TailRow::Tools(tools)));
|
||||||
|
}
|
||||||
|
let FoldedRow::Single(item) = row else {
|
||||||
|
unreachable!("every Tools row took the branch above");
|
||||||
|
};
|
||||||
|
let key = row_key(&item.key());
|
||||||
|
let (widget, blocks) = build_single(rsc, list, key, item, cap, theme);
|
||||||
|
(key, widget, Some(TailRow::Blocks(blocks)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn blocks(src: &str) -> Vec<Block> {
|
||||||
|
display_blocks(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_inside_the_bounds_is_not_capped() {
|
||||||
|
let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true);
|
||||||
|
assert_eq!(kept.len(), 2);
|
||||||
|
assert_eq!(hidden, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_false_keeps_everything() {
|
||||||
|
let src = "a\n\n".repeat(MESSAGE_LINES * 2);
|
||||||
|
let (kept, hidden) = cap_message(blocks(&src), false);
|
||||||
|
assert_eq!(kept.len(), MESSAGE_LINES * 2);
|
||||||
|
assert_eq!(hidden, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_message_is_cut_on_a_block_boundary() {
|
||||||
|
let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2);
|
||||||
|
let all = blocks(&src);
|
||||||
|
let (kept, hidden) = cap_message(all.clone(), true);
|
||||||
|
assert!(kept.len() < all.len(), "nothing was left out");
|
||||||
|
assert!(
|
||||||
|
kept.iter().zip(&all).all(|(k, a)| k == a),
|
||||||
|
"a block was truncated where a boundary was available",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hidden,
|
||||||
|
Some(all.iter().map(|b| b.source.lines().count()).sum()),
|
||||||
|
"the offer says the whole message's line count, not the shown part's",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_block_over_the_bound_by_itself_is_truncated() {
|
||||||
|
let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2));
|
||||||
|
let all = blocks(&src);
|
||||||
|
assert_eq!(all.len(), 1, "the fixture must be a single block");
|
||||||
|
let (kept, hidden) = cap_message(all.clone(), true);
|
||||||
|
assert_eq!(kept.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
kept[0].kind, all[0].kind,
|
||||||
|
"truncation changed the block's kind"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kept[0].source.len() < all[0].source.len(),
|
||||||
|
"the one over-long block was drawn whole",
|
||||||
|
);
|
||||||
|
assert!(hidden.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
pub(crate) fn on_tap<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
f: impl Fn(&mut Rsc) + 'static,
|
||||||
|
) where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||||
|
let input = &ctx.data;
|
||||||
|
let outcome = rsc
|
||||||
|
.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||||
|
selection.drag(id, rsc, input)
|
||||||
|
})
|
||||||
|
.unwrap_or(SelectionInput::Tapped);
|
||||||
|
if outcome == SelectionInput::Tapped {
|
||||||
|
f(rsc);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget<LazySpan>, key: RowKey) {
|
||||||
|
if let Some((top, _bottom)) = list(rsc).extent(key) {
|
||||||
|
list(rsc).note_tap(top);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
/// The shared phone/desktop paint handles. Replacing their paint-table
|
||||||
|
/// entries changes the theme without rebuilding widgets or primitives.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub text: PaintId,
|
||||||
|
pub code: PaintId,
|
||||||
|
pub link: PaintId,
|
||||||
|
pub marker: PaintId,
|
||||||
|
pub verbatim_surface: PaintId,
|
||||||
|
pub table_surface: PaintId,
|
||||||
|
pub quote_bar: PaintId,
|
||||||
|
pub quote_text: PaintId,
|
||||||
|
pub strikethrough: PaintId,
|
||||||
|
pub card_surface: PaintId,
|
||||||
|
pub group_surface: PaintId,
|
||||||
|
pub muted: PaintId,
|
||||||
|
pub awaiting: PaintId,
|
||||||
|
pub failed: PaintId,
|
||||||
|
pub unknown: PaintId,
|
||||||
|
pub composer_surface: PaintId,
|
||||||
|
pub secondary_text: PaintId,
|
||||||
|
pub syntax_keyword: PaintId,
|
||||||
|
pub syntax_string: PaintId,
|
||||||
|
pub syntax_literal: PaintId,
|
||||||
|
pub syntax_comment: PaintId,
|
||||||
|
pub syntax_metadata: PaintId,
|
||||||
|
pub syntax_punctuation: PaintId,
|
||||||
|
pub syntax_mark: PaintId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn new(paints: &mut Paints) -> Self {
|
||||||
|
Self {
|
||||||
|
text: paints.add(srgb(0xCDD6F4)),
|
||||||
|
code: paints.add(srgb(0xCDD6F4)),
|
||||||
|
link: paints.add(srgb(0x89B4FA)),
|
||||||
|
marker: paints.add(srgb(0xB4BEFE)),
|
||||||
|
verbatim_surface: paints.add(srgb(0x11111B)),
|
||||||
|
table_surface: paints.add(srgb(0x313244)),
|
||||||
|
quote_bar: paints.add(srgb(0x585B70)),
|
||||||
|
quote_text: paints.add(srgb(0xA6ADC8)),
|
||||||
|
strikethrough: paints.add(srgb(0x6C7086)),
|
||||||
|
card_surface: paints.add(srgb(0x313244)),
|
||||||
|
group_surface: paints.add(srgb(0x181825)),
|
||||||
|
muted: paints.add(srgb(0xA6ADC8)),
|
||||||
|
awaiting: paints.add(srgb(0xFAB387)),
|
||||||
|
failed: paints.add(srgb(0xF38BA8)),
|
||||||
|
unknown: paints.add(srgb(0xF9E2AF)),
|
||||||
|
composer_surface: paints.add(Srgba8::rgb(40, 40, 46)),
|
||||||
|
secondary_text: paints.add(Srgba8::rgb(150, 150, 160)),
|
||||||
|
syntax_keyword: paints.add(srgb(0xCBA6F7)),
|
||||||
|
syntax_string: paints.add(srgb(0xA6E3A1)),
|
||||||
|
syntax_literal: paints.add(srgb(0xFAB387)),
|
||||||
|
syntax_comment: paints.add(srgb(0x6C7086)),
|
||||||
|
syntax_metadata: paints.add(srgb(0xF9E2AF)),
|
||||||
|
syntax_punctuation: paints.add(srgb(0xA6ADC8)),
|
||||||
|
syntax_mark: paints.add(srgb(0x89DCEB)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn srgb(hex: u32) -> Srgba8 {
|
||||||
|
Srgba8::rgb(
|
||||||
|
((hex >> 16) & 0xff) as u8,
|
||||||
|
((hex >> 8) & 0xff) as u8,
|
||||||
|
(hex & 0xff) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
|
||||||
|
use crate::client::tool_summary::{ToolInput, parse_tool_input};
|
||||||
|
use crate::client::transcript_fold::{ToolState, TranscriptItem};
|
||||||
|
use crate::ui::markdown::highlight_into;
|
||||||
|
use crate::ui::tap::{hold_edge, on_tap};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||||
|
|
||||||
|
const NAME_SIZE: f32 = 14.0;
|
||||||
|
const BODY_SIZE: f32 = 12.0;
|
||||||
|
const LABEL_SIZE: f32 = 11.0;
|
||||||
|
|
||||||
|
const CARD_PAD_DP: f32 = 12.0;
|
||||||
|
const CARD_RADIUS_DP: f32 = 12.0;
|
||||||
|
const GAP_DP: f32 = 8.0;
|
||||||
|
const RAW_RADIUS_DP: f32 = 4.0;
|
||||||
|
const RAW_PAD_DP: f32 = 8.0;
|
||||||
|
const GROUP_INSET_DP: f32 = 4.0;
|
||||||
|
|
||||||
|
const MARK_DP: f32 = 9.0;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ToolRowState {
|
||||||
|
group_expanded: bool,
|
||||||
|
open: HashMap<String, bool>,
|
||||||
|
whole: HashMap<(String, Part), bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
|
enum Part {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Shared {
|
||||||
|
calls: RefCell<Vec<TranscriptItem>>,
|
||||||
|
state: RefCell<ToolRowState>,
|
||||||
|
/// One `WidgetPtr` per call, in order -- what makes a result cost one
|
||||||
|
/// card. Empty while the group is collapsed, because a collapsed group
|
||||||
|
/// draws no cards at all. Its path out is [`build_content`], which
|
||||||
|
/// clears it before building whatever replaces them.
|
||||||
|
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
|
||||||
|
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
working: Cell<bool>,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One transcript row's worth of tool calls, kept by the caller for the
|
||||||
|
/// row a result can still land in -- the tool-call counterpart of
|
||||||
|
/// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card
|
||||||
|
/// rather than a row.
|
||||||
|
pub struct ToolRow {
|
||||||
|
shared: Rc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
|
||||||
|
wtext(content)
|
||||||
|
.size(size)
|
||||||
|
.color(color)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
|
||||||
|
text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
let field = body
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.size(BODY_SIZE)
|
||||||
|
.wrap(false)
|
||||||
|
.add(rsc);
|
||||||
|
field
|
||||||
|
.scrollable(Axis::X, Pin::Start)
|
||||||
|
.pad(dp(RAW_PAD_DP))
|
||||||
|
.masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_word(state: ToolState) -> Option<&'static str> {
|
||||||
|
match state {
|
||||||
|
ToolState::Deciding => Some("your turn"),
|
||||||
|
ToolState::Running => Some("running"),
|
||||||
|
ToolState::Failed => Some("failed"),
|
||||||
|
ToolState::NoResult => Some("no result"),
|
||||||
|
ToolState::Succeeded => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> {
|
||||||
|
let color = match state {
|
||||||
|
ToolState::Deciding => theme.awaiting.clone(),
|
||||||
|
ToolState::Running => theme.muted.clone(),
|
||||||
|
ToolState::Failed => theme.failed.clone(),
|
||||||
|
ToolState::NoResult => theme.unknown.clone(),
|
||||||
|
ToolState::Succeeded => return None,
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
state_word(state).expect("non-success state has a label"),
|
||||||
|
color,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a screen reader is given for one card, and what a `ui-trace`
|
||||||
|
/// script taps by: the tool, what the call is for, and how it went when
|
||||||
|
/// that is anything but "fine" -- the same three things the Compose card's
|
||||||
|
/// own text says, in the order it says them.
|
||||||
|
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
|
||||||
|
let mut name = tool.to_string();
|
||||||
|
if let Some(title) = parsed.title() {
|
||||||
|
name.push_str(": ");
|
||||||
|
name.push_str(title);
|
||||||
|
}
|
||||||
|
if let Some(word) = state_word(state) {
|
||||||
|
name.push_str(" (");
|
||||||
|
name.push_str(word);
|
||||||
|
name.push(')');
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading a group carries, closed or open. Compose's exact wording,
|
||||||
|
/// because it is also the name every `ui-trace` script taps it by.
|
||||||
|
fn group_label(count: usize) -> String {
|
||||||
|
format!("Called {count} tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
|
||||||
|
match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
|
||||||
|
Some((head, lines)) if !whole => (head, lines, true),
|
||||||
|
Some((_, lines)) => (body, lines, false),
|
||||||
|
None => (body, body.lines().count(), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
|
||||||
|
shared
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.whole
|
||||||
|
.get(&(id.to_string(), part))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control rather than a note, and it says the count rather than "more",
|
||||||
|
/// because the reader is deciding whether to ask for it: "Show all 4,000
|
||||||
|
/// lines" and "Show all 12 lines" are different decisions and the word
|
||||||
|
/// "more" tells them apart not at all.
|
||||||
|
fn show_all<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
id: &str,
|
||||||
|
part: Part,
|
||||||
|
lines: usize,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let label = show_all_label(lines);
|
||||||
|
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let more = more_strong.weak();
|
||||||
|
let words = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone())
|
||||||
|
.label(label)
|
||||||
|
.add_strong(rsc);
|
||||||
|
more(rsc).set(words);
|
||||||
|
let shared_for_tap = shared.clone();
|
||||||
|
let key = (id.to_string(), part);
|
||||||
|
on_tap(rsc, more, shared.list, move |rsc| {
|
||||||
|
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
|
||||||
|
shared_for_tap
|
||||||
|
.state
|
||||||
|
.borrow_mut()
|
||||||
|
.whole
|
||||||
|
.insert(key.clone(), true);
|
||||||
|
redraw_card(rsc, &shared_for_tap, index);
|
||||||
|
});
|
||||||
|
more_strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_block<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
id: &str,
|
||||||
|
output: &str,
|
||||||
|
call_state: ToolState,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if output.is_empty() {
|
||||||
|
let (words, colour) = match call_state {
|
||||||
|
ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
|
||||||
|
ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
|
||||||
|
ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
|
||||||
|
ToolState::Running | ToolState::Deciding => {
|
||||||
|
("No output yet", shared.theme.muted.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
|
||||||
|
column.push(
|
||||||
|
text("Output", LABEL_SIZE, shared.theme.text.clone())
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone());
|
||||||
|
column.push(raw_block(rsc, body, &shared.theme));
|
||||||
|
if was_cut {
|
||||||
|
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
|
||||||
|
}
|
||||||
|
column.width(rest(1)).add_strong(rsc).any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let call = shared.calls.borrow()[index].clone();
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} = &call
|
||||||
|
else {
|
||||||
|
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
|
||||||
|
return Span::empty(Dir::DOWN).add_strong(rsc).any();
|
||||||
|
};
|
||||||
|
let parsed = parse_tool_input(tool, input);
|
||||||
|
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
|
||||||
|
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|
||||||
|
|| call_state == ToolState::Deciding;
|
||||||
|
|
||||||
|
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
|
||||||
|
header.push(
|
||||||
|
disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
header.push(
|
||||||
|
text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
match (open, parsed.title()) {
|
||||||
|
(true, _) | (false, None) => {
|
||||||
|
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
|
||||||
|
}
|
||||||
|
(false, Some(title)) => header.push(
|
||||||
|
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
|
||||||
|
.wrap(false)
|
||||||
|
.masked()
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if open && let Some(timeout) = &parsed.timeout {
|
||||||
|
header.push(
|
||||||
|
text(
|
||||||
|
format!("timeout {timeout}"),
|
||||||
|
LABEL_SIZE,
|
||||||
|
shared.theme.muted.clone(),
|
||||||
|
)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((word, colour)) = state_mark(call_state, &shared.theme) {
|
||||||
|
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
|
||||||
|
column.push(header.width(rest(1)).add_strong(rsc).any());
|
||||||
|
if open {
|
||||||
|
if let Some(description) = &parsed.description {
|
||||||
|
column.push(
|
||||||
|
text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let whole = wants_whole(shared, id, Part::Input);
|
||||||
|
let mut input_lines = 0usize;
|
||||||
|
let mut input_cut = false;
|
||||||
|
if let Some(subject) = &parsed.subject {
|
||||||
|
let (shown, lines, was_cut) = capped(subject, whole);
|
||||||
|
input_lines += lines;
|
||||||
|
input_cut |= was_cut;
|
||||||
|
let spans = match parsed.language {
|
||||||
|
Some(language) => {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme);
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
|
||||||
|
column.push(raw_block(rsc, body, &shared.theme));
|
||||||
|
}
|
||||||
|
if !parsed.rest.is_empty() {
|
||||||
|
// Never dropped: a field left out would be claiming the tool
|
||||||
|
// had no other input when it might (`ToolInput.kt`). Capped is
|
||||||
|
// not dropped -- the field is still there, with its size said
|
||||||
|
// out loud.
|
||||||
|
let joined = parsed.rest.join("\n");
|
||||||
|
let (shown, lines, was_cut) = capped(&joined, whole);
|
||||||
|
input_lines += lines;
|
||||||
|
input_cut |= was_cut;
|
||||||
|
let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
|
||||||
|
column.push(raw_block(rsc, body, &shared.theme));
|
||||||
|
}
|
||||||
|
if input_cut {
|
||||||
|
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
|
||||||
|
}
|
||||||
|
column.push(output_block(rsc, shared, index, id, output, call_state));
|
||||||
|
}
|
||||||
|
|
||||||
|
column
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(card_label(tool, &parsed, call_state))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let Some(ptr) = shared.card_ptr(index) else {
|
||||||
|
debug_assert!(false, "card {index} has no widget to redraw");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let content = build_card(rsc, shared, index);
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card_ptr<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
) -> (StrongWidget, WeakWidget<WidgetPtr>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
shared.cards.borrow_mut().push(ptr);
|
||||||
|
debug_assert_eq!(
|
||||||
|
shared.cards.borrow().len(),
|
||||||
|
index + 1,
|
||||||
|
"a card's index is its position, and both are the call's"
|
||||||
|
);
|
||||||
|
let content = build_card(rsc, shared, index);
|
||||||
|
ptr(rsc).set(content);
|
||||||
|
let for_tap = shared.clone();
|
||||||
|
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||||
|
hold_edge(rsc, for_tap.list, for_tap.key);
|
||||||
|
let Some(id) = for_tap.call_id(index) else {
|
||||||
|
debug_assert!(false, "tapped card {index} is no longer in the row");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let was = for_tap
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.open
|
||||||
|
.get(&id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
for_tap.state.borrow_mut().open.insert(id, !was);
|
||||||
|
redraw_card(rsc, &for_tap, index);
|
||||||
|
});
|
||||||
|
(strong.any(), ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
let mark = disclosure(icon::COLLAPSE, &shared.theme)
|
||||||
|
.center_text()
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
// Anything shown only as a mark still needs a name: this is what
|
||||||
|
// a screen reader reads and what a `ui-trace` script taps.
|
||||||
|
.label("Collapse these tool calls")
|
||||||
|
.add_strong(rsc);
|
||||||
|
ptr(rsc).set(mark);
|
||||||
|
let for_tap = shared.clone();
|
||||||
|
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||||
|
toggle_group(rsc, &for_tap)
|
||||||
|
});
|
||||||
|
strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilt whole when the group opens or closes, because that is a change
|
||||||
|
/// of what the row *is* rather than of one card in it. Everything a single
|
||||||
|
/// card's tap does goes through [`redraw_card`] instead.
|
||||||
|
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
shared.cards.borrow_mut().clear();
|
||||||
|
let count = shared.calls.borrow().len();
|
||||||
|
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
return build_card_ptr(rsc, shared, 0).0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shared.state.borrow().group_expanded {
|
||||||
|
let heading = group_label(count);
|
||||||
|
return text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(heading)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading = group_label(count);
|
||||||
|
let mut group = Span::empty(Dir::DOWN);
|
||||||
|
group.push(
|
||||||
|
text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(heading)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut cards = Span::empty(Dir::DOWN);
|
||||||
|
for index in 0..count {
|
||||||
|
cards.push(build_card_ptr(rsc, shared, index).0);
|
||||||
|
}
|
||||||
|
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
|
||||||
|
}
|
||||||
|
group.push(collapse_bar(rsc, shared));
|
||||||
|
group
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
hold_edge(rsc, shared.list, shared.key);
|
||||||
|
let was = shared.state.borrow().group_expanded;
|
||||||
|
shared.state.borrow_mut().group_expanded = !was;
|
||||||
|
let content = build_content(rsc, shared);
|
||||||
|
shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shared {
|
||||||
|
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
|
||||||
|
let Some(ptr) = *self.content.borrow() else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"the row's content pointer is set before anything can tap it"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_id(&self, index: usize) -> Option<String> {
|
||||||
|
match self.calls.borrow().get(index) {
|
||||||
|
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
|
||||||
|
self.cards.borrow().get(index).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `working` is the caller's `session_working` **for this row** -- true
|
||||||
|
/// only for the newest row of a session that is still doing something.
|
||||||
|
/// Every row behind it belongs to a turn that has ended, so a call in one
|
||||||
|
/// with no result never came back rather than still running.
|
||||||
|
pub fn build_tool_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
calls: Vec<TranscriptItem>,
|
||||||
|
working: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, ToolRow)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let shared = Rc::new(Shared {
|
||||||
|
calls: RefCell::new(calls),
|
||||||
|
state: RefCell::new(ToolRowState::default()),
|
||||||
|
cards: RefCell::new(Vec::new()),
|
||||||
|
content: RefCell::new(None),
|
||||||
|
list,
|
||||||
|
key,
|
||||||
|
working: Cell::new(working),
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
let content_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let content = content_strong.weak();
|
||||||
|
*shared.content.borrow_mut() = Some(content);
|
||||||
|
let inner = build_content(rsc, &shared);
|
||||||
|
content(rsc).set(inner);
|
||||||
|
(content_strong.any(), ToolRow { shared })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolRow {
|
||||||
|
/// The calls this row is currently drawing -- what a caller passes
|
||||||
|
/// back to [`Self::apply_calls`] when something other than the calls
|
||||||
|
/// themselves changed (the session's status).
|
||||||
|
pub fn calls(&self) -> Vec<TranscriptItem> {
|
||||||
|
self.shared.calls.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn card_count(&self) -> usize {
|
||||||
|
self.shared.cards.borrow().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exists because the expanded appearance is otherwise unreachable
|
||||||
|
/// from anything that cannot press the screen -- a headless
|
||||||
|
/// screenshot on this displayless machine, and a test. Same path a tap
|
||||||
|
/// takes, including `LazySpan::note_tap`, so what it produces is what a
|
||||||
|
/// reader would have got.
|
||||||
|
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.shared.state.borrow().group_expanded != expanded {
|
||||||
|
toggle_group(rsc, &self.shared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring this row up to date with `calls` **without** rebuilding the
|
||||||
|
/// cards that did not change, and say whether that was possible.
|
||||||
|
/// `false` means the caller must rebuild the row the ordinary way.
|
||||||
|
pub fn apply_calls<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
calls: &[TranscriptItem],
|
||||||
|
working: bool,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if calls.is_empty()
|
||||||
|
|| !calls
|
||||||
|
.iter()
|
||||||
|
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let old = self.shared.calls.borrow().clone();
|
||||||
|
if calls.len() < old.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (old.len() == 1) != (calls.len() == 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
|
||||||
|
self.shared.working.set(working);
|
||||||
|
*self.shared.calls.borrow_mut() = calls.to_vec();
|
||||||
|
let ids: std::collections::HashSet<String> = calls
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut state = self.shared.state.borrow_mut();
|
||||||
|
state.open.retain(|id, _| ids.contains(id));
|
||||||
|
state.whole.retain(|(id, _), _| ids.contains(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.shared.cards.borrow().is_empty() {
|
||||||
|
if calls.len() != old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.shared.cards.borrow().len(),
|
||||||
|
old.len(),
|
||||||
|
"an open row draws exactly one card per call"
|
||||||
|
);
|
||||||
|
|
||||||
|
for index in changed {
|
||||||
|
redraw_card(rsc, &self.shared, index);
|
||||||
|
}
|
||||||
|
if calls.len() > old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use iris::sense::DRAG_SLOP;
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
|
||||||
|
let middle = phone_size().y / 2.0;
|
||||||
|
let list = (screen.list)(&mut h.rsc);
|
||||||
|
let key = list.key_at(middle).expect("a row under the viewport");
|
||||||
|
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||||
|
(key, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||||
|
(screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the tracked row is still loaded")
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP: f32 = 2.0;
|
||||||
|
const SAMPLES: usize = 3;
|
||||||
|
const CATCH_X: f32 = 540.0;
|
||||||
|
|
||||||
|
fn drag_from(
|
||||||
|
h: &mut Harness,
|
||||||
|
screen: &ai_app::ui::TranscriptScreen,
|
||||||
|
key: RowKey,
|
||||||
|
y0: f32,
|
||||||
|
t0: u64,
|
||||||
|
expect_tracking: bool,
|
||||||
|
) -> u64 {
|
||||||
|
let before = row_top(h, screen, key);
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
|
||||||
|
assert_eq!(
|
||||||
|
row_top(h, screen, key),
|
||||||
|
before,
|
||||||
|
"the down itself must not move the content, only stop it"
|
||||||
|
);
|
||||||
|
let mut t = t0;
|
||||||
|
for i in 1..=SAMPLES {
|
||||||
|
let moved = STEP * i as f32;
|
||||||
|
t = t0 + 8 * i as u64;
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
|
||||||
|
let travelled = row_top(h, screen, key) - before;
|
||||||
|
if expect_tracking {
|
||||||
|
assert!(
|
||||||
|
(travelled - moved).abs() < 0.5,
|
||||||
|
"sample {i}: the finger has moved {moved}px since the down and the content \
|
||||||
|
{travelled:.1}px -- it is not pinned to the finger"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
travelled.abs() < 0.5,
|
||||||
|
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
|
||||||
|
nothing, but the content moved {travelled:.1}px"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t += 8;
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Up,
|
||||||
|
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
let catch_at = flick.end_ms() + 150;
|
||||||
|
h.frames_until(
|
||||||
|
flick.end_ms() + PHONE_FRAME_MS,
|
||||||
|
catch_at - PHONE_FRAME_MS,
|
||||||
|
PHONE_FRAME_MS,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||||
|
"the fling must still be running 150ms in, or this test catches nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, _) = tracked_row(&mut h, &screen);
|
||||||
|
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
let catch_at = flick.end_ms() + 150;
|
||||||
|
h.frames_until(
|
||||||
|
flick.end_ms() + PHONE_FRAME_MS,
|
||||||
|
catch_at - PHONE_FRAME_MS,
|
||||||
|
PHONE_FRAME_MS,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||||
|
"the fling must still be running 150ms in, or this test catches nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, _) = tracked_row(&mut h, &screen);
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
|
||||||
|
let stopped_at = row_top(&mut h, &screen, key);
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a press that stopped a fling and moved nothing must not start another"
|
||||||
|
);
|
||||||
|
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
|
||||||
|
assert!(
|
||||||
|
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
|
||||||
|
"the content moved after a catch was released without moving"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"a catch is not a tap: nothing under it may be followed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
let settled = h.frames_until(
|
||||||
|
flick.end_ms() + PHONE_FRAME_MS,
|
||||||
|
flick.end_ms() + 4000,
|
||||||
|
PHONE_FRAME_MS,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!(screen.list)(&mut h.rsc).is_scrolling(),
|
||||||
|
"the fling must have stopped, or this is the same case as the test above"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, _) = tracked_row(&mut h, &screen);
|
||||||
|
drag_from(
|
||||||
|
&mut h,
|
||||||
|
&screen,
|
||||||
|
key,
|
||||||
|
1200.0,
|
||||||
|
settled + PHONE_FRAME_MS,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchAction};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||||
|
h.render
|
||||||
|
.active
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|&id| {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||||
|
.is_some_and(|s| s.axis() == Axis::X)
|
||||||
|
})
|
||||||
|
.find_map(|id| {
|
||||||
|
let r = h.render.window_region(&id, &h.rsc)?;
|
||||||
|
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||||
|
.expect("the fence's scroll area is still drawn")
|
||||||
|
.is_scrolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||||
|
.expect("the fence's scroll area is still drawn")
|
||||||
|
.amt()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||||
|
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
let screen = opened.screen;
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
||||||
|
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = ai_app::ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||||
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||||
|
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
||||||
|
|
||||||
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||||
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||||
|
|
||||||
|
let at_release = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
at_release > 0.0,
|
||||||
|
"the flick itself must have panned the fence, got {at_release}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut t = 240;
|
||||||
|
while t <= 740 {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let coasted = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
coasted > at_release + 1.0,
|
||||||
|
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let settled = coasted;
|
||||||
|
while t <= 4_000 {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let after = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
after >= settled,
|
||||||
|
"a fling must not run backwards: {settled} -> {after}"
|
||||||
|
);
|
||||||
|
let last = after;
|
||||||
|
h.frame(t);
|
||||||
|
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||||
|
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
let screen = opened.screen;
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: format!(
|
||||||
|
"```\n{}\n```",
|
||||||
|
(1..=200)
|
||||||
|
.map(|i| format!("word{i}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = ai_app::ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||||
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||||
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||||
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||||
|
h.frame(248);
|
||||||
|
assert!(
|
||||||
|
is_scrolling(&h, fence_scroll),
|
||||||
|
"the fence has to still be coasting for this to be the reported case",
|
||||||
|
);
|
||||||
|
|
||||||
|
let probe = box_.top_left.y - 500.0;
|
||||||
|
let row = (screen.list)(&mut h.rsc)
|
||||||
|
.key_at(probe)
|
||||||
|
.expect("a row that far up the screen");
|
||||||
|
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
|
||||||
|
let from = (row_top + row_bottom) / 2.0;
|
||||||
|
|
||||||
|
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
|
||||||
|
let fence_before = amt(&h, fence_scroll);
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
|
||||||
|
let mut t = 264;
|
||||||
|
for i in 1..=8 {
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Move,
|
||||||
|
Vec2::new(540.0, from + 20.0 * i as f32),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
t += 8;
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
list_before,
|
||||||
|
(screen.list)(&mut h.rsc).anchor_position_display(),
|
||||||
|
"the drag was nowhere near the fence, so it belongs to the list",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
amt(&h, fence_scroll) > fence_before,
|
||||||
|
"the fence's fling must carry on through a gesture that was never \
|
||||||
|
its own: {fence_before} -> {}",
|
||||||
|
amt(&h, fence_scroll),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(name: &str, text: &str) -> TouchScript {
|
||||||
|
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
|
||||||
|
let middle = phone_size().y / 2.0;
|
||||||
|
let list = (screen.list)(&mut h.rsc);
|
||||||
|
let key = list.key_at(middle).expect("a row under the viewport");
|
||||||
|
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||||
|
(key, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||||
|
(screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the tracked row is still loaded")
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_flick_does_not_fling() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let flick = script(
|
||||||
|
"flick-cancelled",
|
||||||
|
include_str!("../touch/flick-cancelled.touch"),
|
||||||
|
);
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a gesture the platform took away must not fling"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, settled) = tracked_row(&mut h, &screen);
|
||||||
|
let end = flick.end_ms() + 1_000;
|
||||||
|
let mut t = flick.end_ms();
|
||||||
|
while t <= end {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let now = row_top(&mut h, &screen, key);
|
||||||
|
assert!(
|
||||||
|
(now - settled).abs() < 0.5,
|
||||||
|
"the list kept moving after a cancelled gesture: {settled} -> {now}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
|
||||||
|
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
|
||||||
|
|
||||||
|
let (key, before) = tracked_row(&mut h, &screen);
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
|
||||||
|
|
||||||
|
let after = row_top(&mut h, &screen, key);
|
||||||
|
assert!(
|
||||||
|
(after - before).abs() < 0.5,
|
||||||
|
"a tap after a cancelled press panned the list by {}px, the distance between them",
|
||||||
|
after - before
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"and it must not have flung either"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||||
|
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
|
||||||
|
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_001,
|
||||||
|
text: "A plain paragraph with nothing to tap in it, only words, so that a \
|
||||||
|
press here is a press on ordinary text and nothing else."
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
screen.push_row(&mut h.rsc, ¶);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = ai_app::ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let y = (top + bottom) / 2.0;
|
||||||
|
assert!(
|
||||||
|
y > 0.0 && y < phone_size().y,
|
||||||
|
"the fence row has to be on screen to be pressed: {top}..{bottom}"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
|
||||||
|
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
|
||||||
|
|
||||||
|
let (tracked, before) = tracked_row(&mut h, &screen);
|
||||||
|
|
||||||
|
let para_key = ai_app::ui::row::row_key(¶.key());
|
||||||
|
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(para_key)
|
||||||
|
.expect("the paragraph row is on screen");
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Down,
|
||||||
|
Vec2::new(540.0, (ptop + pbottom) / 2.0),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Up,
|
||||||
|
Vec2::new(540.0, (ptop + pbottom) / 2.0),
|
||||||
|
450,
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = row_top(&mut h, &screen, tracked);
|
||||||
|
assert!(
|
||||||
|
(after - before).abs() < 0.5,
|
||||||
|
"a tap after panning a code fence moved the transcript by {}px",
|
||||||
|
after - before
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchScript};
|
||||||
|
|
||||||
|
struct CaptureLogger {
|
||||||
|
lines: Mutex<Vec<(log::Level, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
|
||||||
|
|
||||||
|
impl log::Log for CaptureLogger {
|
||||||
|
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
self.lines
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((record.level(), record.args().to_string()));
|
||||||
|
}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logger() -> &'static CaptureLogger {
|
||||||
|
let logger = LOGGER.get_or_init(|| CaptureLogger {
|
||||||
|
lines: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let _ = log::set_logger(logger);
|
||||||
|
log::set_max_level(log::LevelFilter::Debug);
|
||||||
|
logger
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
|
||||||
|
std::mem::take(&mut *logger.lines.lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||||
|
let logger = logger();
|
||||||
|
|
||||||
|
iris::diagnostics::set_trace(false);
|
||||||
|
drain(logger); // whatever `opened()` itself logged while building
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
drain(logger); // and whatever opening logged
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
|
||||||
|
let quiet = drain(logger);
|
||||||
|
let debug_lines: Vec<_> = quiet
|
||||||
|
.iter()
|
||||||
|
.filter(|(level, _)| *level == log::Level::Debug)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
debug_lines.is_empty(),
|
||||||
|
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
iris::diagnostics::set_trace(true);
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
drain(logger);
|
||||||
|
h.replay(&flick);
|
||||||
|
let _ = (screen.list)(&mut h.rsc);
|
||||||
|
let traced = drain(logger);
|
||||||
|
iris::diagnostics::set_trace(false); // leave it off for any test after this one
|
||||||
|
|
||||||
|
let input_lines: Vec<&str> = traced
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, msg)| msg.contains("iris input: action="))
|
||||||
|
.map(|(_, msg)| msg.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
input_lines.len(),
|
||||||
|
flick.samples.len(),
|
||||||
|
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
|
||||||
|
);
|
||||||
|
let frame_lines: Vec<&str> = traced
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, msg)| msg.starts_with("iris frame:"))
|
||||||
|
.map(|(_, msg)| msg.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!frame_lines.is_empty(),
|
||||||
|
"expected at least one `iris::frame` line once tracing was on"
|
||||||
|
);
|
||||||
|
for line in &frame_lines {
|
||||||
|
assert!(
|
||||||
|
!line.contains("layout=0ns"),
|
||||||
|
"a frame that redrew should not report zero layout time: {line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = input_lines.join("\n");
|
||||||
|
let script_path = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../iris/benches/report_to_touch.py"
|
||||||
|
);
|
||||||
|
let mut child = Command::new("python3")
|
||||||
|
.arg(script_path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("python3 must be on PATH to run report_to_touch.py");
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(report.as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output().expect("report_to_touch.py exited");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"report_to_touch.py failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
|
||||||
|
let round_tripped =
|
||||||
|
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
round_tripped.samples.len(),
|
||||||
|
flick.samples.len(),
|
||||||
|
"round trip produced a different number of samples:\n{touch_text}"
|
||||||
|
);
|
||||||
|
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
|
||||||
|
assert_eq!(original.t_ms, back.t_ms);
|
||||||
|
assert_eq!(original.action, back.action);
|
||||||
|
assert_eq!(original.pos, back.pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(name: &str, text: &str) -> TouchScript {
|
||||||
|
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String {
|
||||||
|
(screen.list)(&mut h.rsc).anchor_position_display()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
let velocity = (screen.list)(&mut h.rsc)
|
||||||
|
.fling_velocity()
|
||||||
|
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||||
|
assert!(
|
||||||
|
(velocity - 15_250.0).abs() < 20.0,
|
||||||
|
"expected ~15250px/s from velocity_reference.py, got {velocity}"
|
||||||
|
);
|
||||||
|
|
||||||
|
const REFERENCE_MS: u64 = 2071;
|
||||||
|
const REFERENCE_PX: f32 = 11057.0;
|
||||||
|
let end = flick.end_ms() + REFERENCE_MS * 2;
|
||||||
|
let mut settled_at = None;
|
||||||
|
let mut t = flick.end_ms();
|
||||||
|
let middle = phone_size().y / 2.0;
|
||||||
|
let mut travelled = 0.0f32;
|
||||||
|
let mut tracked: Option<(RowKey, f32)> = None;
|
||||||
|
while t <= end {
|
||||||
|
h.frame(t);
|
||||||
|
let list = (screen.list)(&mut h.rsc);
|
||||||
|
tracked =
|
||||||
|
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
|
||||||
|
Some((key, was, now)) => {
|
||||||
|
travelled += (now - was).abs();
|
||||||
|
Some((key, now))
|
||||||
|
}
|
||||||
|
None => list
|
||||||
|
.key_at(middle)
|
||||||
|
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
||||||
|
};
|
||||||
|
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||||
|
settled_at = Some(t);
|
||||||
|
}
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = offset(&mut h, &screen);
|
||||||
|
assert_ne!(
|
||||||
|
before, after,
|
||||||
|
"the fling ticks must have moved the list off where the flick left it"
|
||||||
|
);
|
||||||
|
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||||
|
let ran_for = settled_at - flick.end_ms();
|
||||||
|
assert!(
|
||||||
|
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
|
||||||
|
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
|
||||||
|
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
travelled >= REFERENCE_PX * 0.8,
|
||||||
|
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tap_on_a_row_moves_nothing() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a tap must not fling"
|
||||||
|
);
|
||||||
|
h.frames_until(100, 400, PHONE_FRAME_MS);
|
||||||
|
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"no link was under this tap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_press_and_drag_selects_text() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
h.replay(&script(
|
||||||
|
"long-press",
|
||||||
|
include_str!("../touch/long-press.touch"),
|
||||||
|
));
|
||||||
|
|
||||||
|
let selected = screen
|
||||||
|
.selected_text(&mut h.rsc)
|
||||||
|
.expect("a long-press then drag must leave text selected");
|
||||||
|
assert!(
|
||||||
|
!selected.trim().is_empty(),
|
||||||
|
"the selection covered no characters: {selected:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
offset(&mut h, &screen),
|
||||||
|
"a selection must not also pan the list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tap_after_selection_deselects_text() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
h.replay(&script(
|
||||||
|
"select-then-tap",
|
||||||
|
"0 down 300 1000\n\
|
||||||
|
520 move 300 1000\n\
|
||||||
|
560 move 700 1000\n\
|
||||||
|
600 move 900 1000\n\
|
||||||
|
640 up 900 1000\n\
|
||||||
|
800 down 540 1000\n\
|
||||||
|
880 up 540 1000",
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
screen.selected_text(&mut h.rsc),
|
||||||
|
None,
|
||||||
|
"an ordinary tap after a selection must dismiss it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"the deselecting tap must be consumed rather than activating content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let height = h.size().y;
|
||||||
|
let field_bottom = |h: &mut Harness| {
|
||||||
|
h.render
|
||||||
|
.window_region(&screen.composer.field, &h.rsc)
|
||||||
|
.expect("the composer field is on screen")
|
||||||
|
.bot_right
|
||||||
|
.y
|
||||||
|
};
|
||||||
|
|
||||||
|
let closed = field_bottom(&mut h);
|
||||||
|
assert!(
|
||||||
|
closed <= height,
|
||||||
|
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ime = 1000.0;
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
|
||||||
|
let open = field_bottom(&mut h);
|
||||||
|
assert!(
|
||||||
|
open <= height - ime,
|
||||||
|
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
|
||||||
|
height - ime
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(closed - open - ime).abs() < 1.0,
|
||||||
|
"the composer moved {} for a {ime}px inset",
|
||||||
|
closed - open
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_space_in_the_composer_finishes_layout() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, 1000.0);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
h.state.set_focus(Some(screen.composer.field));
|
||||||
|
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||||
|
|
||||||
|
for text in ["h", "i", " "] {
|
||||||
|
screen.composer.field.edit(&mut h.rsc).insert(text);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(screen.composer.field.edit(&mut h.rsc).text.text(), "hi ");
|
||||||
|
let region = h
|
||||||
|
.render
|
||||||
|
.window_region(&screen.composer.field, &h.rsc)
|
||||||
|
.expect("the composer field is drawn");
|
||||||
|
let size = region.bot_right - region.top_left;
|
||||||
|
assert!(
|
||||||
|
size.x > phone_size().x / 2.0,
|
||||||
|
"the field shrink-wrapped to the short message: {region:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
size.y < 30.0 * PHONE_SCALE,
|
||||||
|
"the trailing space wrapped onto a second line: {region:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let height = h.size().y;
|
||||||
|
let ime = 1000.0;
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
|
||||||
|
h.state.set_focus(Some(screen.composer.field));
|
||||||
|
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||||
|
for _ in 0..12 {
|
||||||
|
screen.composer.field.edit(&mut h.rsc).insert("a\n");
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
}
|
||||||
|
let message = h
|
||||||
|
.render
|
||||||
|
.debug(h.rsc.widgets(), "Message")
|
||||||
|
.find(|a| !a.primitives.is_empty())
|
||||||
|
.expect("the composer field is drawn");
|
||||||
|
let caret = h
|
||||||
|
.render
|
||||||
|
.primitive_corners(message.primitives.last().unwrap().slot, &h.rsc);
|
||||||
|
let bar_bottom = height - ime;
|
||||||
|
let padding = 12.0 * PHONE_SCALE;
|
||||||
|
assert!(
|
||||||
|
caret.bot_right.y < bar_bottom - padding / 2.0,
|
||||||
|
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
|
||||||
|
and its padding is {padding}px",
|
||||||
|
caret.bot_right.y,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mask = h.rsc.ui.masks[message.mask.idx()];
|
||||||
|
let bar = h.render.primitive_corners(mask.primitive, &h.rsc);
|
||||||
|
let visible_content_top = message
|
||||||
|
.primitives
|
||||||
|
.iter()
|
||||||
|
.map(|p| h.render.primitive_corners(p.slot, &h.rsc))
|
||||||
|
.filter(|r| r.bot_right.y > bar.top_left.y)
|
||||||
|
.map(|r| r.top_left.y)
|
||||||
|
.fold(f32::INFINITY, f32::min);
|
||||||
|
assert!(
|
||||||
|
visible_content_top > bar.top_left.y,
|
||||||
|
"the composer's first visible line is clipped above its bar: content starts at {visible_content_top}, bar starts at {}",
|
||||||
|
bar.top_left.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::Harness;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const HEADER_H: f32 = 300.0;
|
||||||
|
const HEADER: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let (opened, tree) = ai_app::ui::fixture::build_screen(&mut h.rsc).expect("the fixture folds");
|
||||||
|
let content = WidgetPtr::new().add(&mut h.rsc);
|
||||||
|
content(&mut h.rsc).set(tree);
|
||||||
|
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(&mut h.rsc)
|
||||||
|
.any();
|
||||||
|
h.state.set_root(&mut h.rsc, root);
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion {
|
||||||
|
h.render
|
||||||
|
.window_region(&screen.list.id(), &h.rsc)
|
||||||
|
.expect("the list is on screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
||||||
|
let mut rows: Vec<(f32, f32)> = h
|
||||||
|
.render
|
||||||
|
.active
|
||||||
|
.get(&screen.list.id())
|
||||||
|
.expect("the list is drawn")
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| h.render.window_region(id, &h.rsc))
|
||||||
|
.map(|px| (px.top_left.y, px.bot_right.y))
|
||||||
|
.collect();
|
||||||
|
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scrolled(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
||||||
|
(screen.list)(&mut h.rsc).scroll(amount);
|
||||||
|
h.frame(t);
|
||||||
|
t + PHONE_FRAME_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_row_across_the_top_edge_is_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let top = list_box(&h, &screen).top_left.y;
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..60 {
|
||||||
|
t = scrolled(&mut h, &screen, 40.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let first = *rows.first().expect("something is on screen");
|
||||||
|
assert!(
|
||||||
|
first.0 <= top + 0.5,
|
||||||
|
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
|
||||||
|
at {top:.1}",
|
||||||
|
first.0 - top,
|
||||||
|
first.0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
first.1 > top,
|
||||||
|
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
|
||||||
|
{top:.1}",
|
||||||
|
first.1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_list_is_clipped_to_its_own_box() {
|
||||||
|
let (h, screen) = opened();
|
||||||
|
let active = h.render.active.get(&screen.list.id()).expect("drawn");
|
||||||
|
assert!(
|
||||||
|
active.mask != MaskIdx::NONE,
|
||||||
|
"the transcript's list is drawn with nothing clipping it",
|
||||||
|
);
|
||||||
|
let clip = h.render.mask_region(active.mask, &h.rsc);
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
assert!(
|
||||||
|
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
|
||||||
|
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
|
||||||
|
edge still draws past it",
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = h
|
||||||
|
.render
|
||||||
|
.active
|
||||||
|
.get(&screen.list.id())
|
||||||
|
.expect("the list is drawn")
|
||||||
|
.children
|
||||||
|
.clone();
|
||||||
|
let mut checked = 0;
|
||||||
|
for row in rows {
|
||||||
|
for prim in primitives_under(&h, row) {
|
||||||
|
assert!(
|
||||||
|
mask_chain(&h, prim).contains(&active.mask),
|
||||||
|
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
|
||||||
|
own mask {:?}",
|
||||||
|
mask_chain(&h, prim),
|
||||||
|
active.mask,
|
||||||
|
);
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
checked > 0,
|
||||||
|
"no row primitive was checked, so this test asserted nothing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
||||||
|
let Some(active) = h.render.active.get(&id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<MaskIdx> = active
|
||||||
|
.primitives
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.binding != IMAGE_BINDING)
|
||||||
|
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
|
||||||
|
.collect();
|
||||||
|
for child in &active.children {
|
||||||
|
out.extend(primitives_under(h, *child));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
let mut at = mask;
|
||||||
|
while at != MaskIdx::NONE {
|
||||||
|
assert!(
|
||||||
|
!chain.contains(&at),
|
||||||
|
"the mask chain from {mask:?} loops back to {at:?}",
|
||||||
|
);
|
||||||
|
chain.push(at);
|
||||||
|
at = h.rsc.ui.masks[at.idx()].parent;
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||||
|
assert!(
|
||||||
|
rows.len() <= 24,
|
||||||
|
"{leg} {step}: {} rows drawn for one 2012px viewport",
|
||||||
|
rows.len(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||||
|
for &(top, bottom) in rows {
|
||||||
|
assert!(
|
||||||
|
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
|
||||||
|
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
|
||||||
|
{list:?} and was drawn anyway",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 400.0, t);
|
||||||
|
bounded(&drawn_rows(&h, &screen), "measuring", step);
|
||||||
|
}
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, -400.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
bounded(&rows, "forward", step);
|
||||||
|
inside(&rows, "forward", step);
|
||||||
|
}
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 400.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
bounded(&rows, "back", step);
|
||||||
|
inside(&rows, "back", step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_row_across_the_bottom_edge_is_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 37.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let last = *rows.last().expect("something is on screen");
|
||||||
|
assert!(
|
||||||
|
last.1 >= list.bot_right.y - 0.5,
|
||||||
|
"a band of {:.1}px above the composer belongs to no row",
|
||||||
|
list.bot_right.y - last.1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
last.0 < list.bot_right.y,
|
||||||
|
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
|
||||||
|
{:.1}",
|
||||||
|
last.0,
|
||||||
|
list.bot_right.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_first_row_settles_on_it() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..60 {
|
||||||
|
t = scrolled(&mut h, &screen, 100_000.0, t);
|
||||||
|
}
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let first = *rows.first().expect("the first row is on screen");
|
||||||
|
assert!(
|
||||||
|
(first.0 - list.top_left.y).abs() < 0.5,
|
||||||
|
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
|
||||||
|
first.0 - list.top_left.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_last_row_settles_on_it() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..20 {
|
||||||
|
t = scrolled(&mut h, &screen, -100_000.0, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let last = *rows.last().expect("the last row is on screen");
|
||||||
|
assert!(
|
||||||
|
(last.1 - list.bot_right.y).abs() < 0.5,
|
||||||
|
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
|
||||||
|
blank",
|
||||||
|
list.bot_right.y - last.1,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# A finger flick the shape Iris's phone delivers one, from
|
||||||
|
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
|
||||||
|
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
|
||||||
|
# as DOWN, one or two MOVEs and UP inside a few frames, with the
|
||||||
|
# intermediate positions batched inside those MOVEs as historical
|
||||||
|
# samples (~4ms apart, the touch digitiser's own rate) rather than
|
||||||
|
# arriving as separate events. Each line here is one such sample, which
|
||||||
|
# is exactly what `IrisViewPeer::on_touch_event` replays through the
|
||||||
|
# sensors one at a time -- so the whole gesture is 20ms and five
|
||||||
|
# samples, and the velocity has to come out of *those*.
|
||||||
|
#
|
||||||
|
# Downward (increasing y) on purpose: the screen opens pinned to the
|
||||||
|
# newest end, so a flick the other way has nothing left to scroll to and
|
||||||
|
# the fling clamps on its first tick -- a pass that would prove nothing.
|
||||||
|
# Coordinates are physical pixels on a 1080x2424 surface.
|
||||||
|
0 down 540 1000
|
||||||
|
4 move 540 1040
|
||||||
|
8 move 540 1086
|
||||||
|
12 move 540 1138
|
||||||
|
16 move 540 1196
|
||||||
|
20 up 540 1196
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# `flick-120hz.touch` to the sample, with the platform taking the gesture
|
||||||
|
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
|
||||||
|
# is what the swipe up from the bottom edge to leave the app delivers
|
||||||
|
# after its moves. Nothing may follow from it: no tap, no selection and,
|
||||||
|
# the one that showed on Iris's phone, no fling.
|
||||||
|
0 down 540 1000
|
||||||
|
4 move 540 1040
|
||||||
|
8 move 540 1086
|
||||||
|
12 move 540 1138
|
||||||
|
16 move 540 1196
|
||||||
|
20 cancel 540 1196
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# A long-press then a drag across the text: held past LONG_PRESS
|
||||||
|
# (500ms) without moving, which is what starts a selection rather than a
|
||||||
|
# pan, then dragged sideways so the selection actually covers
|
||||||
|
# something. A press alone leaves a collapsed caret and no selected
|
||||||
|
# text (`Selection::begin`), which is why this file does not stop at the
|
||||||
|
# hold.
|
||||||
|
0 down 300 1000
|
||||||
|
520 move 300 1000
|
||||||
|
560 move 700 1000
|
||||||
|
600 move 900 1000
|
||||||
|
640 up 900 1000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# The case the flick had no reason to touch: a press and release in one
|
||||||
|
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
|
||||||
|
# tap -- no pan, no velocity, nothing moved.
|
||||||
|
0 down 540 1000
|
||||||
|
80 up 540 1000
|
||||||
@@ -102,6 +102,16 @@ android {
|
|||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
|
||||||
|
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
|
||||||
|
// the `bench` build type below, which overrides it.
|
||||||
|
buildConfigField("boolean", "FIXTURE_MODE", "false")
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
|
||||||
|
buildConfig = true
|
||||||
|
// Only for the bench build type's resValue("string", "app_name", ...) below.
|
||||||
|
resValues = true
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
@@ -131,6 +141,35 @@ android {
|
|||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
|
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
|
||||||
|
// optimisations so a frame time measured here means what release means everywhere else in
|
||||||
|
// this project, its own application id so it installs beside a real enrollment rather than
|
||||||
|
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
|
||||||
|
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
|
||||||
|
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
|
||||||
|
// be one more secret to keep off this machine's shared mount for no benefit.
|
||||||
|
create("bench") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
|
||||||
|
// with dev-updater and has no reason to know this project invented one -- so this says
|
||||||
|
// which of its build types to link against instead.
|
||||||
|
matchingFallbacks += listOf("release")
|
||||||
|
applicationIdSuffix = ".bench"
|
||||||
|
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
|
||||||
|
// Settings): this resValue overrides res/values/strings.xml's app_name for this
|
||||||
|
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
|
||||||
|
// rather than a literal so a build type can override it without touching the
|
||||||
|
// manifest.
|
||||||
|
resValue("string", "app_name", "AI Sessions bench")
|
||||||
|
buildConfigField("boolean", "FIXTURE_MODE", "true")
|
||||||
|
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
// The fixture both bench builds (this one and iris's) open with; see
|
||||||
|
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
|
||||||
|
// into androidApp/src -- one file to keep in sync with the generator, not two.
|
||||||
|
getByName("bench").assets.directories.add("../bench-fixture/assets")
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
the fix is a judgement about how this app should look. Drop this
|
the fix is a judgement about how this app should look. Drop this
|
||||||
suppression when a real icon lands. -->
|
suppression when a real icon lands. -->
|
||||||
<application
|
<application
|
||||||
android:label="AI Sessions"
|
android:label="@string/app_name"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
tools:ignore="MissingApplicationIcon">
|
tools:ignore="MissingApplicationIcon">
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ data class SessionSummary(
|
|||||||
val keepsOwnTranscript: Boolean,
|
val keepsOwnTranscript: Boolean,
|
||||||
/** How much the session asks before acting; null when it was never set. */
|
/** How much the session asks before acting; null when it was never set. */
|
||||||
val permissionMode: String?,
|
val permissionMode: String?,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
||||||
|
* reported rather than assumed for the same reason [permissionMode] is.
|
||||||
|
*/
|
||||||
|
val effort: String?,
|
||||||
|
/**
|
||||||
|
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
|
||||||
|
*
|
||||||
|
* Asked of the server rather than worked out from the provider's name, because this is a
|
||||||
|
* property of the driver's *kind* and the phone only has the name.
|
||||||
|
*/
|
||||||
|
val takesEffort: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||||
*/
|
*/
|
||||||
@@ -153,6 +167,24 @@ data class SessionSummary(
|
|||||||
* itself from a default is one you can turn off while believing you are reading it.
|
* itself from a default is one you can turn off while believing you are reading it.
|
||||||
*/
|
*/
|
||||||
val notify: Boolean,
|
val notify: Boolean,
|
||||||
|
/**
|
||||||
|
* Whether this session sends itself a message once its account's usage limit lifts, and what
|
||||||
|
* that message says.
|
||||||
|
*
|
||||||
|
* The message is what the server would actually send, with its own default already filled in,
|
||||||
|
* so the field shows the words rather than an empty box standing for them.
|
||||||
|
*/
|
||||||
|
val autoResume: Boolean,
|
||||||
|
val autoResumeMessage: String,
|
||||||
|
/**
|
||||||
|
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
|
||||||
|
* when nothing is waiting.
|
||||||
|
*
|
||||||
|
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
|
||||||
|
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
|
||||||
|
* app cannot keep is worse than no time at all.
|
||||||
|
*/
|
||||||
|
val resumeAt: Double?,
|
||||||
/**
|
/**
|
||||||
* The directory the session works in, or null where it was never given one.
|
* The directory the session works in, or null where it was never given one.
|
||||||
*
|
*
|
||||||
@@ -178,8 +210,27 @@ data class SessionSummary(
|
|||||||
* server because that is where a provider's kind is known.
|
* server because that is where a provider's kind is known.
|
||||||
*/
|
*/
|
||||||
val maxImageEdge: Int?,
|
val maxImageEdge: Int?,
|
||||||
|
/**
|
||||||
|
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
|
||||||
|
*
|
||||||
|
* The rate-limit bar answers a question about an *account*, and what decides which account --
|
||||||
|
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
|
||||||
|
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
|
||||||
|
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
|
||||||
|
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
|
||||||
|
* this app has only its name.
|
||||||
|
*/
|
||||||
|
val usageProvider: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val lastActivity: Double,
|
val lastActivity: Double,
|
||||||
|
/**
|
||||||
|
* How many subagents this session has, however their own status now reads.
|
||||||
|
*
|
||||||
|
* A directory listing on the server rather than a status read per subagent, so the list stays
|
||||||
|
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
|
||||||
|
* that predates subagents, so this app still opens against one.
|
||||||
|
*/
|
||||||
|
val subagents: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseSession(session: JSONObject) =
|
private fun parseSession(session: JSONObject) =
|
||||||
@@ -192,14 +243,24 @@ private fun parseSession(session: JSONObject) =
|
|||||||
title = session.getString("title"),
|
title = session.getString("title"),
|
||||||
model = session.optString("model").ifEmpty { null },
|
model = session.optString("model").ifEmpty { null },
|
||||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||||
|
effort = session.optString("effort").ifEmpty { null },
|
||||||
|
takesEffort = session.optBoolean("takesEffort", false),
|
||||||
imported = session.optBoolean("imported", false),
|
imported = session.optBoolean("imported", false),
|
||||||
notify = session.optBoolean("notify", true),
|
notify = session.optBoolean("notify", true),
|
||||||
|
autoResume = session.optBoolean("autoResume", false),
|
||||||
|
// The server sends its own default rather than nothing, so an empty answer means an older
|
||||||
|
// server -- and this app's word for it is the same word.
|
||||||
|
autoResumeMessage =
|
||||||
|
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
|
||||||
|
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
|
||||||
cwd = session.optString("cwd").ifEmpty { null },
|
cwd = session.optString("cwd").ifEmpty { null },
|
||||||
contextTokens =
|
contextTokens =
|
||||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||||
|
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||||
status = session.getString("status"),
|
status = session.getString("status"),
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
lastActivity = session.getDouble("lastActivity"),
|
||||||
|
subagents = session.optInt("subagents", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||||
@@ -215,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
|||||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||||
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
||||||
|
*
|
||||||
|
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
||||||
|
* this carries only what a card needs to draw and to open it; see docs/SUBAGENTS.md. [status] is
|
||||||
|
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
||||||
|
* running, and the list says so rather than reporting a state that cannot hold.
|
||||||
|
*/
|
||||||
|
data class SubagentSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val status: String,
|
||||||
|
val created: Double,
|
||||||
|
val lastActivity: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
|
||||||
|
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
||||||
|
it.jsonObjects { row ->
|
||||||
|
SubagentSummary(
|
||||||
|
id = row.getString("id"),
|
||||||
|
title = row.getString("title"),
|
||||||
|
status = row.getString("status"),
|
||||||
|
created = row.getDouble("created"),
|
||||||
|
lastActivity = row.getDouble("lastActivity"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||||
// config.ron appears here with no app rebuild.
|
// config.ron appears here with no app rebuild.
|
||||||
//
|
//
|
||||||
@@ -375,6 +465,12 @@ data class SshDetails(
|
|||||||
* Where files attached from here land on that machine; null for the session's own directory.
|
* Where files attached from here land on that machine; null for the session's own directory.
|
||||||
*/
|
*/
|
||||||
val attachmentsDir: String? = null,
|
val attachmentsDir: String? = null,
|
||||||
|
/**
|
||||||
|
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
|
||||||
|
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
|
||||||
|
* from the machine it runs on, so this is where its models are looked for and listed.
|
||||||
|
*/
|
||||||
|
val modelsDir: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SshDetails.toJson() =
|
private fun SshDetails.toJson() =
|
||||||
@@ -382,6 +478,7 @@ private fun SshDetails.toJson() =
|
|||||||
if (port != null) put("port", port)
|
if (port != null) put("port", port)
|
||||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
||||||
|
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a machine turns out to have, without saving anything. */
|
/** What a machine turns out to have, without saving anything. */
|
||||||
@@ -450,6 +547,8 @@ fun spawnSession(
|
|||||||
model: String? = null,
|
model: String? = null,
|
||||||
cwd: String? = null,
|
cwd: String? = null,
|
||||||
permissionMode: String? = null,
|
permissionMode: String? = null,
|
||||||
|
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
||||||
|
effort: String? = null,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
/** Continue this Claude Code session instead of starting an empty one. */
|
/** Continue this Claude Code session instead of starting an empty one. */
|
||||||
import: String? = null,
|
import: String? = null,
|
||||||
@@ -467,6 +566,7 @@ fun spawnSession(
|
|||||||
if (!model.isNullOrBlank()) put("model", model)
|
if (!model.isNullOrBlank()) put("model", model)
|
||||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||||
|
if (!effort.isNullOrBlank()) put("effort", effort)
|
||||||
if (!import.isNullOrBlank()) put("import", import)
|
if (!import.isNullOrBlank()) put("import", import)
|
||||||
if (params.isNotEmpty()) {
|
if (params.isNotEmpty()) {
|
||||||
put("params", JSONObject(params.toMap<String, Any>()))
|
put("params", JSONObject(params.toMap<String, Any>()))
|
||||||
@@ -906,7 +1006,7 @@ fun startImport(
|
|||||||
*/
|
*/
|
||||||
fun fetchTranscript(
|
fun fetchTranscript(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
sessionId: String,
|
address: TranscriptAddress,
|
||||||
before: Long? = null,
|
before: Long? = null,
|
||||||
limit: Int = 80,
|
limit: Int = 80,
|
||||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
||||||
@@ -925,7 +1025,7 @@ fun fetchTranscript(
|
|||||||
if (coalesce) append("&coalesce=true")
|
if (coalesce) append("&coalesce=true")
|
||||||
if (after != null) append("&after=").append(after)
|
if (after != null) append("&after=").append(after)
|
||||||
}
|
}
|
||||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
|
||||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||||
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
||||||
// other, and they have to be the same line.
|
// other, and they have to be the same line.
|
||||||
@@ -973,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
|||||||
*/
|
*/
|
||||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
|
||||||
|
*
|
||||||
|
* Held by the server rather than by this phone, because a second device would otherwise spawn
|
||||||
|
* sessions at a level the first one's owner never picked.
|
||||||
|
*/
|
||||||
|
fun fetchDefaultEffort(settings: ServerSettings): String? =
|
||||||
|
requestFromServer(settings, "/defaults") {
|
||||||
|
it.jsonObject().optString("effort").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets what new sessions start at. Nothing already running changes. */
|
||||||
|
fun setDefaultEffort(settings: ServerSettings, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/defaults",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||||
|
*
|
||||||
|
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
||||||
|
* not behave like them: the CLI has a control request for those two and none for this (checked
|
||||||
|
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
||||||
|
* the process, which is what the working directory beside it in this dialog does, and why it is
|
||||||
|
* here rather than on a bar whose other controls take effect mid-turn.
|
||||||
|
*/
|
||||||
|
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
||||||
|
|
||||||
|
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
||||||
|
const val DEFAULT_EFFORT = "default"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
||||||
|
* process is launched. The next message, or Start, runs one that has it.
|
||||||
|
*
|
||||||
|
* [level] is null for the CLI's own default.
|
||||||
|
*/
|
||||||
|
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/effort",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Switches how much a running session asks before acting, also in place. */
|
/** Switches how much a running session asks before acting, also in place. */
|
||||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
@@ -984,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
||||||
|
/**
|
||||||
|
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
|
||||||
|
* cleared field shows the word that would actually be sent instead of going blank.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_RESUME_MESSAGE = "continue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns auto-resume on or off and sets what it would say, in one request because they are one
|
||||||
|
* decision -- see the server's `/sessions/{id}/auto-resume`.
|
||||||
|
*/
|
||||||
|
fun setSessionAutoResume(
|
||||||
|
settings: ServerSettings,
|
||||||
|
sessionId: String,
|
||||||
|
autoResume: Boolean,
|
||||||
|
message: String?,
|
||||||
|
) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/auto-resume",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody =
|
||||||
|
JSONObject()
|
||||||
|
.put("autoResume", autoResume)
|
||||||
|
// Empty means the server's default rather than a session poked with nothing to
|
||||||
|
// read, which is the same rule the server applies to the field.
|
||||||
|
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
|
||||||
|
.toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
settings,
|
settings,
|
||||||
@@ -1074,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
|
|||||||
error = if (o.has("error")) o.getString("error") else null,
|
error = if (o.has("error")) o.getString("error") else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models on one machine, which is the list a llama.cpp session there can choose from.
|
||||||
|
*
|
||||||
|
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
|
||||||
|
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
|
||||||
|
* offering the backend's would name files that are not there, turning a choice that cannot work
|
||||||
|
* into a session that fails when it tries to load one.
|
||||||
|
*/
|
||||||
|
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
|
||||||
|
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
|
||||||
|
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
||||||
|
LocalModel(
|
||||||
|
key = m.getString("key"),
|
||||||
|
repo = m.getString("repo"),
|
||||||
|
file = m.getString("file"),
|
||||||
|
bytes = m.getLong("bytes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchModels(settings: ServerSettings): Models =
|
fun fetchModels(settings: ServerSettings): Models =
|
||||||
requestFromServer(settings, "/models") { connection ->
|
requestFromServer(settings, "/models") { connection ->
|
||||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
|
|||||||
* session, spawning one, and settings.
|
* session, spawning one, and settings.
|
||||||
*/
|
*/
|
||||||
private sealed class Screen {
|
private sealed class Screen {
|
||||||
data object Main : Screen()
|
/**
|
||||||
|
* The session list, with a subagent's own transcript over it when [subagent] is set.
|
||||||
|
*
|
||||||
|
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
|
||||||
|
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
|
||||||
|
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
|
||||||
|
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
|
||||||
|
* itself the moment its own subagent's view was closed.
|
||||||
|
*/
|
||||||
|
data class Main(val subagent: SubagentTarget? = null) : Screen()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
||||||
|
* docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
||||||
|
* is opened from the session list's card rather than from inside the session it belongs to.
|
||||||
|
*/
|
||||||
|
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One session, with the file explorer over it when [files] is set.
|
* One session, with the file explorer over it when [files] is set.
|
||||||
@@ -81,7 +99,7 @@ fun AppRoot(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||||
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
|
||||||
// A notification tap this could not follow, and why. Null both before one is asked for and
|
// A notification tap this could not follow, and why. Null both before one is asked for and
|
||||||
// after one succeeds, since success is a screen rather than a message.
|
// after one succeeds, since success is a screen rather than a message.
|
||||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||||
@@ -96,7 +114,7 @@ fun AppRoot(
|
|||||||
share = shareRequest
|
share = shareRequest
|
||||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
// A session already open takes it. Otherwise the list is where the choice is made,
|
||||||
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
||||||
if (screen !is Screen.Session) screen = Screen.Main
|
if (screen !is Screen.Session) screen = Screen.Main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +141,7 @@ fun AppRoot(
|
|||||||
existing = null,
|
existing = null,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
settings = saved
|
settings = saved
|
||||||
screen = Screen.Main
|
screen = Screen.Main()
|
||||||
},
|
},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
)
|
)
|
||||||
@@ -136,7 +154,7 @@ fun AppRoot(
|
|||||||
// shows, so it always refetches.
|
// shows, so it always refetches.
|
||||||
val goToMain = {
|
val goToMain = {
|
||||||
reloadToken++
|
reloadToken++
|
||||||
screen = Screen.Main
|
screen = Screen.Main()
|
||||||
}
|
}
|
||||||
if (screen !is Screen.Main) {
|
if (screen !is Screen.Main) {
|
||||||
BackHandler(onBack = goToMain)
|
BackHandler(onBack = goToMain)
|
||||||
@@ -185,6 +203,9 @@ fun AppRoot(
|
|||||||
reloadToken = reloadToken,
|
reloadToken = reloadToken,
|
||||||
share = share,
|
share = share,
|
||||||
onOpen = { screen = Screen.Session(it) },
|
onOpen = { screen = Screen.Session(it) },
|
||||||
|
onOpenSubagent = { summary, subagent ->
|
||||||
|
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
||||||
|
},
|
||||||
onSpawn = { screen = Screen.Spawn },
|
onSpawn = { screen = Screen.Spawn },
|
||||||
onImported = { imported ->
|
onImported = { imported ->
|
||||||
reloadToken++
|
reloadToken++
|
||||||
@@ -192,6 +213,27 @@ fun AppRoot(
|
|||||||
},
|
},
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
)
|
)
|
||||||
|
// Its own back handler is registered after MainScreen's, so it is the one the
|
||||||
|
// platform asks first while a subagent is open -- the same rule the files
|
||||||
|
// explorer's handler follows over its session, below.
|
||||||
|
here.subagent?.let { target ->
|
||||||
|
BackHandler { screen = here.copy(subagent = null) }
|
||||||
|
// Its own opaque background: this screen was always the sole content under
|
||||||
|
// the theme's own Surface before, so it never had to paint one -- stacked over
|
||||||
|
// the list here, the space between its own cards let the list underneath show
|
||||||
|
// through without this. The same fix FilesScreen needed over its session.
|
||||||
|
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
|
key(target.summary.id, target.subagent.id) {
|
||||||
|
SessionScreen(
|
||||||
|
settings = current,
|
||||||
|
summary = target.summary,
|
||||||
|
onBack = { screen = here.copy(subagent = null) },
|
||||||
|
onFiles = {},
|
||||||
|
subagent = target.subagent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Screen.Session ->
|
is Screen.Session ->
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
// Keyed on the id, because a different session is a different screen rather than this
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
|
||||||
|
* backend, so the `bench` build type can drive a real session screen -- the real
|
||||||
|
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
|
||||||
|
*
|
||||||
|
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||||
|
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
|
||||||
|
* that both [MainActivity] (which every variant compiles) and this can share without one.
|
||||||
|
*
|
||||||
|
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
|
||||||
|
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
|
||||||
|
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
|
||||||
|
* `https://` connection to that host and answers from this object's in-memory event log instead of
|
||||||
|
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
|
||||||
|
* the fold, uniqueItems) never learns the difference.
|
||||||
|
*/
|
||||||
|
object BenchFixture {
|
||||||
|
const val FIXTURE_HOST = "bench.fixture.invalid"
|
||||||
|
const val FIXTURE_PORT = 1
|
||||||
|
|
||||||
|
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||||
|
private const val BACKLOG_COUNT = 3202
|
||||||
|
|
||||||
|
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||||
|
|
||||||
|
/** The session id every bench run opens; nothing else in this build ever mints one. */
|
||||||
|
const val SESSION_ID = "bench-fixture-session"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
|
||||||
|
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
|
||||||
|
* live frame agree on what has "already happened" -- the same thing a real server's own
|
||||||
|
* transcript file guarantees.
|
||||||
|
*/
|
||||||
|
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
|
||||||
|
|
||||||
|
/** The events not yet appended to [log] -- the streaming phase's own source. */
|
||||||
|
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
|
||||||
|
|
||||||
|
private val images = mutableMapOf<String, ByteArray>()
|
||||||
|
|
||||||
|
@Volatile private var loaded = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun ensureLoaded(context: Context) {
|
||||||
|
if (loaded) return
|
||||||
|
val lines =
|
||||||
|
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
|
||||||
|
it.isNotBlank()
|
||||||
|
}
|
||||||
|
val parsed = lines.map { it to parseSeqEvent(it) }
|
||||||
|
log.addAll(parsed.take(BACKLOG_COUNT))
|
||||||
|
streamTail = parsed.drop(BACKLOG_COUNT)
|
||||||
|
for (name in listOf("bench1.png", "bench2.png")) {
|
||||||
|
images[name] = context.assets.open(name).readBytes()
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events the streaming phase has left to send. */
|
||||||
|
fun remainingStreamEvents(): Int = streamTail.size
|
||||||
|
|
||||||
|
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
|
||||||
|
fun pushNextLiveEvent(): Boolean {
|
||||||
|
val next = streamTail.firstOrNull() ?: return false
|
||||||
|
streamTail = streamTail.drop(1)
|
||||||
|
log.add(next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
|
||||||
|
@Synchronized
|
||||||
|
fun resetToBacklog(context: Context) {
|
||||||
|
loaded = false
|
||||||
|
log.clear()
|
||||||
|
ensureLoaded(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fileBytes(name: String): ByteArray? = images[name]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
|
||||||
|
*/
|
||||||
|
fun linesAfter(after: Long): List<String> =
|
||||||
|
log.filter { it.second.seq > after }.map { it.first }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
|
||||||
|
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
|
||||||
|
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
|
||||||
|
* client-core's own port tracks separately (CLIENT_CORE.md).
|
||||||
|
*/
|
||||||
|
fun page(before: Long?, limit: Int, after: Long?): List<String> {
|
||||||
|
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
|
||||||
|
val candidates = log.filter {
|
||||||
|
it.second.seq < upper && (after == null || it.second.seq > after)
|
||||||
|
}
|
||||||
|
return candidates.takeLast(limit).map { it.first }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.PipedInputStream
|
||||||
|
import java.io.PipedOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLStreamHandler
|
||||||
|
import java.net.URLStreamHandlerFactory
|
||||||
|
import java.security.Principal
|
||||||
|
import java.security.cert.Certificate
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
|
||||||
|
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
|
||||||
|
* per process, and a second real call throws -- so this guards it rather than relying on every
|
||||||
|
* caller to remember.
|
||||||
|
*
|
||||||
|
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
|
||||||
|
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun installFixtureNetworkOnce() {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
URL.setURLStreamHandlerFactory(
|
||||||
|
URLStreamHandlerFactory { protocol ->
|
||||||
|
if (protocol != "https") null
|
||||||
|
else
|
||||||
|
object : URLStreamHandler() {
|
||||||
|
override fun openConnection(url: URL): HttpURLConnection =
|
||||||
|
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
|
||||||
|
else
|
||||||
|
// The bench build makes no other https call -- this factory is
|
||||||
|
// installed only in FIXTURE_MODE (MainActivity) -- so there is
|
||||||
|
// deliberately no delegate to a platform handler here: once a
|
||||||
|
// URLStreamHandlerFactory is installed there is no supported way to
|
||||||
|
// ask the JDK for its own default handler back, and re-entering this
|
||||||
|
// same factory for the fallback would recurse forever rather than
|
||||||
|
// reach one.
|
||||||
|
throw java.io.IOException(
|
||||||
|
"bench build's fixture network has no route to https host " +
|
||||||
|
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var installed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
|
||||||
|
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
|
||||||
|
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
|
||||||
|
* `responseCode` and `inputStream`.
|
||||||
|
*/
|
||||||
|
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
|
||||||
|
private var input: InputStream? = null
|
||||||
|
private var writer: Thread? = null
|
||||||
|
|
||||||
|
override fun connect() {
|
||||||
|
if (input != null) return
|
||||||
|
input = route(url.path, url.query)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
writer?.interrupt()
|
||||||
|
try {
|
||||||
|
input?.close()
|
||||||
|
} catch (_: IOException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun usingProxy() = false
|
||||||
|
|
||||||
|
override fun getResponseCode(): Int {
|
||||||
|
connect()
|
||||||
|
return 200
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInputStream(): InputStream {
|
||||||
|
connect()
|
||||||
|
return input!!
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getErrorStream(): InputStream? = null
|
||||||
|
|
||||||
|
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
|
||||||
|
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
|
||||||
|
// fabricating a plausible-looking certificate.
|
||||||
|
override fun getCipherSuite() = "none (bench fixture, no TLS)"
|
||||||
|
|
||||||
|
override fun getLocalCertificates(): Array<Certificate>? = null
|
||||||
|
|
||||||
|
override fun getServerCertificates(): Array<Certificate> =
|
||||||
|
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||||
|
|
||||||
|
override fun getPeerPrincipal(): Principal =
|
||||||
|
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||||
|
|
||||||
|
override fun getLocalPrincipal(): Principal? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
|
||||||
|
*/
|
||||||
|
private fun route(path: String, query: String?): InputStream {
|
||||||
|
val params =
|
||||||
|
(query ?: "")
|
||||||
|
.split("&")
|
||||||
|
.filter { it.contains('=') }
|
||||||
|
.associate {
|
||||||
|
val (k, v) = it.split("=", limit = 2)
|
||||||
|
k to java.net.URLDecoder.decode(v, "UTF-8")
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
path.endsWith("/transcript") -> {
|
||||||
|
val lines =
|
||||||
|
BenchFixture.page(
|
||||||
|
before = params["before"]?.toLongOrNull(),
|
||||||
|
limit = params["limit"]?.toIntOrNull() ?: 80,
|
||||||
|
after = params["after"]?.toLongOrNull(),
|
||||||
|
)
|
||||||
|
val body = JSONArray(lines.map { org.json.JSONObject(it) })
|
||||||
|
ByteArrayInputStream(body.toString().toByteArray())
|
||||||
|
}
|
||||||
|
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
|
||||||
|
path.contains("/files/") -> {
|
||||||
|
val name = path.substringAfterLast("/files/")
|
||||||
|
val bytes =
|
||||||
|
BenchFixture.fileBytes(name)
|
||||||
|
?: throw IOException("bench fixture has no file named $name")
|
||||||
|
ByteArrayInputStream(bytes)
|
||||||
|
}
|
||||||
|
else -> throw IOException("bench fixture has no route for $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
|
||||||
|
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
|
||||||
|
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
|
||||||
|
* rather than something worth a condition variable for.
|
||||||
|
*/
|
||||||
|
private fun openEventsStream(after: Long): InputStream {
|
||||||
|
val pipeIn = PipedInputStream(1 shl 16)
|
||||||
|
val pipeOut = PipedOutputStream(pipeIn)
|
||||||
|
var sent = after
|
||||||
|
val thread = Thread {
|
||||||
|
try {
|
||||||
|
while (!Thread.currentThread().isInterrupted) {
|
||||||
|
val fresh = BenchFixture.linesAfter(sent)
|
||||||
|
for (line in fresh) {
|
||||||
|
pipeOut.write("data: $line\n\n".toByteArray())
|
||||||
|
pipeOut.flush()
|
||||||
|
sent = org.json.JSONObject(line).getLong("seq")
|
||||||
|
}
|
||||||
|
Thread.sleep(50)
|
||||||
|
}
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
// disconnect() -- the ordinary way this ends.
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// The reader side (Sse) closed its end.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
pipeOut.close()
|
||||||
|
} catch (_: IOException) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.also {
|
||||||
|
it.isDaemon = true
|
||||||
|
it.start()
|
||||||
|
}
|
||||||
|
writer = thread
|
||||||
|
return pipeIn
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Process
|
||||||
|
import android.view.View
|
||||||
|
import androidx.compose.foundation.gestures.FlingBehavior
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
|
||||||
|
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
|
||||||
|
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
|
||||||
|
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||||
|
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||||
|
* [BenchFixture]'s doc comment gives.
|
||||||
|
*
|
||||||
|
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
|
||||||
|
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
|
||||||
|
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
|
||||||
|
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
|
||||||
|
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
|
||||||
|
* **keyboard** (five show/hide cycles). The exact constants below are also written into
|
||||||
|
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
|
||||||
|
* spec -- changing a number here without updating that box makes the two apps measure different
|
||||||
|
* things while looking like the same benchmark.
|
||||||
|
*/
|
||||||
|
object BenchRun {
|
||||||
|
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
|
||||||
|
private const val CYCLES = 6
|
||||||
|
private const val SWIPE_PX = 900f
|
||||||
|
private const val SWIPE_MS = 200
|
||||||
|
private const val SWIPE_PAUSE_MS = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
|
||||||
|
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
|
||||||
|
* never exceed the distance it is told to cover in the time it is given, while a real fling
|
||||||
|
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
|
||||||
|
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
|
||||||
|
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
|
||||||
|
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
|
||||||
|
*/
|
||||||
|
private const val FLING_VELOCITY_PX_S = 12_000f
|
||||||
|
|
||||||
|
private const val FLING_COUNT = 8
|
||||||
|
private const val FLING_SETTLE_CAP_MS = 3_000L
|
||||||
|
private const val FLING_PAUSE_MS = 300L
|
||||||
|
|
||||||
|
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
|
||||||
|
private const val STREAM_EVENTS_PER_SEC = 20
|
||||||
|
private const val STREAM_SECONDS = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
|
||||||
|
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
|
||||||
|
* own height grows over several frames, pushing the transcript above it upward the same way a
|
||||||
|
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
|
||||||
|
* half types the identical content.
|
||||||
|
*/
|
||||||
|
const val TYPE_TEXT =
|
||||||
|
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
|
||||||
|
"wrapping and reflow are properly exercised: internationalization, " +
|
||||||
|
"counterproductiveness, disproportionately, incomprehensibility, " +
|
||||||
|
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
|
||||||
|
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
|
||||||
|
"collaboration all push a narrow composer field to wrap across several lines while " +
|
||||||
|
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
|
||||||
|
"is exactly what a real reader typing a long message sees happening now!!!"
|
||||||
|
|
||||||
|
private const val TYPE_CHAR_DELAY_MS = 50L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
|
||||||
|
* transition is ever actually observed rather than being a one-off fluke either way.
|
||||||
|
*/
|
||||||
|
private const val KEYBOARD_CYCLES = 5
|
||||||
|
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
|
||||||
|
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
|
||||||
|
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
|
||||||
|
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
|
||||||
|
* resets them, so the two accountings cover the same stretch of work.
|
||||||
|
*/
|
||||||
|
suspend fun run(
|
||||||
|
context: Context,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
listState: LazyListState,
|
||||||
|
flingBehavior: FlingBehavior,
|
||||||
|
composerFocus: FocusRequester,
|
||||||
|
setComposerText: (String) -> Unit,
|
||||||
|
view: View,
|
||||||
|
): List<String> {
|
||||||
|
FrameStats.reset()
|
||||||
|
DebugStats.reset()
|
||||||
|
val cpuStartMs = Process.getElapsedCpuTime()
|
||||||
|
|
||||||
|
val battery = BatterySampler(context)
|
||||||
|
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
|
||||||
|
// suspend this function until the sampler job ended -- and it only ends when told to.
|
||||||
|
val samplerJob = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
battery.sample()
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val travel = runFlingPhase(listState, flingBehavior)
|
||||||
|
val sent = runStreamPhase()
|
||||||
|
runTypePhase(listState, composerFocus, setComposerText, view)
|
||||||
|
val keyboard = runKeyboardPhase(context, view)
|
||||||
|
|
||||||
|
samplerJob.cancel()
|
||||||
|
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||||
|
val rssLine = peakRssLine()
|
||||||
|
val batteryLine = battery.finish()
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
|
||||||
|
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
|
||||||
|
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
|
||||||
|
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
|
||||||
|
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
|
||||||
|
" ${TYPE_CHAR_DELAY_MS}ms",
|
||||||
|
keyboard,
|
||||||
|
" process CPU time over this run: ${cpuMs}ms",
|
||||||
|
rssLine,
|
||||||
|
batteryLine,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
|
||||||
|
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
|
||||||
|
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
|
||||||
|
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
|
||||||
|
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
|
||||||
|
* the pre-v2 swipe loop below already used for its first two swipes.
|
||||||
|
*/
|
||||||
|
private suspend fun runFlingPhase(
|
||||||
|
listState: LazyListState,
|
||||||
|
flingBehavior: FlingBehavior,
|
||||||
|
): String {
|
||||||
|
FrameStats.markPhase("fling")
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
val start = position(listState)
|
||||||
|
repeat(FLING_COUNT) {
|
||||||
|
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
|
||||||
|
waitForSettle(listState)
|
||||||
|
delay(FLING_PAUSE_MS)
|
||||||
|
}
|
||||||
|
val outward = position(listState)
|
||||||
|
repeat(FLING_COUNT) {
|
||||||
|
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
|
||||||
|
waitForSettle(listState)
|
||||||
|
delay(FLING_PAUSE_MS)
|
||||||
|
}
|
||||||
|
val back = position(listState)
|
||||||
|
return "start=$start outward=$outward end=$back"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun position(listState: LazyListState) =
|
||||||
|
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
|
||||||
|
|
||||||
|
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
|
||||||
|
private suspend fun waitForSettle(listState: LazyListState) {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
while (
|
||||||
|
listState.isScrollInProgress &&
|
||||||
|
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
|
||||||
|
) {
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
|
||||||
|
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
|
||||||
|
* arrives off-screen and the report would show nothing happened.
|
||||||
|
*/
|
||||||
|
private suspend fun runStreamPhase(): Int {
|
||||||
|
FrameStats.markPhase("stream")
|
||||||
|
var sent = 0
|
||||||
|
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||||
|
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||||
|
BenchFixture.pushNextLiveEvent()
|
||||||
|
sent++
|
||||||
|
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||||
|
}
|
||||||
|
// Lets the last few deltas land and draw before the next phase starts.
|
||||||
|
delay(300)
|
||||||
|
return sent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
|
||||||
|
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
|
||||||
|
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
|
||||||
|
* being pushed upward, not a single big write.
|
||||||
|
*/
|
||||||
|
private suspend fun runTypePhase(
|
||||||
|
listState: LazyListState,
|
||||||
|
composerFocus: FocusRequester,
|
||||||
|
setComposerText: (String) -> Unit,
|
||||||
|
view: View,
|
||||||
|
) {
|
||||||
|
FrameStats.markPhase("type")
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
composerFocus.requestFocus()
|
||||||
|
showIme(view.context, view)
|
||||||
|
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
|
||||||
|
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
|
||||||
|
delay(300)
|
||||||
|
var typed = ""
|
||||||
|
for (ch in TYPE_TEXT) {
|
||||||
|
typed += ch
|
||||||
|
setComposerText(typed)
|
||||||
|
delay(TYPE_CHAR_DELAY_MS)
|
||||||
|
}
|
||||||
|
delay(200)
|
||||||
|
while (typed.isNotEmpty()) {
|
||||||
|
typed = typed.dropLast(1)
|
||||||
|
setComposerText(typed)
|
||||||
|
delay(TYPE_CHAR_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
|
||||||
|
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
|
||||||
|
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
|
||||||
|
* never present an inferred value as a measured one. If the platform never shows it even once,
|
||||||
|
* this says so in words rather than reporting a phase with no keyboard in it.
|
||||||
|
*/
|
||||||
|
private suspend fun runKeyboardPhase(context: Context, view: View): String {
|
||||||
|
FrameStats.markPhase("keyboard")
|
||||||
|
var shown = 0
|
||||||
|
var hidden = 0
|
||||||
|
repeat(KEYBOARD_CYCLES) {
|
||||||
|
showIme(context, view)
|
||||||
|
delay(KEYBOARD_SHOW_WAIT_MS)
|
||||||
|
if (imeVisible(view)) shown++
|
||||||
|
hideIme(context, view)
|
||||||
|
delay(KEYBOARD_HIDE_WAIT_MS)
|
||||||
|
if (!imeVisible(view)) hidden++
|
||||||
|
}
|
||||||
|
return if (shown == 0) {
|
||||||
|
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
|
||||||
|
} else {
|
||||||
|
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
|
||||||
|
" (confirmed via isImeVisible)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
|
||||||
|
val window = context.activity()?.window ?: return null
|
||||||
|
return WindowInsetsControllerCompat(window, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showIme(context: Context, view: View) {
|
||||||
|
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideIme(context: Context, view: View) {
|
||||||
|
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imeVisible(view: View): Boolean =
|
||||||
|
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
|
||||||
|
|
||||||
|
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||||
|
private fun peakRssLine(): String {
|
||||||
|
val kb =
|
||||||
|
try {
|
||||||
|
File("/proc/self/status")
|
||||||
|
.readLines()
|
||||||
|
.firstOrNull { it.startsWith("VmHWM:") }
|
||||||
|
?.trim()
|
||||||
|
?.removePrefix("VmHWM:")
|
||||||
|
?.trim()
|
||||||
|
?.removeSuffix("kB")
|
||||||
|
?.trim()
|
||||||
|
?.toLongOrNull()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return " peak RSS: " +
|
||||||
|
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
|
||||||
|
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
|
||||||
|
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
|
||||||
|
* real samples, which would silently understate every number after it. See UI_RULES: never present
|
||||||
|
* an inferred value as a measured one.
|
||||||
|
*/
|
||||||
|
private class BatterySampler(context: Context) {
|
||||||
|
private val manager = context.getSystemService(BatteryManager::class.java)
|
||||||
|
private val samples = mutableListOf<Int>()
|
||||||
|
|
||||||
|
fun sample() {
|
||||||
|
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||||
|
if (value != null && value != Int.MIN_VALUE) samples.add(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish(): String {
|
||||||
|
if (samples.isEmpty()) return " battery current: unavailable on this device"
|
||||||
|
val meanUa = samples.sum() / samples.size
|
||||||
|
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
|
||||||
|
" (min ${samples.min()}, max ${samples.max()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,20 @@ fun debugReport(
|
|||||||
frames: List<String>,
|
frames: List<String>,
|
||||||
accounting: List<String>,
|
accounting: List<String>,
|
||||||
crash: String?,
|
crash: String?,
|
||||||
|
/**
|
||||||
|
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
|
||||||
|
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
|
||||||
|
* heading only appears when there is something to put under it, so an ordinary copy from the
|
||||||
|
* render-report button reads exactly as it did before this existed.
|
||||||
|
*/
|
||||||
|
extra: List<String> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
|
||||||
|
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
|
||||||
|
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
|
||||||
|
* [extra].
|
||||||
|
*/
|
||||||
|
phaseFrames: List<String> = emptyList(),
|
||||||
): String = buildString {
|
): String = buildString {
|
||||||
appendLine("ai-app render report")
|
appendLine("ai-app render report")
|
||||||
appendLine(device)
|
appendLine(device)
|
||||||
@@ -141,6 +155,11 @@ fun debugReport(
|
|||||||
appendLine("transcript:")
|
appendLine("transcript:")
|
||||||
transcript.forEach { appendLine(it) }
|
transcript.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
|
if (phaseFrames.isNotEmpty()) {
|
||||||
|
appendLine("per phase:")
|
||||||
|
phaseFrames.forEach { appendLine(it) }
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
appendLine("frames:")
|
appendLine("frames:")
|
||||||
frames.forEach { appendLine(it) }
|
frames.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
@@ -152,6 +171,11 @@ fun debugReport(
|
|||||||
appendLine("work since this was last copied:")
|
appendLine("work since this was last copied:")
|
||||||
val work = DebugStats.lines()
|
val work = DebugStats.lines()
|
||||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
||||||
|
if (extra.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("bench:")
|
||||||
|
extra.forEach { appendLine(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A line across the transcript saying what left the session's context.
|
* A line across the transcript saying what left the session's context.
|
||||||
@@ -47,3 +51,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
|||||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
fun ClearedRow(modifier: Modifier = Modifier) {
|
||||||
TranscriptDivider("Context cleared", clearedColor, modifier)
|
TranscriptDivider("Context cleared", clearedColor, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark running out of quota leaves.
|
||||||
|
*
|
||||||
|
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
|
||||||
|
* place: colour by consequence, so "there is nothing left to spend" is learned once.
|
||||||
|
*
|
||||||
|
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
|
||||||
|
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
|
||||||
|
* which is not a promise about when the session picks back up. A limit the session was told no
|
||||||
|
* reset time for says nothing about one -- that state has its own words rather than a plausible
|
||||||
|
* number.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
|
||||||
|
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the row says. Split out so the wording is testable without a screen, since the two states it
|
||||||
|
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
|
||||||
|
* that reads the same when it goes wrong.
|
||||||
|
*
|
||||||
|
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
|
||||||
|
*/
|
||||||
|
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
|
||||||
|
val at = resetsAt?.let {
|
||||||
|
try {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(Instant.ofEpochSecond(it.toLong()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
|
|||||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
||||||
* saw as the new cursor.
|
* saw as the new cursor.
|
||||||
*/
|
*/
|
||||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
|
||||||
private val stream = Sse(settings)
|
private val stream = Sse(settings)
|
||||||
|
|
||||||
fun close() = stream.close()
|
fun close() = stream.close()
|
||||||
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
|||||||
// one and the screen folds the other, and they have to be the same line.
|
// one and the screen folds the other, and they have to be the same line.
|
||||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
|
||||||
// A named frame carries no payload and a data frame has no name.
|
// A named frame carries no payload and a data frame has no name.
|
||||||
if (name == RESET_EVENT) onReset()
|
if (name == RESET_EVENT) onReset()
|
||||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data object Cleared : SessionEvent()
|
data object Cleared : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session stopped because its account's usage limit was reached.
|
||||||
|
*
|
||||||
|
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
|
||||||
|
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
|
||||||
|
* limit reached|1788546972`, which is not readable by the person it is shown to.
|
||||||
|
*
|
||||||
|
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
|
||||||
|
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
|
||||||
|
*/
|
||||||
|
data class LimitReached(val resetsAt: Double?) : SessionEvent()
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +273,10 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
trigger = body.optString("trigger").ifEmpty { null },
|
trigger = body.optString("trigger").ifEmpty { null },
|
||||||
)
|
)
|
||||||
"cleared" -> SessionEvent.Cleared
|
"cleared" -> SessionEvent.Cleared
|
||||||
|
"limitReached" ->
|
||||||
|
SessionEvent.LimitReached(
|
||||||
|
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||||
|
)
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ object FrameStats {
|
|||||||
private val gpu = ArrayList<Long>()
|
private val gpu = ArrayList<Long>()
|
||||||
private var since = System.currentTimeMillis()
|
private var since = System.currentTimeMillis()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
|
||||||
|
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
|
||||||
|
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
|
||||||
|
*/
|
||||||
|
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
|
||||||
|
|
||||||
|
private val phaseMarks = ArrayList<PhaseMark>()
|
||||||
|
|
||||||
|
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
|
||||||
|
@Synchronized
|
||||||
|
fun markPhase(name: String) {
|
||||||
|
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun add(metrics: FrameMetrics) {
|
fun add(metrics: FrameMetrics) {
|
||||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||||
@@ -69,6 +84,7 @@ object FrameStats {
|
|||||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||||
it.clear()
|
it.clear()
|
||||||
}
|
}
|
||||||
|
phaseMarks.clear()
|
||||||
since = System.currentTimeMillis()
|
since = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +111,38 @@ object FrameStats {
|
|||||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
|
||||||
|
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
|
||||||
|
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
|
||||||
|
* that finished before a frame was drawn) still get a line rather than being silently dropped
|
||||||
|
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun phaseLines(refreshHz: Float): List<String> {
|
||||||
|
if (phaseMarks.isEmpty()) return emptyList()
|
||||||
|
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||||
|
val lines = ArrayList<String>()
|
||||||
|
phaseMarks.forEachIndexed { i, mark ->
|
||||||
|
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
|
||||||
|
val endMs =
|
||||||
|
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
|
||||||
|
else System.currentTimeMillis()
|
||||||
|
val samples = total.subList(mark.startIndex, endIndex)
|
||||||
|
val seconds = (endMs - mark.startMs) / 1000.0
|
||||||
|
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
|
||||||
|
if (samples.isEmpty()) {
|
||||||
|
lines += " no frames recorded in this phase"
|
||||||
|
} else {
|
||||||
|
val late = samples.count { it / 1_000_000.0 > budget }
|
||||||
|
lines += " late: $late (${percent(late, samples.size)})"
|
||||||
|
lines += " " + phase("total ", samples)
|
||||||
|
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
// Transparent status bar on every version; the Surface below paints through underneath it
|
||||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
|
||||||
|
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
|
||||||
|
// permission, and no notification prompt -- none of them mean anything with no server and
|
||||||
|
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
|
||||||
|
// to talk to a real backend is made to talk to this instead. Still needs the same
|
||||||
|
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
|
||||||
|
// platform's own default from Android 15 on this app's targetSdk, with or without the call
|
||||||
|
// above, so skipping the padding here put the header's own buttons under the status bar --
|
||||||
|
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
|
||||||
|
if (BuildConfig.FIXTURE_MODE) {
|
||||||
|
installFixtureNetworkOnce()
|
||||||
|
BenchFixture.ensureLoaded(this)
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(colorScheme = AiAppColors) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
|
||||||
|
SessionScreen(
|
||||||
|
settings = BenchFixture.settings,
|
||||||
|
summary = benchSessionSummary(),
|
||||||
|
onBack = { finish() },
|
||||||
|
onFiles = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
||||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||||
@@ -147,6 +176,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
|
||||||
|
private fun benchSessionSummary() =
|
||||||
|
SessionSummary(
|
||||||
|
id = BenchFixture.SESSION_ID,
|
||||||
|
setup = "bench",
|
||||||
|
setupName = "bench",
|
||||||
|
provider = "bench",
|
||||||
|
title = "P0 benchmark",
|
||||||
|
model = null,
|
||||||
|
keepsOwnTranscript = false,
|
||||||
|
permissionMode = null,
|
||||||
|
effort = null,
|
||||||
|
takesEffort = false,
|
||||||
|
imported = false,
|
||||||
|
notify = false,
|
||||||
|
autoResume = false,
|
||||||
|
autoResumeMessage = "",
|
||||||
|
resumeAt = null,
|
||||||
|
cwd = null,
|
||||||
|
contextTokens = null,
|
||||||
|
maxImageEdge = null,
|
||||||
|
usageProvider = null,
|
||||||
|
status = "idle",
|
||||||
|
lastActivity = 0.0,
|
||||||
|
subagents = 0,
|
||||||
|
)
|
||||||
|
|
||||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||||
// lands here rather than in a second activity instance.
|
// lands here rather than in a second activity instance.
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|||||||
Loaded 100 of 348 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user