Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8602c1626 | ||
|
|
f49b284b46 | ||
|
|
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
|
||||
# every crate in this repo into one workspace -- they are deliberately
|
||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
||||
# from the repo root runs xtask/src/main.rs directly.
|
||||
# independent (see scripts/run-tests.sh, which cds into each).
|
||||
#
|
||||
# `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]
|
||||
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`
|
||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
||||
// both need.
|
||||
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
|
||||
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
|
||||
// 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(
|
||||
name: "shell",
|
||||
modes: ["release", "debug"],
|
||||
|
||||
+6
-9
@@ -9,8 +9,7 @@ local.properties
|
||||
.DS_Store
|
||||
server/target/
|
||||
event-model/target/
|
||||
client-core/target/
|
||||
android-shell/target/
|
||||
app-rust/target/
|
||||
|
||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||
# (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/target/
|
||||
iris/android-app/target/
|
||||
|
||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
||||
# The repo root has no Cargo workspace, so this is xtask's own
|
||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
||||
xtask/target/
|
||||
/target/
|
||||
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
|
||||
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
|
||||
# publishes for Dev Updater to find.
|
||||
scripts/xtask/target/
|
||||
scripts/rigs/gpu-probe/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
|
||||
between them.
|
||||
|
||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
||||
its rationale, and what was rejected. Read it before changing anything
|
||||
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||||
date, its rationale, and what was rejected. Read it before changing anything
|
||||
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
|
||||
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
|
||||
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,
|
||||
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
|
||||
|
||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||||
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
|
||||
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".
|
||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||
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
|
||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||
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
|
||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||
sessions and setups are what makes this project itself.
|
||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
||||
and stream effects in `SessionScreen.kt`.
|
||||
- `TODO.md` — the working list.
|
||||
- `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/` — every design and working document except this file and
|
||||
`CLAUDE.md`:
|
||||
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||||
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||||
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||
the opening and stream effects in `SessionScreen.kt`.
|
||||
- `docs/TODO.md` — the working list.
|
||||
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
|
||||
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
|
||||
`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
|
||||
`~/.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
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
||||
`server/`), plus `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.
|
||||
- **Commit completed work.** Once a coherent piece of work has passed its
|
||||
relevant checks and has no known major issue or unresolved design decision,
|
||||
commit it rather than leaving it in the worktree. Keep independently
|
||||
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/`,
|
||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||||
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
|
||||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||||
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
|
||||
place "how far had this phone fallen behind" is answerable — the app sees a
|
||||
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
|
||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||
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.
|
||||
|
||||
- **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
|
||||
and data directory, holding eight invented Claude Code transcripts and a
|
||||
`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
|
||||
how the cost of a layout node per link was attributed to the framework
|
||||
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
|
||||
|
||||
@@ -291,13 +546,11 @@ thing to suspect first if a remote spawn ever mangles an argument.
|
||||
|
||||
## Where things run (host vs this VM)
|
||||
|
||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
||||
means here:
|
||||
This checkout runs in a VM while production runs on its host:
|
||||
|
||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||
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>`.
|
||||
- **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.
|
||||
@@ -312,7 +565,7 @@ means here:
|
||||
## Sessions outlive the backend
|
||||
|
||||
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:
|
||||
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
Project-specific only — a lesson that would bite any project on this machine
|
||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
Project-specific only; keep cross-project machine notes out of this file.
|
||||
|
||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||
`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
|
||||
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.
|
||||
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
|
||||
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
||||
// with one deliberate change: `protected` rather than package-private, so a
|
||||
// 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.
|
||||
// Vendored from android-view bec6c62. The only local change is `protected`,
|
||||
// allowing IrisView to forward insets through this native peer.
|
||||
protected final long mViewPeer;
|
||||
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() {
|
||||
DesktopApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DesktopUiState)]
|
||||
pub struct Client {
|
||||
ui_state: DesktopUiState,
|
||||
#[allow(dead_code)]
|
||||
screen: Option<ai_app::ui::TranscriptScreen>,
|
||||
}
|
||||
|
||||
impl DesktopAppState 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: DesktopUiState,
|
||||
rsc: &mut DesktopRsc<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() {
|
||||
DesktopApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DesktopUiState)]
|
||||
pub struct Client {
|
||||
ui_state: DesktopUiState,
|
||||
#[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 DesktopAppState for Client {
|
||||
fn new(
|
||||
mut ui_state: DesktopUiState,
|
||||
rsc: &mut DesktopRsc<Self>,
|
||||
_: Proxy<Self::Event>,
|
||||
) -> Self {
|
||||
let mut 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,101 @@
|
||||
//! 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};
|
||||
use std::{
|
||||
fs, panic,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
/// Installs the in-process ring in front of Android's logger.
|
||||
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()
|
||||
{
|
||||
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}"),
|
||||
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";
|
||||
|
||||
/// Enough preceding log lines to explain a crash without evicting the next run.
|
||||
const CRASH_CONTEXT_LINES: usize = 80;
|
||||
|
||||
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||
|
||||
static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Copies aborting panics into the device-readable log ring.
|
||||
fn install_panic_hook() {
|
||||
let previous = panic::take_hook();
|
||||
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() {
|
||||
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()
|
||||
});
|
||||
let _ = fs::write(path, format!("{line}\n{context}"));
|
||||
}
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Configures crash persistence and replays a report left by the previous run.
|
||||
pub fn set_crash_dir(dir: &Path) {
|
||||
let path = dir.join(CRASH_FILE);
|
||||
if let Ok(previous) = fs::read_to_string(&path) {
|
||||
// Delete first so a panic during replay cannot create a replay loop.
|
||||
let _ = 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,888 @@
|
||||
use crate::android::bench_jni::PlatformHandle;
|
||||
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
||||
use crate::ui::{self, TranscriptScreen};
|
||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::{
|
||||
fs, mem,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
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<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 = 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> {
|
||||
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 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>) -> 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) = ui::build_tree(rsc, 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) = 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.as_mut() {
|
||||
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::{path::Path, ptr, 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(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 = 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,169 @@
|
||||
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};
|
||||
use log::LevelFilter;
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// 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 `DesktopAppState::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>) -> 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(Path::new(&dir));
|
||||
enrollment::set_files_dir(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,299 @@
|
||||
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, raw_seq,
|
||||
};
|
||||
use crate::ui::{self, TranscriptScreen};
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
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<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>) -> 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(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) = 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.as_mut() {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, session_id: String, text: String) {
|
||||
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;
|
||||
|
||||
/// 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)]
|
||||
pub struct AnsiPalette {
|
||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||
pub colours: [Rgb; 16],
|
||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||
pub foreground: Rgb,
|
||||
/// What the text sits on, needed for reverse video.
|
||||
pub background: Rgb,
|
||||
}
|
||||
|
||||
@@ -67,8 +37,6 @@ pub struct Style {
|
||||
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)]
|
||||
pub struct StyledText {
|
||||
pub text: String,
|
||||
@@ -87,11 +55,7 @@ impl StyledText {
|
||||
const ESC: char = '\u{1B}';
|
||||
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 {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated
|
||||
// to find that out.
|
||||
if !text.contains(ESC) && !text.contains('\r') {
|
||||
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') {
|
||||
// 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);
|
||||
drop_line(&mut runs);
|
||||
at += 1;
|
||||
} else if c == '\r' {
|
||||
at += 1;
|
||||
} 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);
|
||||
at += 1;
|
||||
} else {
|
||||
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
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>)>) {
|
||||
while let Some((text, style)) = runs.pop() {
|
||||
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 {
|
||||
('@'..='~').contains(&c)
|
||||
}
|
||||
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
end += 1;
|
||||
}
|
||||
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()
|
||||
} else {
|
||||
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' | '^' | '_' => {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
||||
// allows after an OSC.
|
||||
let mut end = at + 2;
|
||||
while end < chars.len() {
|
||||
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)]
|
||||
struct Sgr {
|
||||
fg: Option<Rgb>,
|
||||
@@ -227,7 +174,6 @@ struct Sgr {
|
||||
reverse: bool,
|
||||
}
|
||||
|
||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
||||
const DIM_ALPHA: f32 = 0.65;
|
||||
|
||||
impl Sgr {
|
||||
@@ -242,7 +188,6 @@ impl Sgr {
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||
if *self == Sgr::PLAIN {
|
||||
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 {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
||||
// zero too.
|
||||
let codes: Vec<i64> = params
|
||||
.split(';')
|
||||
.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) {
|
||||
match codes.get(at + 1) {
|
||||
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];
|
||||
|
||||
/// 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 {
|
||||
if n < 0 {
|
||||
palette.foreground
|
||||
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
||||
/// white foreground, black background.
|
||||
fn palette() -> AnsiPalette {
|
||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||
for (i, c) in colours.iter_mut().enumerate() {
|
||||
@@ -452,8 +378,6 @@ mod tests {
|
||||
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> {
|
||||
let out = styled(text);
|
||||
let at = out
|
||||
@@ -509,8 +433,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
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 event_model::SeqEvent;
|
||||
use serde::Deserialize;
|
||||
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
|
||||
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||
/// `Api.kt`.
|
||||
@@ -32,8 +20,6 @@ impl std::fmt::Display 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 {
|
||||
Json(Value),
|
||||
Bytes {
|
||||
@@ -50,10 +36,7 @@ pub struct RawResponse {
|
||||
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 {
|
||||
/// One request/response call -- everything but the long-lived SSE GETs.
|
||||
fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -61,10 +44,6 @@ pub trait Transport: Send + Sync {
|
||||
body: Option<Body>,
|
||||
) -> 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>;
|
||||
}
|
||||
|
||||
@@ -103,10 +82,6 @@ fn default_true() -> bool {
|
||||
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> {
|
||||
transport: T,
|
||||
}
|
||||
@@ -116,6 +91,14 @@ impl<T: Transport> ApiClient<T> {
|
||||
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>>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -256,7 +239,7 @@ impl<T: Transport> ApiClient<T> {
|
||||
/// A page of transcript history. `before` is the newest-first cursor
|
||||
/// (server default is "the newest page" when absent, which a caller
|
||||
/// 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
|
||||
/// lines (for the transcript cache) is not forced to parse them.
|
||||
pub fn fetch_transcript_page(
|
||||
@@ -266,23 +249,58 @@ impl<T: Transport> ApiClient<T> {
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
) -> Result<Vec<Value>, ApiError> {
|
||||
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");
|
||||
}
|
||||
self.json_request("GET", &path, None)
|
||||
self.json_request(
|
||||
"GET",
|
||||
&transcript_path(session_id, before, limit, coalesce, None),
|
||||
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/`
|
||||
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
||||
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
||||
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
||||
/// store -- the server's certificate is self-signed on purpose (see
|
||||
/// `wg-app-link`).
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
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 {
|
||||
agent: ureq::Agent,
|
||||
base_url: String,
|
||||
@@ -290,8 +308,6 @@ pub struct 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(
|
||||
base_url: impl Into<String>,
|
||||
token: impl Into<String>,
|
||||
@@ -306,10 +322,6 @@ impl UreqTransport {
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.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)
|
||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
@@ -387,9 +399,6 @@ impl Transport for UreqTransport {
|
||||
.get(&url)
|
||||
.header("Authorization", &auth)
|
||||
.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()
|
||||
.timeout_recv_response(None)
|
||||
.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 {
|
||||
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||
let message = if status == 401 {
|
||||
@@ -441,8 +447,6 @@ mod tests {
|
||||
use std::io::Cursor;
|
||||
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)]
|
||||
struct FakeTransport {
|
||||
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||
@@ -503,7 +507,6 @@ mod tests {
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "s1");
|
||||
assert_eq!(sessions[0].setup_name, "desktop");
|
||||
// Defaults for fields the server omits.
|
||||
assert!(sessions[0].notify);
|
||||
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 event_model::SeqEvent;
|
||||
|
||||
use crate::api::{ApiError, Transport};
|
||||
use crate::sse::SseReader;
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
use crate::client::sse::SseReader;
|
||||
|
||||
/// 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`.
|
||||
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
|
||||
/// callbacks were for, as a single enum instead, since Rust has no
|
||||
/// equivalent of handing three closures to one blocking call.
|
||||
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,
|
||||
/// 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,
|
||||
/// 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 },
|
||||
}
|
||||
|
||||
@@ -59,7 +43,6 @@ pub fn follow_session_events(
|
||||
let Some(frame) = reader.feed_line(&line) else {
|
||||
continue;
|
||||
};
|
||||
// A named frame carries no payload and a data frame has no name.
|
||||
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||
if !on_item(StreamItem::Reset) {
|
||||
return Ok(());
|
||||
@@ -83,7 +66,7 @@ pub fn follow_session_events(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::{Body, RawResponse};
|
||||
use crate::client::api::{Body, RawResponse};
|
||||
use std::io::Cursor;
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -32,8 +27,6 @@ pub enum Language {
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||
/// (`Language.entries`).
|
||||
pub const ALL: [Language; 22] = [
|
||||
Language::C,
|
||||
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)]
|
||||
pub struct Rules {
|
||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
||||
/// anything else.
|
||||
pub keywords: HashSet<&'static str>,
|
||||
/// Tokens that open a comment running to the end of the line.
|
||||
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 block_comment: Option<BlockComment>,
|
||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
||||
/// tried before `"`.
|
||||
pub quotes: Vec<Quote>,
|
||||
pub attributes: Attributes,
|
||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
||||
pub raw_strings: bool,
|
||||
/// Rust: `'` opens a character literal only when a backslash or one
|
||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||
@@ -91,8 +74,6 @@ pub struct BlockComment {
|
||||
pub nests: bool,
|
||||
}
|
||||
|
||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
||||
/// (and itself).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Quote {
|
||||
pub open: &'static str,
|
||||
@@ -100,18 +81,13 @@ pub struct Quote {
|
||||
pub escapes: bool,
|
||||
}
|
||||
|
||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum Attributes {
|
||||
#[default]
|
||||
None,
|
||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
||||
AtWord,
|
||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
||||
HashBracket,
|
||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
||||
HashLine,
|
||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
||||
LineBracket,
|
||||
}
|
||||
|
||||
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
|
||||
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 {
|
||||
match language {
|
||||
Language::C => Rules {
|
||||
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..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 {
|
||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||
line_comments: vec!["#"],
|
||||
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
keywords: words(KEYWORDS_SHELL),
|
||||
line_comments: vec!["#"],
|
||||
line_comments_at_word_start: true,
|
||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
Quote {
|
||||
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
||||
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 =
|
||||
"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
|
||||
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
|
||||
required rethrow return sealed set show static super switch this throw true try var void
|
||||
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 =
|
||||
"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";
|
||||
@@ -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
|
||||
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_RUBY: &str =
|
||||
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
|
||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||
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_TYPESCRIPT: &str =
|
||||
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<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
|
||||
/// `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
|
||||
@@ -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};
|
||||
|
||||
/// The characters an unordered list may be bulleted with.
|
||||
const BULLETS: &str = "-*+";
|
||||
/// The characters a thematic break, or a setext heading's underline, can be
|
||||
/// drawn with.
|
||||
const RULE_MARKERS: &str = "-*_=";
|
||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
||||
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_TRAILING: &str = ".,:;!?";
|
||||
|
||||
@@ -46,15 +28,10 @@ impl MarkdownScanner {
|
||||
// The delimiter run that opened the fenced block we are inside, or
|
||||
// None between them.
|
||||
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;
|
||||
loop {
|
||||
let end = self.line_end(at);
|
||||
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);
|
||||
if self.closes_fence(at, end, &open) {
|
||||
fence = None;
|
||||
@@ -76,7 +53,6 @@ impl MarkdownScanner {
|
||||
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 {
|
||||
self.code[at..]
|
||||
.iter()
|
||||
@@ -85,8 +61,6 @@ impl MarkdownScanner {
|
||||
.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 {
|
||||
if self.table_delimiter(start, end) {
|
||||
let indented = self.indented(start, end);
|
||||
@@ -102,13 +76,11 @@ impl MarkdownScanner {
|
||||
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 {
|
||||
let mut dashes = false;
|
||||
let mut pipes = false;
|
||||
for at in self.indented(start, end)..end {
|
||||
match self.code[at] {
|
||||
for c in &self.code[self.indented(start, end)..end] {
|
||||
match c {
|
||||
'-' => dashes = true,
|
||||
'|' => pipes = true,
|
||||
':' | ' ' | '\t' => {}
|
||||
@@ -132,7 +104,6 @@ impl MarkdownScanner {
|
||||
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) {
|
||||
let mut at = self.indented(start, end);
|
||||
let mut cell = at;
|
||||
@@ -151,7 +122,6 @@ impl MarkdownScanner {
|
||||
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) {
|
||||
if end <= start {
|
||||
return;
|
||||
@@ -166,7 +136,6 @@ impl MarkdownScanner {
|
||||
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 {
|
||||
let mut at = start;
|
||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||
@@ -175,8 +144,6 @@ impl MarkdownScanner {
|
||||
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)> {
|
||||
let at = self.indented(start, end);
|
||||
if at == end {
|
||||
@@ -193,20 +160,14 @@ impl MarkdownScanner {
|
||||
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>> {
|
||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||
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);
|
||||
self.emit(indented, end, Kind::Metadata);
|
||||
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 {
|
||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||
return false;
|
||||
@@ -217,12 +178,8 @@ impl MarkdownScanner {
|
||||
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) {
|
||||
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] == '>' {
|
||||
at += 1;
|
||||
self.emit(at - 1, at, Kind::Mark);
|
||||
@@ -238,8 +195,6 @@ impl MarkdownScanner {
|
||||
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 {
|
||||
let mut at = start;
|
||||
while at < end && self.code[at] == '#' {
|
||||
@@ -256,15 +211,13 @@ impl MarkdownScanner {
|
||||
true
|
||||
}
|
||||
|
||||
/// A line made of one repeated rule character and nothing else.
|
||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||
let marker = self.code[start];
|
||||
if !RULE_MARKERS.contains(marker) {
|
||||
return false;
|
||||
}
|
||||
let mut seen = 0usize;
|
||||
for at in start..end {
|
||||
let c = self.code[at];
|
||||
for &c in &self.code[start..end] {
|
||||
if c == marker {
|
||||
seen += 1;
|
||||
} else if !c.is_whitespace() {
|
||||
@@ -278,8 +231,6 @@ impl MarkdownScanner {
|
||||
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 {
|
||||
let marker = self.code[start];
|
||||
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'
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
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
|
||||
} else if c == '`' {
|
||||
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 {
|
||||
let mut open = start;
|
||||
while open < end && self.code[open] == '`' {
|
||||
@@ -355,11 +300,9 @@ impl MarkdownScanner {
|
||||
}
|
||||
at = close;
|
||||
}
|
||||
// Nothing closes it on this line, so those were ordinary backticks.
|
||||
open
|
||||
}
|
||||
|
||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||
let mut depth = 0i32;
|
||||
let mut close = bracket;
|
||||
@@ -398,8 +341,6 @@ impl MarkdownScanner {
|
||||
paren + 1
|
||||
}
|
||||
|
||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
||||
/// destination they are.
|
||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut at = start + 1;
|
||||
let mut addressed = false;
|
||||
@@ -423,8 +364,6 @@ impl MarkdownScanner {
|
||||
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> {
|
||||
if start > 0 && is_word(self.code[start - 1]) {
|
||||
return None;
|
||||
@@ -466,8 +405,6 @@ impl MarkdownScanner {
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
||||
/// all.
|
||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[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 markdown;
|
||||
|
||||
@@ -26,7 +5,6 @@ pub use languages::{
|
||||
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)]
|
||||
pub enum Kind {
|
||||
Keyword,
|
||||
@@ -38,7 +16,6 @@ pub enum Kind {
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start: usize,
|
||||
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
||||
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 MARKS: &str = "()={}<>-+[]|&";
|
||||
|
||||
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
|
||||
|
||||
fn run(mut self) -> Vec<Span> {
|
||||
while self.at < self.code.len() {
|
||||
// Every branch that answers true has advanced `self.at`, so
|
||||
// this terminates.
|
||||
let consumed = self.block_comment()
|
||||
|| self.line_comment()
|
||||
|| self.raw_string()
|
||||
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
|
||||
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 {
|
||||
self.at == 0
|
||||
|| self.code[self.at - 1].is_whitespace()
|
||||
|| ";|&(".contains(self.code[self.at - 1])
|
||||
}
|
||||
|
||||
/// Whether only whitespace stands between the start of this line and here.
|
||||
fn at_line_start(&self) -> bool {
|
||||
let mut back = self.at as isize - 1;
|
||||
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) {
|
||||
let mut depth = 0i32;
|
||||
while self.at < self.code.len() {
|
||||
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
|
||||
self.at += comment.open.chars().count();
|
||||
let mut depth = 1i32;
|
||||
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) {
|
||||
depth -= 1;
|
||||
self.at += comment.close.chars().count();
|
||||
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||
fn raw_string(&mut self) -> bool {
|
||||
if !self.rules.raw_strings {
|
||||
return false;
|
||||
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
|
||||
}
|
||||
|
||||
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;
|
||||
for candidate in &self.rules.quotes {
|
||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
|
||||
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 {
|
||||
if !self.code[self.at].is_ascii_digit() {
|
||||
return false;
|
||||
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
|
||||
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 {
|
||||
let token: Vec<char> = token.chars().collect();
|
||||
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[..]
|
||||
}
|
||||
|
||||
/// The first index at or after `from` where `code` contains `needle`, or
|
||||
/// `None`.
|
||||
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||
if needle.is_empty() || from > code.len() {
|
||||
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]
|
||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||
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 api;
|
||||
pub mod config;
|
||||
pub mod durations;
|
||||
pub mod event_stream;
|
||||
pub mod highlight;
|
||||
pub mod log_ring;
|
||||
pub mod markdown_blocks;
|
||||
pub mod notifications;
|
||||
pub mod sse;
|
||||
pub mod text_cap;
|
||||
pub mod tool_summary;
|
||||
pub mod transcript_cache;
|
||||
pub mod transcript_fold;
|
||||
pub mod transcript_source;
|
||||
|
||||
pub use event_model::*;
|
||||
@@ -1,27 +1,17 @@
|
||||
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||
//! places, never both" describes. Ported from the parsing half of
|
||||
//! `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
|
||||
//! `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 serde::Deserialize;
|
||||
|
||||
use crate::api::{ApiError, Transport};
|
||||
use crate::sse::SseReader;
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionNotification {
|
||||
@@ -32,9 +22,6 @@ pub struct SessionNotification {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NotificationKind {
|
||||
@@ -93,7 +80,7 @@ pub fn follow_notifications(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::{Body, RawResponse};
|
||||
use crate::client::api::{Body, RawResponse};
|
||||
use std::io::Cursor;
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
@@ -17,10 +5,6 @@ pub struct Frame {
|
||||
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)]
|
||||
pub struct SseReader {
|
||||
data: String,
|
||||
@@ -32,8 +16,6 @@ impl SseReader {
|
||||
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> {
|
||||
if line.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:") {
|
||||
self.name = Some(rest.trim().to_string());
|
||||
}
|
||||
// `id:`, comments -- nothing to do.
|
||||
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,56 +1,21 @@
|
||||
//! 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::{HashSet, VecDeque};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// What the newest cached line says, which is what the probe checks against
|
||||
/// the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CachedTail {
|
||||
pub seq: u64,
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// This phone's cache root for one server, holding one directory per session.
|
||||
pub struct TranscriptCache {
|
||||
root: PathBuf,
|
||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||
warn: Arc<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
@@ -64,12 +29,10 @@ impl TranscriptCache {
|
||||
) -> Self {
|
||||
Self {
|
||||
root: root.into(),
|
||||
warn: std::sync::Arc::new(warn),
|
||||
warn: Arc::new(warn),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache for one session, whether or not anything has been stored
|
||||
/// for it yet.
|
||||
pub fn session(&self, id: &str) -> SessionCache {
|
||||
SessionCache::new(self.root.join(id), self.warn.clone())
|
||||
}
|
||||
@@ -78,7 +41,7 @@ impl TranscriptCache {
|
||||
/// successful list fetch. The path out for a session deleted on
|
||||
/// another device: nothing here would otherwise hear about it, and
|
||||
/// unlike a draft's few bytes what it leaves behind is megabytes.
|
||||
pub fn retain_only(&self, ids: &std::collections::HashSet<String>) {
|
||||
pub fn retain_only(&self, ids: &HashSet<String>) {
|
||||
guard_io((), self.warn.as_ref(), || {
|
||||
for dir in session_dirs(&self.root)? {
|
||||
if let Some(name) = dir.file_name().and_then(|n| n.to_str())
|
||||
@@ -99,12 +62,12 @@ impl TranscriptCache {
|
||||
/// for.
|
||||
pub fn evict_to_budget(&self, keep: &str, budget: u64) {
|
||||
guard_io((), self.warn.as_ref(), || {
|
||||
let mut dirs: Vec<(PathBuf, std::time::SystemTime)> = session_dirs(&self.root)?
|
||||
let mut dirs: Vec<(PathBuf, SystemTime)> = session_dirs(&self.root)?
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let modified = fs::metadata(&d)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
.unwrap_or(UNIX_EPOCH);
|
||||
(d, modified)
|
||||
})
|
||||
.collect();
|
||||
@@ -164,46 +127,18 @@ fn dir_size(path: &Path) -> u64 {
|
||||
.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
|
||||
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
||||
/// 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.
|
||||
///
|
||||
/// **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 {
|
||||
dir: PathBuf,
|
||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||
warn: Arc<dyn Fn(&str) + Send + Sync>,
|
||||
state: Mutex<WriterState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WriterState {
|
||||
/// Set by the first write that fails: a second would fail the same way,
|
||||
/// once per delta.
|
||||
disabled: bool,
|
||||
writer: Option<fs::File>,
|
||||
open_file: Option<PathBuf>,
|
||||
@@ -211,7 +146,7 @@ struct WriterState {
|
||||
}
|
||||
|
||||
impl SessionCache {
|
||||
fn new(dir: PathBuf, warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
||||
fn new(dir: PathBuf, warn: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
warn,
|
||||
@@ -238,7 +173,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
|
||||
pub fn newest(&self, limit: usize) -> Vec<String> {
|
||||
self.guard(Vec::new(), |this, state| {
|
||||
let mut taken: VecDeque<String> = VecDeque::new();
|
||||
@@ -262,11 +196,6 @@ impl SessionCache {
|
||||
/// 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
|
||||
/// 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>> {
|
||||
self.guard(None, |this, state| {
|
||||
let suffix = this.suffix(state)?;
|
||||
@@ -291,18 +220,12 @@ impl SessionCache {
|
||||
continue;
|
||||
}
|
||||
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");
|
||||
if seq >= before {
|
||||
return true;
|
||||
}
|
||||
if rows {
|
||||
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) {
|
||||
wanting = false;
|
||||
} else {
|
||||
@@ -338,9 +261,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
|
||||
/// 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
|
||||
@@ -361,6 +281,10 @@ impl SessionCache {
|
||||
{
|
||||
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)?;
|
||||
let kind = if rows { "rows" } else { "raw" };
|
||||
let mut content = lines.join("\n");
|
||||
@@ -373,13 +297,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) {
|
||||
self.guard((), |this, state| {
|
||||
if state.disabled {
|
||||
@@ -388,9 +305,10 @@ impl SessionCache {
|
||||
let Some(writer) = this.writer_for(state, seq)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// Written as it arrived. A newline inside it would split one
|
||||
// event into two unreadable halves, but neither source can
|
||||
// produce one.
|
||||
debug_assert!(
|
||||
!line.contains('\n'),
|
||||
"a cached transcript line must be one line: {line}"
|
||||
);
|
||||
use std::io::Write;
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
@@ -399,7 +317,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
/// Flushes what [`Self::append`] has buffered.
|
||||
pub fn flush(&self) {
|
||||
self.guard((), |_this, state| {
|
||||
if let Some(writer) = state.writer.as_mut() {
|
||||
@@ -410,16 +327,14 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
/// What [`Self::purge`] would discard, for the reload row in session settings.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
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) {
|
||||
self.guard((), |this, _state| {
|
||||
if this.dir.is_dir() {
|
||||
let now = std::time::SystemTime::now();
|
||||
let now = SystemTime::now();
|
||||
filetime_set_modified(&this.dir, now)?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -436,8 +351,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
// -- chunks ------------------------------------------------------------------------------
|
||||
|
||||
/// Every chunk on disk, oldest first. A name this does not recognise is
|
||||
/// not ours and is ignored. Recomputed per operation rather than kept:
|
||||
/// another operation may have changed the directory.
|
||||
@@ -463,9 +376,6 @@ impl SessionCache {
|
||||
} else {
|
||||
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
|
||||
&& end > first
|
||||
{
|
||||
@@ -482,13 +392,6 @@ impl SessionCache {
|
||||
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> {
|
||||
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
||||
return Some(state.open_end);
|
||||
@@ -504,12 +407,6 @@ impl SessionCache {
|
||||
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>> {
|
||||
let all = self.chunks(state)?;
|
||||
let Some(newest) = all.last() else {
|
||||
@@ -528,8 +425,6 @@ impl SessionCache {
|
||||
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
|
||||
/// 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
|
||||
@@ -553,10 +448,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
// -- writing -----------------------------------------------------------------------------
|
||||
|
||||
/// The writer for the chunk `seq` belongs in, opening or rotating one
|
||||
/// as it has to.
|
||||
fn writer_for<'s>(
|
||||
&self,
|
||||
state: &'s mut WriterState,
|
||||
@@ -569,14 +460,10 @@ impl SessionCache {
|
||||
if seq < state.open_end {
|
||||
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;
|
||||
self.close_open_chunk(state, end);
|
||||
}
|
||||
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);
|
||||
if let Some(existing) = existing {
|
||||
if seq < existing.end {
|
||||
@@ -620,8 +507,6 @@ impl SessionCache {
|
||||
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) {
|
||||
let file = state.open_file.clone();
|
||||
close_writer(state);
|
||||
@@ -635,31 +520,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>(
|
||||
&self,
|
||||
if_broken: T,
|
||||
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
||||
) -> T {
|
||||
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 {
|
||||
return if_broken;
|
||||
}
|
||||
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
||||
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()) {
|
||||
(self.warn)(&format!(
|
||||
"transcript cache damaged at {}; discarding {}",
|
||||
@@ -683,11 +554,6 @@ impl SessionCache {
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -715,8 +581,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
|
||||
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)> {
|
||||
let rest = name.strip_suffix(".jsonl")?;
|
||||
let (rest, kind) = rest.rsplit_once('.')?;
|
||||
@@ -732,22 +596,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.
|
||||
///
|
||||
/// 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> {
|
||||
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 {
|
||||
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> {
|
||||
let pattern = format!("\"{key}\"");
|
||||
let at = line.find(&pattern)?;
|
||||
@@ -765,8 +621,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> {
|
||||
let pattern = format!("\"{key}\"");
|
||||
let at = line.find(&pattern)?;
|
||||
@@ -777,18 +631,11 @@ fn find_string_field(line: &str, key: &str) -> Option<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;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
||||
/// missing file yields nothing.
|
||||
@@ -811,7 +658,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
||||
}
|
||||
let mut buffer = block;
|
||||
buffer.extend_from_slice(&pending);
|
||||
// `buffer` is now `block` followed by `pending`; walk it backwards.
|
||||
let mut line_end = buffer.len();
|
||||
let mut at = buffer.len() as isize - 1;
|
||||
while at >= 0 {
|
||||
@@ -828,20 +674,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
||||
pending = buffer[..line_end].to_vec();
|
||||
unread = start;
|
||||
}
|
||||
// The first line of a file has no newline before it to be found.
|
||||
let first = String::from_utf8_lossy(&pending);
|
||||
if !first.trim().is_empty() {
|
||||
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<()> {
|
||||
let mut truncate_to: Option<u64> = None;
|
||||
each_line_backwards(file, |offset, line| {
|
||||
@@ -857,9 +695,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
|
||||
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>(
|
||||
if_broken: T,
|
||||
warn: &(impl Fn(&str) + ?Sized),
|
||||
@@ -874,11 +709,7 @@ 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: SystemTime) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
// Rewriting a marker file's contents (rather than the directory itself,
|
||||
// which `std` has no portable "touch" for) bumps the directory's own
|
||||
@@ -898,19 +729,17 @@ fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Resul
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn cache(temp: &Path) -> TranscriptCache {
|
||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let said: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let said2 = said.clone();
|
||||
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
|
||||
said2.lock().unwrap().push(msg.to_string());
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `cache`, but also hands back the messages it warned with.
|
||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, Arc<Mutex<Vec<String>>>) {
|
||||
let said: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let said2 = said.clone();
|
||||
(
|
||||
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
|
||||
@@ -984,8 +813,6 @@ mod tests {
|
||||
})
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -997,8 +824,6 @@ mod tests {
|
||||
for seq in 1..=3u64 {
|
||||
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.flush();
|
||||
|
||||
@@ -1040,14 +865,11 @@ mod tests {
|
||||
}
|
||||
session.flush();
|
||||
|
||||
// Adjacent: its end is the open chunk's first.
|
||||
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
||||
assert!(session.store_page(&page, 60, 100, true));
|
||||
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
||||
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();
|
||||
assert!(session.store_page(&page2, 1, 10, true));
|
||||
assert_eq!(session.page(10, 5, false), None);
|
||||
@@ -1083,8 +905,6 @@ mod tests {
|
||||
}
|
||||
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(40, 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 cache = cache(temp.path());
|
||||
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![
|
||||
delta(1),
|
||||
delta(2),
|
||||
@@ -1125,14 +943,8 @@ mod tests {
|
||||
session.append(&tool_line(9), 9);
|
||||
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]));
|
||||
// One row is one whole run, however many deltas it is made of.
|
||||
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]));
|
||||
}
|
||||
|
||||
@@ -1151,10 +963,7 @@ mod tests {
|
||||
session.append(&tool_line(10), 10);
|
||||
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]));
|
||||
// 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()));
|
||||
}
|
||||
|
||||
@@ -1178,13 +987,9 @@ mod tests {
|
||||
session.append(&tool_line(90), 90);
|
||||
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(41), Some(40));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1200,9 +1005,6 @@ mod tests {
|
||||
&(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!(!dir_of(temp.path(), "s").exists());
|
||||
}
|
||||
@@ -1231,7 +1033,6 @@ mod tests {
|
||||
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
||||
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.flush();
|
||||
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)],
|
||||
);
|
||||
|
||||
// Not seen by the tail, which reads the newest line and stops.
|
||||
assert_eq!(
|
||||
session.tail(),
|
||||
Some(CachedTail {
|
||||
@@ -1257,8 +1057,6 @@ mod tests {
|
||||
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!(!dir_of(temp.path(), "s").exists());
|
||||
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
||||
@@ -1286,9 +1084,6 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = cache(temp.path());
|
||||
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 lines: Vec<String> = (1..=500u64)
|
||||
.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.newest(80), lines[420..].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 =
|
||||
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
||||
session.append(&accented, 501);
|
||||
@@ -1321,15 +1114,10 @@ mod tests {
|
||||
let when =
|
||||
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();
|
||||
// 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));
|
||||
}
|
||||
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);
|
||||
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
||||
.unwrap()
|
||||
@@ -1382,8 +1170,6 @@ mod tests {
|
||||
session.purge();
|
||||
assert_eq!(session.bytes(), 0);
|
||||
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.flush();
|
||||
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
||||
@@ -1,32 +1,6 @@
|
||||
//! 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 std::collections::{HashMap, HashSet};
|
||||
|
||||
/// 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)]
|
||||
pub struct QuestionCard {
|
||||
pub seq: u64,
|
||||
@@ -38,24 +12,14 @@ pub struct QuestionCard {
|
||||
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";
|
||||
|
||||
/// 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)]
|
||||
pub enum ItemKey {
|
||||
Seq(u64),
|
||||
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)]
|
||||
pub enum TranscriptItem {
|
||||
UserMsg {
|
||||
@@ -66,8 +30,6 @@ pub enum TranscriptItem {
|
||||
AssistantMsg {
|
||||
seq: u64,
|
||||
text: String,
|
||||
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
||||
/// Kotlin doc for why the split it licenses matters.
|
||||
settled: bool,
|
||||
},
|
||||
ToolRun {
|
||||
@@ -78,6 +40,11 @@ pub enum TranscriptItem {
|
||||
input: String,
|
||||
output: String,
|
||||
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>,
|
||||
images: Vec<String>,
|
||||
},
|
||||
@@ -90,9 +57,6 @@ pub enum TranscriptItem {
|
||||
seq: u64,
|
||||
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 {
|
||||
seq: u64,
|
||||
from: String,
|
||||
@@ -103,8 +67,6 @@ pub enum TranscriptItem {
|
||||
seq: u64,
|
||||
text: String,
|
||||
},
|
||||
/// Placeholder for an event kind this build could not fold -- see the
|
||||
/// module doc's "known gap".
|
||||
Note {
|
||||
seq: u64,
|
||||
text: String,
|
||||
@@ -112,6 +74,13 @@ pub enum TranscriptItem {
|
||||
ClearedNote {
|
||||
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 {
|
||||
seq: u64,
|
||||
pre_tokens: Option<u64>,
|
||||
@@ -131,6 +100,7 @@ impl TranscriptItem {
|
||||
| Self::CommandRow { seq, .. }
|
||||
| Self::Note { seq, .. }
|
||||
| Self::ClearedNote { seq }
|
||||
| Self::LimitNote { seq, .. }
|
||||
| Self::CompactedNote { seq, .. } => *seq,
|
||||
Self::QuestionCard(card) => card.seq,
|
||||
}
|
||||
@@ -188,14 +158,10 @@ fn update_tool(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a status means the session is still doing something, mirroring
|
||||
/// `sessionWorking` in `Events.kt`.
|
||||
pub fn session_working(status: SessionStatus) -> bool {
|
||||
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> {
|
||||
if session_working(status) {
|
||||
return items.to_vec();
|
||||
@@ -210,9 +176,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
|
||||
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(
|
||||
items: &[TranscriptItem],
|
||||
seq: u64,
|
||||
@@ -251,8 +214,6 @@ fn place_peer_note(
|
||||
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> {
|
||||
let Some(TranscriptItem::ToolRun {
|
||||
run_id: first_run_id,
|
||||
@@ -286,10 +247,160 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
out
|
||||
}
|
||||
|
||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||
/// at the parse layer).
|
||||
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||
/// -- 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: HashSet<&str> = older
|
||||
.iter()
|
||||
.filter_map(TranscriptItem::as_tool_run)
|
||||
.collect();
|
||||
let ended_later: 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> {
|
||||
let seq = entry.seq;
|
||||
match &entry.event {
|
||||
@@ -304,9 +415,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
});
|
||||
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 {
|
||||
text, attachments, ..
|
||||
} => {
|
||||
@@ -319,12 +427,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
items
|
||||
}
|
||||
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 {
|
||||
seq: first_seq,
|
||||
text,
|
||||
@@ -361,6 +463,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input: input.to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
@@ -371,15 +474,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
*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())) {
|
||||
update_tool(items, id, |item| {
|
||||
if let TranscriptItem::ToolRun {
|
||||
output: out, done, ..
|
||||
output: out,
|
||||
done,
|
||||
failed,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
*out = output.clone();
|
||||
*done = true;
|
||||
*failed = *is_error;
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -393,6 +504,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input: String::new(),
|
||||
output: output.clone(),
|
||||
done: true,
|
||||
failed: *is_error,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
@@ -449,6 +561,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input,
|
||||
output,
|
||||
done,
|
||||
failed,
|
||||
images,
|
||||
} if asks.iter().any(|a| &a.id == id) => {
|
||||
for ask in asks.iter_mut() {
|
||||
@@ -464,6 +577,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input,
|
||||
output,
|
||||
done,
|
||||
failed,
|
||||
asks,
|
||||
images,
|
||||
}
|
||||
@@ -484,7 +598,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
});
|
||||
items
|
||||
}
|
||||
// Screen-level state, not transcript rows.
|
||||
Event::CommandQueued { .. }
|
||||
| Event::MessageQueued { .. }
|
||||
| Event::MessageDropped { .. }
|
||||
@@ -525,6 +638,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
items.push(TranscriptItem::ClearedNote { seq });
|
||||
items
|
||||
}
|
||||
Event::LimitReached { resets_at } => {
|
||||
let mut items = items.to_vec();
|
||||
items.push(TranscriptItem::LimitNote {
|
||||
seq,
|
||||
resets_at: *resets_at,
|
||||
});
|
||||
items
|
||||
}
|
||||
Event::Compacted {
|
||||
pre_tokens,
|
||||
post_tokens,
|
||||
@@ -541,14 +662,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
|
||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||
/// of this crate.
|
||||
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||
/// and a call whose result never arrived both leave an empty `output`,
|
||||
/// 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)]
|
||||
pub enum TranscriptRow {
|
||||
Single(TranscriptItem),
|
||||
/// Two or more calls with nothing between them.
|
||||
Tools(Vec<TranscriptItem>),
|
||||
}
|
||||
|
||||
@@ -568,9 +730,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> {
|
||||
let mut rows = Vec::new();
|
||||
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||
@@ -606,6 +765,21 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -749,6 +923,7 @@ mod tests {
|
||||
Event::ToolEnd {
|
||||
id: "x".to_string(),
|
||||
output: "done".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
)]);
|
||||
assert_eq!(
|
||||
@@ -761,6 +936,7 @@ mod tests {
|
||||
input: String::new(),
|
||||
output: "done".to_string(),
|
||||
done: true,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}]
|
||||
@@ -824,4 +1000,339 @@ mod tests {
|
||||
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,337 @@
|
||||
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::{
|
||||
process,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
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() {
|
||||
DesktopApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DesktopUiState)]
|
||||
struct Client {
|
||||
ui_state: DesktopUiState,
|
||||
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 DesktopAppState for Client {
|
||||
type Event = AppEvent;
|
||||
|
||||
fn new(
|
||||
mut ui_state: DesktopUiState,
|
||||
rsc: &mut DesktopRsc<Self>,
|
||||
proxy: Proxy<AppEvent>,
|
||||
) -> Self {
|
||||
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
|
||||
eprintln!("desktop-app: {e}");
|
||||
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()
|
||||
);
|
||||
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 DesktopRsc<Self>) {
|
||||
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.as_mut() {
|
||||
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 DesktopRsc<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();
|
||||
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 DesktopRsc<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 DesktopRsc<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();
|
||||
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();
|
||||
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 DesktopRsc<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 DesktopRsc<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 DesktopRsc<Client>| {
|
||||
ctx.state.select_session(rsc, id.clone());
|
||||
},
|
||||
)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn placeholder(rsc: &mut DesktopRsc<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,67 @@
|
||||
//! 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 std::{env, fs, path::PathBuf};
|
||||
|
||||
use super::config;
|
||||
|
||||
struct Args {
|
||||
ca_path: Option<PathBuf>,
|
||||
link: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut ca_path = None;
|
||||
let mut link = None;
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--ca" => ca_path = Some(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), _) => 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 notify;
|
||||
mod settings;
|
||||
@@ -28,15 +8,6 @@ use jni::objects::{JClass, JObject};
|
||||
use jni::sys::jint;
|
||||
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() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
|
||||
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>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
|
||||
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>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||
fn native_on_start_command<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||
fn native_on_destroy<'local>(
|
||||
_env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
@@ -12,20 +12,19 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use client_core::api::UreqTransport;
|
||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
||||
use crate::client::api::UreqTransport;
|
||||
use crate::client::notifications::{SessionNotification, follow_notifications};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JValue};
|
||||
use jni::sys::{JNI_TRUE, jint};
|
||||
|
||||
use crate::settings::{self, ServerSettings};
|
||||
use crate::shell::settings::{self, ServerSettings};
|
||||
|
||||
const ALERT_CHANNEL: &str = "sessions";
|
||||
const ONGOING_CHANNEL: &str = "connection";
|
||||
const ONGOING_ID: i32 = 1;
|
||||
const ALERT_ID: i32 = 2;
|
||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||
|
||||
/// 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.
|
||||
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);
|
||||
|
||||
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>> {
|
||||
crate::jcall::call_static_method(
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"from",
|
||||
@@ -80,22 +68,22 @@ fn create_channel(
|
||||
name: &str,
|
||||
importance: i32,
|
||||
) -> Result<()> {
|
||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
|
||||
let builder = crate::shell::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||
)?;
|
||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
||||
crate::jcall::call_method(
|
||||
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"setName",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||
&[JValue::Object(&name_j)],
|
||||
)?;
|
||||
let channel = crate::jcall::call_method(
|
||||
let channel = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
@@ -103,7 +91,7 @@ fn create_channel(
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
crate::jcall::call_method(
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
manager,
|
||||
"createNotificationChannel",
|
||||
@@ -146,8 +134,8 @@ fn new_intent_for<'l>(
|
||||
context: &JObject,
|
||||
class_name: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
||||
crate::jcall::new_object(
|
||||
let target_class = crate::shell::jcall::find_class(env, class_name)?;
|
||||
crate::shell::jcall::new_object(
|
||||
env,
|
||||
"android/content/Intent",
|
||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||
@@ -165,41 +153,42 @@ fn session_intent<'l>(
|
||||
session_id: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||
crate::jcall::call_method(
|
||||
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&action_view)],
|
||||
)?;
|
||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||
crate::jcall::call_method(
|
||||
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"scheme",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&scheme)],
|
||||
)?;
|
||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
||||
crate::jcall::call_method(
|
||||
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"authority",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&authority)],
|
||||
)?;
|
||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
||||
crate::jcall::call_method(
|
||||
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"appendPath",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&path)],
|
||||
)?;
|
||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
crate::jcall::call_method(
|
||||
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
|
||||
.l()?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setData",
|
||||
@@ -216,7 +205,7 @@ fn pending_activity<'l>(
|
||||
) -> Result<JObject<'l>> {
|
||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||
crate::jcall::call_static_method(
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"android/app/PendingIntent",
|
||||
"getActivity",
|
||||
@@ -238,7 +227,7 @@ fn builder_call<'l>(
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<()> {
|
||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
||||
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
|
||||
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>> {
|
||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||
let builder = crate::shell::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[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(
|
||||
env,
|
||||
&builder,
|
||||
@@ -297,7 +286,8 @@ fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObj
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[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
|
||||
@@ -306,7 +296,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let service_intent =
|
||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||
if settings::load(env, context)?.is_none() {
|
||||
crate::jcall::call_method(
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
context,
|
||||
"stopService",
|
||||
@@ -316,7 +306,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
create_channels(env, context)?;
|
||||
crate::jcall::call_static_method(
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"startForegroundService",
|
||||
@@ -326,16 +316,11 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
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 {
|
||||
match try_start(env, &service) {
|
||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||
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)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -352,7 +337,7 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
let ca = settings::load_pinned_ca(env)?;
|
||||
let notification = ongoing_notification(env, service)?;
|
||||
let fg_type = foreground_type(env)?;
|
||||
crate::jcall::call_static_method(
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/ServiceCompat",
|
||||
"startForeground",
|
||||
@@ -379,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
std::thread::Builder::new()
|
||||
.name("ai-app-notifications".to_string())
|
||||
.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| {
|
||||
follow_loop(env, &context, settings, &ca);
|
||||
Ok(())
|
||||
@@ -391,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
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]) {
|
||||
while !STOPPING.load(Ordering::SeqCst) {
|
||||
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<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
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 {
|
||||
true
|
||||
} 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(
|
||||
env,
|
||||
"android/content/pm/PackageManager",
|
||||
"PERMISSION_GRANTED",
|
||||
)?;
|
||||
let result = crate::jcall::call_static_method(
|
||||
let result = crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"checkSelfPermission",
|
||||
@@ -441,20 +415,21 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
||||
result == granted
|
||||
};
|
||||
let enabled =
|
||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
||||
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
|
||||
.z()?;
|
||||
if !allowed || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||
let pending = pending_activity(env, context, &intent)?;
|
||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||
let builder = crate::shell::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[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(
|
||||
env,
|
||||
&builder,
|
||||
@@ -462,7 +437,7 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[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(
|
||||
env,
|
||||
&builder,
|
||||
@@ -507,11 +482,16 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let built =
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||
.l()?;
|
||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||
crate::jcall::call_method(
|
||||
let built = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroid/app/Notification;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let tag = crate::shell::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&manager,
|
||||
"notify",
|
||||
@@ -525,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
||||
/// the gap this module's `STOPPING` doc explains.
|
||||
pub fn on_destroy() {
|
||||
STOPPING.store(true, Ordering::SeqCst);
|
||||
// `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) {
|
||||
let message = format!("android-shell: {where_}: {error}");
|
||||
let _ = (|| -> Result<()> {
|
||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
||||
crate::jcall::call_static_method(
|
||||
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
|
||||
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"android/util/Log",
|
||||
"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";
|
||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||
|
||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||
crate::jcall::new_object(
|
||||
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
|
||||
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||
crate::shell::jcall::new_object(
|
||||
env,
|
||||
STORE_CLASS,
|
||||
"(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> {
|
||||
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")?;
|
||||
Ok(ServerSettings { host, port, token })
|
||||
}
|
||||
|
||||
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)?;
|
||||
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`.
|
||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
let settings_obj = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"load",
|
||||
@@ -80,12 +76,11 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
|
||||
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<()> {
|
||||
let store = new_store(env)?;
|
||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
||||
let settings_obj = crate::jcall::new_object(
|
||||
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
|
||||
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
|
||||
let settings_obj = crate::shell::jcall::new_object(
|
||||
env,
|
||||
SETTINGS_CLASS,
|
||||
"(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),
|
||||
],
|
||||
)?;
|
||||
crate::jcall::call_method(
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"save",
|
||||
@@ -105,12 +100,9 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
||||
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>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
let settings_obj = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"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
|
||||
/// own to generate into.
|
||||
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,
|
||||
"com/example/aiapp/shell/PinnedCa",
|
||||
"PINNED_CA_PEM",
|
||||
@@ -1,30 +1,10 @@
|
||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
||||
//! `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 crate::client::api::{ApiClient, UreqTransport};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
use crate::notify;
|
||||
use crate::settings;
|
||||
use crate::shell::notify;
|
||||
use crate::shell::settings;
|
||||
|
||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||
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";
|
||||
|
||||
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() {
|
||||
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<()> {
|
||||
let message = crate::jcall::jstr_obj(env, message)?;
|
||||
crate::jcall::call_static_method(
|
||||
let message = crate::shell::jcall::jstr_obj(env, message)?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"com/example/aiapp/shell/MainActivity",
|
||||
"toast",
|
||||
@@ -52,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||
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<()> {
|
||||
let action = get_string_method(env, intent, "getAction")?;
|
||||
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) {
|
||||
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() {
|
||||
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 {
|
||||
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}"))
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||
let text = crate::jcall::call_method(
|
||||
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||
let text = crate::shell::jcall::call_method(
|
||||
env,
|
||||
intent,
|
||||
"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,786 @@
|
||||
pub mod composer;
|
||||
// Keep the 1.9 MB fixture out of ordinary APKs.
|
||||
#[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::{TranscriptItem, TranscriptRow as FoldedRow, group_tool_runs};
|
||||
use iris::prelude::*;
|
||||
use std::{mem, 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: usize,
|
||||
tail: Option<(RowKey, row::TailRow)>,
|
||||
session_working: 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>(&mut 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 row::BuiltRow { key, widget, tail } = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
row,
|
||||
self.session_working,
|
||||
true,
|
||||
self.theme.clone(),
|
||||
);
|
||||
(self.list)(rsc).push_back(LazyItem::new(key, widget));
|
||||
self.tail = tail.map(|t| (key, t));
|
||||
}
|
||||
|
||||
pub fn set_session_working<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, working: bool)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.session_working == working {
|
||||
return;
|
||||
}
|
||||
self.session_working = working;
|
||||
if let Some((_, row::TailRow::Tools(tools))) = self.tail.as_mut() {
|
||||
let calls = tools.calls();
|
||||
tools.apply_calls(rsc, &calls, working);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn tail_card_count(&self) -> usize {
|
||||
match self.tail.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>(&mut self, rsc: &mut Rsc, expanded: bool) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let Some((_, row::TailRow::Tools(tools))) = self.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>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
key: RowKey,
|
||||
row: &FoldedRow,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let Some((tail_key, kept)) = self.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, 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)
|
||||
}
|
||||
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working)
|
||||
}
|
||||
(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>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
old: &[TranscriptItem],
|
||||
new: &[TranscriptItem],
|
||||
) where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
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 row::BuiltRow {
|
||||
key: new_key,
|
||||
widget,
|
||||
tail: kept,
|
||||
} = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
&new_rows[common],
|
||||
self.session_working,
|
||||
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 = kept.map(|t| (new_key, t));
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::Rebuild => {
|
||||
self.rebuilds += 1;
|
||||
(self.list)(rsc).clear();
|
||||
self.tail = None;
|
||||
for row in &new_rows {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_rebuilds(&mut self) -> usize {
|
||||
mem::take(&mut self.rebuilds)
|
||||
}
|
||||
|
||||
/// 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 row::BuiltRow {
|
||||
key,
|
||||
widget,
|
||||
tail: 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,
|
||||
session_working: false,
|
||||
list,
|
||||
composer,
|
||||
rebuilds: 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::*;
|
||||
|
||||
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::text_cap::MESSAGE_LINES;
|
||||
use std::iter;
|
||||
|
||||
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: Ui,
|
||||
events: EventManager<TestRsc>,
|
||||
}
|
||||
impl UiRsc for TestRsc {
|
||||
fn ui(&self) -> &Ui {
|
||||
&self.ui
|
||||
}
|
||||
fn ui_mut(&mut self) -> &mut Ui {
|
||||
&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: Ui::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 (mut screen, tree) = build_tree(&mut rsc, 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 RenderCounters { 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 (mut screen, tree) = build_tree(rsc, 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: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let items = run_of(3, output, true);
|
||||
let (mut screen, tree) = build_tree(&mut rsc, 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);
|
||||
render.take_counters().shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_cards_shape_only_their_summary_lines() {
|
||||
let long: String = 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: Ui::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, group_tool_runs(&items));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
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(MESSAGE_LINES * 4));
|
||||
let bigger = shapes_for_message(¶graphs(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: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(count, "", false);
|
||||
let mut after = before.clone();
|
||||
after[0] = call("t0", "the result", true);
|
||||
|
||||
let (mut 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"
|
||||
);
|
||||
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: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(3, "", false);
|
||||
let mut after = before.clone();
|
||||
after[1] = call("t1", "done", true);
|
||||
|
||||
let (mut screen, tree) = build_tree(&mut rsc, 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: Ui::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(2, "ok", true);
|
||||
let mut after = before.clone();
|
||||
after.push(call("t2", "", false));
|
||||
|
||||
let (mut 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: Ui::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 (mut screen, _tree) = build_tree(&mut rsc, 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,567 @@
|
||||
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::{
|
||||
ItemKey, 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, build_tool_row};
|
||||
use iris::prelude::*;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
rc::Rc,
|
||||
slice,
|
||||
};
|
||||
|
||||
const BLOCK_GAP_DP: f32 = 8.0;
|
||||
|
||||
pub const BASE_SIZE: f32 = 16.0;
|
||||
|
||||
/// Maps string run IDs above the sequence-number range used by transcripts.
|
||||
pub fn row_key(key: &ItemKey) -> RowKey {
|
||||
match key {
|
||||
ItemKey::Seq(seq) => *seq,
|
||||
ItemKey::RunId(id) => {
|
||||
let mut h = 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<LinkTargets>,
|
||||
column: WeakWidget<Span>,
|
||||
sender: Option<String>,
|
||||
/// A capped row must be rebuilt before accepting a delta.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Caps at a block boundary when possible, or within the first oversized block.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Shared with the "Show all" callback to avoid copying a long message.
|
||||
struct RowSource {
|
||||
sender: Option<String>,
|
||||
markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LinkTargets(Rc<RefCell<Vec<Link>>>);
|
||||
|
||||
impl LinkTargets {
|
||||
fn new(links: Vec<Link>) -> Self {
|
||||
Self(Rc::new(RefCell::new(links)))
|
||||
}
|
||||
|
||||
fn replace(&self, links: Vec<Link>) {
|
||||
*self.0.borrow_mut() = links;
|
||||
}
|
||||
|
||||
fn url_at(&self, byte: usize) -> Option<String> {
|
||||
self.0
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|link| link.range.contains(&byte))
|
||||
.map(|link| link.url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct BuiltBlock {
|
||||
field: WeakWidget<Text>,
|
||||
widget: StrongWidget,
|
||||
links: LinkTargets,
|
||||
}
|
||||
|
||||
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) -> BuiltBlock
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let frame = frame_of(block.kind, theme);
|
||||
let rendered = render_block(block, BASE_SIZE, theme);
|
||||
let links = LinkTargets::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);
|
||||
// Panning or selecting across a link must not open it.
|
||||
if outcome == SelectionInput::Tapped {
|
||||
let byte = field.selection(rsc).byte_at(pos, size);
|
||||
let url = tap_links.url_at(byte);
|
||||
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(),
|
||||
};
|
||||
BuiltBlock {
|
||||
field,
|
||||
widget: 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 built = build_block(rsc, block, &theme);
|
||||
fields.push(built.field);
|
||||
links.push(built.links);
|
||||
column.push(built.widget);
|
||||
}
|
||||
if let Some(lines) = hidden {
|
||||
column.push(show_all(
|
||||
rsc,
|
||||
list,
|
||||
key,
|
||||
source.clone(),
|
||||
ptr,
|
||||
lines,
|
||||
theme.clone(),
|
||||
));
|
||||
}
|
||||
let column = column.add(rsc);
|
||||
|
||||
// The parent composition performs the header's single strong registration.
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuilds a capped row uncapped; its incremental state is intentionally discarded.
|
||||
#[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 {
|
||||
/// Updates only the changed tail blocks, or returns `false` when a rebuild is required.
|
||||
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;
|
||||
}
|
||||
if self.capped {
|
||||
return false;
|
||||
}
|
||||
let new_blocks = display_blocks(markdown_src);
|
||||
let common = common_prefix(&self.blocks, &new_blocks);
|
||||
// A delta may append or rewrite only the current final block.
|
||||
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.replace(rendered.links);
|
||||
}
|
||||
_ => {
|
||||
let built = build_block(rsc, block, &self.theme);
|
||||
self.fields.push(built.field);
|
||||
self.links.push(built.links);
|
||||
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||
column.push(built.widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// Incremental state retained for whichever kind of row is currently last.
|
||||
pub enum TailRow {
|
||||
Blocks(RowBlocks),
|
||||
Tools(ToolRow),
|
||||
}
|
||||
|
||||
pub struct BuiltRow {
|
||||
pub key: RowKey,
|
||||
pub widget: StrongWidget,
|
||||
pub tail: Option<TailRow>,
|
||||
}
|
||||
|
||||
/// `cap` limits historical rows; a live tail must remain uncapped.
|
||||
pub fn build_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
row: &FoldedRow,
|
||||
working: bool,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> BuiltRow
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
// A single tool call still draws as a card, without a redundant group wrapper.
|
||||
let calls = match row {
|
||||
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => Some(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) = build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
|
||||
return BuiltRow {
|
||||
key,
|
||||
widget,
|
||||
tail: 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);
|
||||
BuiltRow {
|
||||
key,
|
||||
widget,
|
||||
tail: 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,633 @@
|
||||
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, RefCell},
|
||||
collections::{HashMap, HashSet},
|
||||
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: 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,193 @@
|
||||
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)> {
|
||||
let render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
render_state
|
||||
.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 = render_state.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 mut 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 mut 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, mut 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,270 @@
|
||||
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.rsc
|
||||
.ui
|
||||
.render_state()
|
||||
.get()
|
||||
.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
|
||||
.rsc
|
||||
.ui
|
||||
.render_state()
|
||||
.get()
|
||||
.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 render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let message = render_state
|
||||
.debug(h.rsc.widgets(), "Message")
|
||||
.find(|a| !a.primitives.is_empty())
|
||||
.expect("the composer field is drawn");
|
||||
let caret = render_state.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 = render_state.primitive_corners(mask.primitive, &h.rsc);
|
||||
let visible_content_top = message
|
||||
.primitives
|
||||
.iter()
|
||||
.map(|p| render_state.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,256 @@
|
||||
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.rsc
|
||||
.ui
|
||||
.render_state()
|
||||
.get()
|
||||
.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 render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let mut rows: Vec<(f32, f32)> = render_state
|
||||
.active
|
||||
.get(&screen.list.id())
|
||||
.expect("the list is drawn")
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|id| render_state.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 render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let active = render_state.active.get(&screen.list.id()).expect("drawn");
|
||||
assert!(
|
||||
active.mask != MaskIdx::NONE,
|
||||
"the transcript's list is drawn with nothing clipping it",
|
||||
);
|
||||
let clip = render_state.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 = render_state
|
||||
.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 render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let Some(active) = render_state.active.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<MaskIdx> = active
|
||||
.primitives
|
||||
.iter()
|
||||
.filter(|p| p.binding != IMAGE_BINDING)
|
||||
.map(|p| render_state.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
|
||||
versionCode = 1
|
||||
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 {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
@@ -131,6 +141,35 @@ android {
|
||||
isMinifyEnabled = false
|
||||
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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
the fix is a judgement about how this app should look. Drop this
|
||||
suppression when a real icon lands. -->
|
||||
<application
|
||||
android:label="AI Sessions"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
tools:ignore="MissingApplicationIcon">
|
||||
|
||||
@@ -142,6 +142,20 @@ data class SessionSummary(
|
||||
val keepsOwnTranscript: Boolean,
|
||||
/** How much the session asks before acting; null when it was never set. */
|
||||
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.
|
||||
*/
|
||||
@@ -153,6 +167,24 @@ data class SessionSummary(
|
||||
* itself from a default is one you can turn off while believing you are reading it.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -178,8 +210,27 @@ data class SessionSummary(
|
||||
* server because that is where a provider's kind is known.
|
||||
*/
|
||||
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 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) =
|
||||
@@ -192,14 +243,24 @@ private fun parseSession(session: JSONObject) =
|
||||
title = session.getString("title"),
|
||||
model = session.optString("model").ifEmpty { null },
|
||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||
effort = session.optString("effort").ifEmpty { null },
|
||||
takesEffort = session.optBoolean("takesEffort", false),
|
||||
imported = session.optBoolean("imported", false),
|
||||
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 },
|
||||
contextTokens =
|
||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||
status = session.getString("status"),
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
subagents = session.optInt("subagents", 0),
|
||||
)
|
||||
|
||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
@@ -215,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||
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
|
||||
// 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.
|
||||
*/
|
||||
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() =
|
||||
@@ -382,6 +478,7 @@ private fun SshDetails.toJson() =
|
||||
if (port != null) put("port", port)
|
||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
||||
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
||||
}
|
||||
|
||||
/** What a machine turns out to have, without saving anything. */
|
||||
@@ -450,6 +547,8 @@ fun spawnSession(
|
||||
model: String? = null,
|
||||
cwd: String? = null,
|
||||
permissionMode: String? = null,
|
||||
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
||||
effort: String? = null,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
/** Continue this Claude Code session instead of starting an empty one. */
|
||||
import: String? = null,
|
||||
@@ -467,6 +566,7 @@ fun spawnSession(
|
||||
if (!model.isNullOrBlank()) put("model", model)
|
||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||
if (!effort.isNullOrBlank()) put("effort", effort)
|
||||
if (!import.isNullOrBlank()) put("import", import)
|
||||
if (params.isNotEmpty()) {
|
||||
put("params", JSONObject(params.toMap<String, Any>()))
|
||||
@@ -906,7 +1006,7 @@ fun startImport(
|
||||
*/
|
||||
fun fetchTranscript(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
address: TranscriptAddress,
|
||||
before: Long? = null,
|
||||
limit: Int = 80,
|
||||
// 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 (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())
|
||||
// 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.
|
||||
@@ -973,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
||||
*/
|
||||
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. */
|
||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||
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`. */
|
||||
/**
|
||||
* 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) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
@@ -1074,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
|
||||
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 =
|
||||
requestFromServer(settings, "/models") { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
|
||||
* session, spawning one, and settings.
|
||||
*/
|
||||
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.
|
||||
@@ -81,7 +99,7 @@ fun AppRoot(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
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
|
||||
// after one succeeds, since success is a screen rather than a message.
|
||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||
@@ -96,7 +114,7 @@ fun AppRoot(
|
||||
share = shareRequest
|
||||
// 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.
|
||||
if (screen !is Screen.Session) screen = Screen.Main
|
||||
if (screen !is Screen.Session) screen = Screen.Main()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +141,7 @@ fun AppRoot(
|
||||
existing = null,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
screen = Screen.Main
|
||||
screen = Screen.Main()
|
||||
},
|
||||
onBack = null,
|
||||
)
|
||||
@@ -136,7 +154,7 @@ fun AppRoot(
|
||||
// shows, so it always refetches.
|
||||
val goToMain = {
|
||||
reloadToken++
|
||||
screen = Screen.Main
|
||||
screen = Screen.Main()
|
||||
}
|
||||
if (screen !is Screen.Main) {
|
||||
BackHandler(onBack = goToMain)
|
||||
@@ -185,6 +203,9 @@ fun AppRoot(
|
||||
reloadToken = reloadToken,
|
||||
share = share,
|
||||
onOpen = { screen = Screen.Session(it) },
|
||||
onOpenSubagent = { summary, subagent ->
|
||||
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
||||
},
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onImported = { imported ->
|
||||
reloadToken++
|
||||
@@ -192,6 +213,27 @@ fun AppRoot(
|
||||
},
|
||||
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 ->
|
||||
// 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>,
|
||||
accounting: List<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 {
|
||||
appendLine("ai-app render report")
|
||||
appendLine(device)
|
||||
@@ -141,6 +155,11 @@ fun debugReport(
|
||||
appendLine("transcript:")
|
||||
transcript.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
if (phaseFrames.isNotEmpty()) {
|
||||
appendLine("per phase:")
|
||||
phaseFrames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
}
|
||||
appendLine("frames:")
|
||||
frames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
@@ -152,6 +171,11 @@ fun debugReport(
|
||||
appendLine("work since this was last copied:")
|
||||
val work = DebugStats.lines()
|
||||
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. */
|
||||
|
||||
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.
|
||||
@@ -47,3 +51,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
||||
fun ClearedRow(modifier: Modifier = 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
|
||||
* 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)
|
||||
|
||||
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.
|
||||
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.
|
||||
if (name == RESET_EVENT) onReset()
|
||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||
|
||||
@@ -157,6 +157,18 @@ sealed class 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()
|
||||
|
||||
/**
|
||||
@@ -261,6 +273,10 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
trigger = body.optString("trigger").ifEmpty { null },
|
||||
)
|
||||
"cleared" -> SessionEvent.Cleared
|
||||
"limitReached" ->
|
||||
SessionEvent.LimitReached(
|
||||
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||
)
|
||||
"error" -> SessionEvent.Error(body.getString("message"))
|
||||
else -> SessionEvent.Unknown(type)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,21 @@ object FrameStats {
|
||||
private val gpu = ArrayList<Long>()
|
||||
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
|
||||
fun add(metrics: FrameMetrics) {
|
||||
// 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 {
|
||||
it.clear()
|
||||
}
|
||||
phaseMarks.clear()
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -95,6 +111,38 @@ object FrameStats {
|
||||
) + 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. */
|
||||
@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
|
||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||
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
|
||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||
// 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,
|
||||
// lands here rather than in a second activity instance.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user