Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
88631f5e8b | ||
|
|
cf10b17c5b | ||
|
|
9fa09b0af1 | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
6bdec6e785 | ||
|
|
4821a02bd3 | ||
|
|
1ff662c7c3 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
127b25e60a |
No files matched your search
@@ -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.
|
||||||
@@ -19,6 +19,21 @@ child process, translated into one common event model.** A new session type
|
|||||||
is a new driver — never a session-type branch in shared code (routes,
|
is a new driver — never a session-type branch in shared code (routes,
|
||||||
transcript, app screens).
|
transcript, app screens).
|
||||||
|
|
||||||
|
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||||||
|
and a planned desktop app share almost all of their code.** Screens, widgets,
|
||||||
|
folding, paging, config and the network client live in the shared crates
|
||||||
|
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and
|
||||||
|
`desktop-app` 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 a shared crate the first time it is written, and a
|
||||||
|
platform crate 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.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||||
@@ -261,6 +276,27 @@ Each exists because something was invisible without it.
|
|||||||
framework, from `atrace` text output with no trace processor needed. It is
|
framework, from `atrace` text output with no trace processor needed. It is
|
||||||
how the cost of a layout node per link was attributed to the framework
|
how the cost of a layout node per link was attributed to the framework
|
||||||
rather than guessed at.
|
rather than guessed at.
|
||||||
|
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
||||||
|
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
||||||
|
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
||||||
|
**`iris/android-app/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).
|
||||||
|
- **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. `cargo test -p transcript-fixture` 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
|
||||||
|
`iris/transcript-fixture/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
|
||||||
|
--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. 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.
|
||||||
|
|
||||||
### Driving the UI
|
### Driving the UI
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Decisions awaiting review
|
||||||
|
|
||||||
|
Choices made while working autonomously, for Bryan to keep or change. Each
|
||||||
|
says what was picked and why; the detail is in the design doc it names.
|
||||||
|
Delete an entry once it has been looked at.
|
||||||
|
|
||||||
|
## Subagent views (2026-09-05, `SUBAGENTS.md`)
|
||||||
|
|
||||||
|
Made on my own judgement, limited blast radius:
|
||||||
|
|
||||||
|
1. **A subagent is a transcript, not a session.** It has no process,
|
||||||
|
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
|
||||||
|
and stored under the session's directory, so deleting the session takes
|
||||||
|
it. Alternative rejected: registering it as a session of its own, which
|
||||||
|
would give it a card in the main list and a driver that can do nothing.
|
||||||
|
2. **Read-only view is the session screen minus its controls**, rather than
|
||||||
|
a second, simpler transcript screen. Keeps paging, caching, selection
|
||||||
|
and rendering in one place. Cost: a `readOnly` mode threaded through
|
||||||
|
`SessionScreen`.
|
||||||
|
3. **The list only carries a count.** Each session row says how many
|
||||||
|
subagents it has; their titles and statuses are fetched when the card is
|
||||||
|
expanded. Keeps `GET /sessions` from reading every subagent transcript.
|
||||||
|
Consequence: an expanded card's statuses refresh with the list, not live.
|
||||||
|
4. **Expanded/collapsed is remembered per session on the phone**, not on
|
||||||
|
the server. Collapsed by default, per the transcript convention that new
|
||||||
|
things arrive collapsed.
|
||||||
|
5. **Subagents of imported sessions are not shown.** The import path still
|
||||||
|
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
|
||||||
|
are not read. Only subagents run while this backend was watching exist.
|
||||||
|
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
|
||||||
|
paid turn to exercise.
|
||||||
|
|
||||||
|
Deferred, because they reach further than this feature:
|
||||||
|
|
||||||
|
- **Live status on the list.** Whether the session list should follow a
|
||||||
|
stream at all (it refreshes on demand today) decides whether subagent
|
||||||
|
status can ever be live there. Not changed.
|
||||||
|
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
|
||||||
|
in its transcript and are not given transcripts of their own. Supporting
|
||||||
|
that is the same mechanism one level down, but the UI would need nested
|
||||||
|
expanders.
|
||||||
|
|
||||||
|
- **The subagent status row says "context unknown".** Nothing measures a
|
||||||
|
subagent's context; the row could leave it out rather than admit it.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Subagents
|
||||||
|
|
||||||
|
A session's subagents -- the helpers a Claude Code session starts through its
|
||||||
|
Task tool -- each get a transcript of their own, listed under the session's
|
||||||
|
card and readable in the same transcript view the session has. Designed
|
||||||
|
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
||||||
|
|
||||||
|
## What a subagent is here
|
||||||
|
|
||||||
|
**A subagent is a second transcript owned by a session, in the same event
|
||||||
|
model, with no process and no controls.** It is not a session: it cannot be
|
||||||
|
messaged, stopped or started, and it has no setup, model or usage of its
|
||||||
|
own. Everything it shares with a session -- the transcript file format, the
|
||||||
|
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||||
|
by addressing, not by copying.
|
||||||
|
|
||||||
|
The CLI reports a subagent's messages on the parent's own stream-json
|
||||||
|
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
|
||||||
|
that started it. Before this the translator dropped those lines
|
||||||
|
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
||||||
|
them to that subagent's own translator and transcript. The parent's
|
||||||
|
transcript still shows only the Task call itself.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Under the session directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
<session>/subagents/<tool_use_id>/meta.json {title, created}
|
||||||
|
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||||
|
```
|
||||||
|
|
||||||
|
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
|
||||||
|
backend restart, and already the key everything on the parent side uses.
|
||||||
|
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
||||||
|
id becomes a path.
|
||||||
|
|
||||||
|
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
||||||
|
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
||||||
|
|
||||||
|
Its path out: deleting the session deletes its directory, subagents included.
|
||||||
|
There is no separate delete.
|
||||||
|
|
||||||
|
## Lifecycle, as events in the subagent's transcript
|
||||||
|
|
||||||
|
1. Created on the first child line for an unseen parent id (or, when the
|
||||||
|
parent Task call was seen, at that call). First lines written:
|
||||||
|
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
||||||
|
the prompt is known -- it genuinely is the subagent's first user turn.
|
||||||
|
2. Every child line is translated by that subagent's own `Translator`
|
||||||
|
(one per subagent: tool ids are unique but streaming deltas are by
|
||||||
|
content-block index, and parallel subagents interleave).
|
||||||
|
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
|
||||||
|
runs in the background by default: the `tool_result` -- "Async agent
|
||||||
|
launched..." -- arrives the moment it *starts*, while the subagent goes
|
||||||
|
on working for however long its own turn takes, sometimes minutes. What
|
||||||
|
ends it is its own turn ending: the raw API's `message_delta` on its
|
||||||
|
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
|
||||||
|
is the model about to call one, not an end), or a `result` line for its
|
||||||
|
own turn if a future CLI version ever sends one. Either maps to
|
||||||
|
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
|
||||||
|
equivalent event `dispatch` produces for an ordinary session is dropped
|
||||||
|
rather than written. A shipped version of this finished on the
|
||||||
|
`tool_result` instead, which read a running background agent as
|
||||||
|
"finished" with its transcript truncated at the moment it launched.
|
||||||
|
4. **A child line for a subagent that already finished reopens it**
|
||||||
|
(`Status Running`) rather than being dropped: a background Task can be
|
||||||
|
sent another message long after its first turn ended, and that is
|
||||||
|
exactly what a further line for it means. Same transcript, same child
|
||||||
|
`Translator`, just picking back up.
|
||||||
|
5. When the parent session's process exits (`Status Exited` on the
|
||||||
|
session), every subagent still `Running` gets `Status Exited` too: its
|
||||||
|
process was the parent's.
|
||||||
|
|
||||||
|
A subagent that was mid-flight when the backend restarted keeps working:
|
||||||
|
the registry reopens the existing transcript on the next child line, and
|
||||||
|
the file continues its sequence -- the same reopening #4 describes, whether
|
||||||
|
what closed it was a restart or its own `end_turn`. If its turn ended while
|
||||||
|
the backend was down nothing recorded that until the next line arrives, so
|
||||||
|
its last status stays `Running`, which the list reports as **unknown**
|
||||||
|
rather than as running (see the wire shape) until then.
|
||||||
|
|
||||||
|
Title: the Task call's `description` input, then ` (<subagent_type>)` when
|
||||||
|
one is given; falling back to the tool's name when the child arrives before
|
||||||
|
(or without) the parent call being seen.
|
||||||
|
|
||||||
|
## Server layout
|
||||||
|
|
||||||
|
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
||||||
|
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
||||||
|
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
||||||
|
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
|
||||||
|
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
||||||
|
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
||||||
|
one child `Translator` per subagent, remembers pending Task calls'
|
||||||
|
description/prompt/subagent_type.
|
||||||
|
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
|
||||||
|
subagents at once, each named "helper k". Each writes the prompt as its
|
||||||
|
user message, streams a few words of text, runs one `Bash` tool call, then
|
||||||
|
finishes about three seconds after starting, and the parent's Task calls
|
||||||
|
end when their subagent does. Three seconds so the running state can be
|
||||||
|
seen on the phone.
|
||||||
|
- `routes.rs` -- three routes, in the doc table.
|
||||||
|
|
||||||
|
## Wire shape
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
|
||||||
|
GET /sessions same field on each row
|
||||||
|
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
||||||
|
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
|
||||||
|
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` is the transcript's last `Status` event, serialised like a session's
|
||||||
|
(`running`, `exited`), except that a subagent whose session is not itself
|
||||||
|
running cannot be running: the list answers `unknown` for that one. The
|
||||||
|
phone words these as *running*, *finished* and *unknown* on the subcard.
|
||||||
|
|
||||||
|
The count on `SessionInfo` is a directory listing, so the list stays cheap.
|
||||||
|
The per-subagent status is only read when the list route is asked for.
|
||||||
|
|
||||||
|
## Phone
|
||||||
|
|
||||||
|
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
|
||||||
|
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
|
||||||
|
`Pointing.Up` -- collapsed by default. Expanding fetches
|
||||||
|
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
|
||||||
|
indented inside the session card, the way dev-updater draws a project's
|
||||||
|
components: title, then the status word and a relative time. The
|
||||||
|
expansion state is per session id and survives a refresh of the list.
|
||||||
|
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
|
||||||
|
**read-only** form: the same transcript, paging, cache, selection,
|
||||||
|
images and status row, with the composer, the process button, the model
|
||||||
|
picker, the files button, the settings cog and the usage bar left out.
|
||||||
|
The header shows the subagent's title with the session's title beneath
|
||||||
|
it. Back returns to the list.
|
||||||
|
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||||
|
cache take a transcript address rather than a session id --
|
||||||
|
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||||
|
subagent's copy under its session's and the same code serves both.
|
||||||
@@ -50,6 +50,12 @@ version = "0.23.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -77,6 +83,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -206,6 +213,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -490,6 +506,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -783,12 +818,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ data class SessionSummary(
|
|||||||
val keepsOwnTranscript: Boolean,
|
val keepsOwnTranscript: Boolean,
|
||||||
/** How much the session asks before acting; null when it was never set. */
|
/** How much the session asks before acting; null when it was never set. */
|
||||||
val permissionMode: String?,
|
val permissionMode: String?,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
||||||
|
* reported rather than assumed for the same reason [permissionMode] is.
|
||||||
|
*/
|
||||||
|
val effort: String?,
|
||||||
|
/**
|
||||||
|
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
|
||||||
|
*
|
||||||
|
* Asked of the server rather than worked out from the provider's name, because this is a
|
||||||
|
* property of the driver's *kind* and the phone only has the name.
|
||||||
|
*/
|
||||||
|
val takesEffort: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||||
*/
|
*/
|
||||||
@@ -153,6 +167,24 @@ data class SessionSummary(
|
|||||||
* itself from a default is one you can turn off while believing you are reading it.
|
* itself from a default is one you can turn off while believing you are reading it.
|
||||||
*/
|
*/
|
||||||
val notify: Boolean,
|
val notify: Boolean,
|
||||||
|
/**
|
||||||
|
* Whether this session sends itself a message once its account's usage limit lifts, and what
|
||||||
|
* that message says.
|
||||||
|
*
|
||||||
|
* The message is what the server would actually send, with its own default already filled in,
|
||||||
|
* so the field shows the words rather than an empty box standing for them.
|
||||||
|
*/
|
||||||
|
val autoResume: Boolean,
|
||||||
|
val autoResumeMessage: String,
|
||||||
|
/**
|
||||||
|
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
|
||||||
|
* when nothing is waiting.
|
||||||
|
*
|
||||||
|
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
|
||||||
|
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
|
||||||
|
* app cannot keep is worse than no time at all.
|
||||||
|
*/
|
||||||
|
val resumeAt: Double?,
|
||||||
/**
|
/**
|
||||||
* The directory the session works in, or null where it was never given one.
|
* The directory the session works in, or null where it was never given one.
|
||||||
*
|
*
|
||||||
@@ -178,8 +210,27 @@ data class SessionSummary(
|
|||||||
* server because that is where a provider's kind is known.
|
* server because that is where a provider's kind is known.
|
||||||
*/
|
*/
|
||||||
val maxImageEdge: Int?,
|
val maxImageEdge: Int?,
|
||||||
|
/**
|
||||||
|
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
|
||||||
|
*
|
||||||
|
* The rate-limit bar answers a question about an *account*, and what decides which account --
|
||||||
|
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
|
||||||
|
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
|
||||||
|
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
|
||||||
|
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
|
||||||
|
* this app has only its name.
|
||||||
|
*/
|
||||||
|
val usageProvider: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val lastActivity: Double,
|
val lastActivity: Double,
|
||||||
|
/**
|
||||||
|
* How many subagents this session has, however their own status now reads.
|
||||||
|
*
|
||||||
|
* A directory listing on the server rather than a status read per subagent, so the list stays
|
||||||
|
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
|
||||||
|
* that predates subagents, so this app still opens against one.
|
||||||
|
*/
|
||||||
|
val subagents: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseSession(session: JSONObject) =
|
private fun parseSession(session: JSONObject) =
|
||||||
@@ -192,14 +243,24 @@ private fun parseSession(session: JSONObject) =
|
|||||||
title = session.getString("title"),
|
title = session.getString("title"),
|
||||||
model = session.optString("model").ifEmpty { null },
|
model = session.optString("model").ifEmpty { null },
|
||||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||||
|
effort = session.optString("effort").ifEmpty { null },
|
||||||
|
takesEffort = session.optBoolean("takesEffort", false),
|
||||||
imported = session.optBoolean("imported", false),
|
imported = session.optBoolean("imported", false),
|
||||||
notify = session.optBoolean("notify", true),
|
notify = session.optBoolean("notify", true),
|
||||||
|
autoResume = session.optBoolean("autoResume", false),
|
||||||
|
// The server sends its own default rather than nothing, so an empty answer means an older
|
||||||
|
// server -- and this app's word for it is the same word.
|
||||||
|
autoResumeMessage =
|
||||||
|
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
|
||||||
|
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
|
||||||
cwd = session.optString("cwd").ifEmpty { null },
|
cwd = session.optString("cwd").ifEmpty { null },
|
||||||
contextTokens =
|
contextTokens =
|
||||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||||
|
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||||
status = session.getString("status"),
|
status = session.getString("status"),
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
lastActivity = session.getDouble("lastActivity"),
|
||||||
|
subagents = session.optInt("subagents", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||||
@@ -215,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
|||||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||||
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
||||||
|
*
|
||||||
|
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
||||||
|
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
|
||||||
|
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
||||||
|
* running, and the list says so rather than reporting a state that cannot hold.
|
||||||
|
*/
|
||||||
|
data class SubagentSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val status: String,
|
||||||
|
val created: Double,
|
||||||
|
val lastActivity: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
|
||||||
|
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
||||||
|
it.jsonObjects { row ->
|
||||||
|
SubagentSummary(
|
||||||
|
id = row.getString("id"),
|
||||||
|
title = row.getString("title"),
|
||||||
|
status = row.getString("status"),
|
||||||
|
created = row.getDouble("created"),
|
||||||
|
lastActivity = row.getDouble("lastActivity"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||||
// config.ron appears here with no app rebuild.
|
// config.ron appears here with no app rebuild.
|
||||||
//
|
//
|
||||||
@@ -375,6 +465,12 @@ data class SshDetails(
|
|||||||
* Where files attached from here land on that machine; null for the session's own directory.
|
* Where files attached from here land on that machine; null for the session's own directory.
|
||||||
*/
|
*/
|
||||||
val attachmentsDir: String? = null,
|
val attachmentsDir: String? = null,
|
||||||
|
/**
|
||||||
|
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
|
||||||
|
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
|
||||||
|
* from the machine it runs on, so this is where its models are looked for and listed.
|
||||||
|
*/
|
||||||
|
val modelsDir: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SshDetails.toJson() =
|
private fun SshDetails.toJson() =
|
||||||
@@ -382,6 +478,7 @@ private fun SshDetails.toJson() =
|
|||||||
if (port != null) put("port", port)
|
if (port != null) put("port", port)
|
||||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
||||||
|
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a machine turns out to have, without saving anything. */
|
/** What a machine turns out to have, without saving anything. */
|
||||||
@@ -450,6 +547,8 @@ fun spawnSession(
|
|||||||
model: String? = null,
|
model: String? = null,
|
||||||
cwd: String? = null,
|
cwd: String? = null,
|
||||||
permissionMode: String? = null,
|
permissionMode: String? = null,
|
||||||
|
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
||||||
|
effort: String? = null,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
/** Continue this Claude Code session instead of starting an empty one. */
|
/** Continue this Claude Code session instead of starting an empty one. */
|
||||||
import: String? = null,
|
import: String? = null,
|
||||||
@@ -467,6 +566,7 @@ fun spawnSession(
|
|||||||
if (!model.isNullOrBlank()) put("model", model)
|
if (!model.isNullOrBlank()) put("model", model)
|
||||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||||
|
if (!effort.isNullOrBlank()) put("effort", effort)
|
||||||
if (!import.isNullOrBlank()) put("import", import)
|
if (!import.isNullOrBlank()) put("import", import)
|
||||||
if (params.isNotEmpty()) {
|
if (params.isNotEmpty()) {
|
||||||
put("params", JSONObject(params.toMap<String, Any>()))
|
put("params", JSONObject(params.toMap<String, Any>()))
|
||||||
@@ -906,7 +1006,7 @@ fun startImport(
|
|||||||
*/
|
*/
|
||||||
fun fetchTranscript(
|
fun fetchTranscript(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
sessionId: String,
|
address: TranscriptAddress,
|
||||||
before: Long? = null,
|
before: Long? = null,
|
||||||
limit: Int = 80,
|
limit: Int = 80,
|
||||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
||||||
@@ -925,7 +1025,7 @@ fun fetchTranscript(
|
|||||||
if (coalesce) append("&coalesce=true")
|
if (coalesce) append("&coalesce=true")
|
||||||
if (after != null) append("&after=").append(after)
|
if (after != null) append("&after=").append(after)
|
||||||
}
|
}
|
||||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
|
||||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||||
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
||||||
// other, and they have to be the same line.
|
// other, and they have to be the same line.
|
||||||
@@ -973,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
|||||||
*/
|
*/
|
||||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
|
||||||
|
*
|
||||||
|
* Held by the server rather than by this phone, because a second device would otherwise spawn
|
||||||
|
* sessions at a level the first one's owner never picked.
|
||||||
|
*/
|
||||||
|
fun fetchDefaultEffort(settings: ServerSettings): String? =
|
||||||
|
requestFromServer(settings, "/defaults") {
|
||||||
|
it.jsonObject().optString("effort").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets what new sessions start at. Nothing already running changes. */
|
||||||
|
fun setDefaultEffort(settings: ServerSettings, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/defaults",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||||
|
*
|
||||||
|
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
||||||
|
* not behave like them: the CLI has a control request for those two and none for this (checked
|
||||||
|
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
||||||
|
* the process, which is what the working directory beside it in this dialog does, and why it is
|
||||||
|
* here rather than on a bar whose other controls take effect mid-turn.
|
||||||
|
*/
|
||||||
|
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
||||||
|
|
||||||
|
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
||||||
|
const val DEFAULT_EFFORT = "default"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
||||||
|
* process is launched. The next message, or Start, runs one that has it.
|
||||||
|
*
|
||||||
|
* [level] is null for the CLI's own default.
|
||||||
|
*/
|
||||||
|
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/effort",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Switches how much a running session asks before acting, also in place. */
|
/** Switches how much a running session asks before acting, also in place. */
|
||||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
@@ -984,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
||||||
|
/**
|
||||||
|
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
|
||||||
|
* cleared field shows the word that would actually be sent instead of going blank.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_RESUME_MESSAGE = "continue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns auto-resume on or off and sets what it would say, in one request because they are one
|
||||||
|
* decision -- see the server's `/sessions/{id}/auto-resume`.
|
||||||
|
*/
|
||||||
|
fun setSessionAutoResume(
|
||||||
|
settings: ServerSettings,
|
||||||
|
sessionId: String,
|
||||||
|
autoResume: Boolean,
|
||||||
|
message: String?,
|
||||||
|
) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/auto-resume",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody =
|
||||||
|
JSONObject()
|
||||||
|
.put("autoResume", autoResume)
|
||||||
|
// Empty means the server's default rather than a session poked with nothing to
|
||||||
|
// read, which is the same rule the server applies to the field.
|
||||||
|
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
|
||||||
|
.toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
settings,
|
settings,
|
||||||
@@ -1074,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
|
|||||||
error = if (o.has("error")) o.getString("error") else null,
|
error = if (o.has("error")) o.getString("error") else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models on one machine, which is the list a llama.cpp session there can choose from.
|
||||||
|
*
|
||||||
|
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
|
||||||
|
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
|
||||||
|
* offering the backend's would name files that are not there, turning a choice that cannot work
|
||||||
|
* into a session that fails when it tries to load one.
|
||||||
|
*/
|
||||||
|
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
|
||||||
|
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
|
||||||
|
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
||||||
|
LocalModel(
|
||||||
|
key = m.getString("key"),
|
||||||
|
repo = m.getString("repo"),
|
||||||
|
file = m.getString("file"),
|
||||||
|
bytes = m.getLong("bytes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchModels(settings: ServerSettings): Models =
|
fun fetchModels(settings: ServerSettings): Models =
|
||||||
requestFromServer(settings, "/models") { connection ->
|
requestFromServer(settings, "/models") { connection ->
|
||||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
|
|||||||
* session, spawning one, and settings.
|
* session, spawning one, and settings.
|
||||||
*/
|
*/
|
||||||
private sealed class Screen {
|
private sealed class Screen {
|
||||||
data object Main : Screen()
|
/**
|
||||||
|
* The session list, with a subagent's own transcript over it when [subagent] is set.
|
||||||
|
*
|
||||||
|
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
|
||||||
|
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
|
||||||
|
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
|
||||||
|
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
|
||||||
|
* itself the moment its own subagent's view was closed.
|
||||||
|
*/
|
||||||
|
data class Main(val subagent: SubagentTarget? = null) : Screen()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
||||||
|
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
||||||
|
* is opened from the session list's card rather than from inside the session it belongs to.
|
||||||
|
*/
|
||||||
|
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One session, with the file explorer over it when [files] is set.
|
* One session, with the file explorer over it when [files] is set.
|
||||||
@@ -81,7 +99,7 @@ fun AppRoot(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||||
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
|
||||||
// A notification tap this could not follow, and why. Null both before one is asked for and
|
// A notification tap this could not follow, and why. Null both before one is asked for and
|
||||||
// after one succeeds, since success is a screen rather than a message.
|
// after one succeeds, since success is a screen rather than a message.
|
||||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||||
@@ -96,7 +114,7 @@ fun AppRoot(
|
|||||||
share = shareRequest
|
share = shareRequest
|
||||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
// A session already open takes it. Otherwise the list is where the choice is made,
|
||||||
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
||||||
if (screen !is Screen.Session) screen = Screen.Main
|
if (screen !is Screen.Session) screen = Screen.Main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +141,7 @@ fun AppRoot(
|
|||||||
existing = null,
|
existing = null,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
settings = saved
|
settings = saved
|
||||||
screen = Screen.Main
|
screen = Screen.Main()
|
||||||
},
|
},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
)
|
)
|
||||||
@@ -136,7 +154,7 @@ fun AppRoot(
|
|||||||
// shows, so it always refetches.
|
// shows, so it always refetches.
|
||||||
val goToMain = {
|
val goToMain = {
|
||||||
reloadToken++
|
reloadToken++
|
||||||
screen = Screen.Main
|
screen = Screen.Main()
|
||||||
}
|
}
|
||||||
if (screen !is Screen.Main) {
|
if (screen !is Screen.Main) {
|
||||||
BackHandler(onBack = goToMain)
|
BackHandler(onBack = goToMain)
|
||||||
@@ -185,6 +203,9 @@ fun AppRoot(
|
|||||||
reloadToken = reloadToken,
|
reloadToken = reloadToken,
|
||||||
share = share,
|
share = share,
|
||||||
onOpen = { screen = Screen.Session(it) },
|
onOpen = { screen = Screen.Session(it) },
|
||||||
|
onOpenSubagent = { summary, subagent ->
|
||||||
|
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
||||||
|
},
|
||||||
onSpawn = { screen = Screen.Spawn },
|
onSpawn = { screen = Screen.Spawn },
|
||||||
onImported = { imported ->
|
onImported = { imported ->
|
||||||
reloadToken++
|
reloadToken++
|
||||||
@@ -192,6 +213,27 @@ fun AppRoot(
|
|||||||
},
|
},
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
)
|
)
|
||||||
|
// Its own back handler is registered after MainScreen's, so it is the one the
|
||||||
|
// platform asks first while a subagent is open -- the same rule the files
|
||||||
|
// explorer's handler follows over its session, below.
|
||||||
|
here.subagent?.let { target ->
|
||||||
|
BackHandler { screen = here.copy(subagent = null) }
|
||||||
|
// Its own opaque background: this screen was always the sole content under
|
||||||
|
// the theme's own Surface before, so it never had to paint one -- stacked over
|
||||||
|
// the list here, the space between its own cards let the list underneath show
|
||||||
|
// through without this. The same fix FilesScreen needed over its session.
|
||||||
|
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
|
key(target.summary.id, target.subagent.id) {
|
||||||
|
SessionScreen(
|
||||||
|
settings = current,
|
||||||
|
summary = target.summary,
|
||||||
|
onBack = { screen = here.copy(subagent = null) },
|
||||||
|
onFiles = {},
|
||||||
|
subagent = target.subagent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Screen.Session ->
|
is Screen.Session ->
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
// Keyed on the id, because a different session is a different screen rather than this
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ package com.example.aiapp
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.BatteryManager
|
import android.os.BatteryManager
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
import androidx.compose.animation.core.tween
|
import android.view.View
|
||||||
import androidx.compose.foundation.gestures.animateScrollBy
|
import androidx.compose.foundation.gestures.FlingBehavior
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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 java.io.File
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -19,27 +23,84 @@ import kotlinx.coroutines.launch
|
|||||||
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||||
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||||
* [BenchFixture]'s doc comment gives.
|
* [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 {
|
object BenchRun {
|
||||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
|
/** 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 CYCLES = 6
|
||||||
private const val SWIPE_PX = 900f
|
private const val SWIPE_PX = 900f
|
||||||
private const val SWIPE_MS = 200
|
private const val SWIPE_MS = 200
|
||||||
private const val SWIPE_PAUSE_MS = 500L
|
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. */
|
/** 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_EVENTS_PER_SEC = 20
|
||||||
private const val STREAM_SECONDS = 20
|
private const val STREAM_SECONDS = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
|
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
|
||||||
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
|
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
|
||||||
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
|
* 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(
|
suspend fun run(
|
||||||
context: Context,
|
context: Context,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
|
flingBehavior: FlingBehavior,
|
||||||
|
composerFocus: FocusRequester,
|
||||||
|
setComposerText: (String) -> Unit,
|
||||||
|
view: View,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
FrameStats.reset()
|
FrameStats.reset()
|
||||||
DebugStats.reset()
|
DebugStats.reset()
|
||||||
@@ -55,34 +116,10 @@ object BenchRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
|
val travel = runFlingPhase(listState, flingBehavior)
|
||||||
// content and two back, so a cycle returns to where it started and the whole loop measures
|
val sent = runStreamPhase()
|
||||||
// steady-state scrolling rather than travelling somewhere new each time.
|
runTypePhase(listState, composerFocus, setComposerText, view)
|
||||||
repeat(CYCLES) {
|
val keyboard = runKeyboardPhase(context, view)
|
||||||
repeat(2) {
|
|
||||||
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
|
|
||||||
delay(SWIPE_PAUSE_MS)
|
|
||||||
}
|
|
||||||
repeat(2) {
|
|
||||||
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
|
|
||||||
delay(SWIPE_PAUSE_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
|
|
||||||
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 report is read.
|
|
||||||
delay(300)
|
|
||||||
|
|
||||||
samplerJob.cancel()
|
samplerJob.cancel()
|
||||||
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||||
@@ -90,13 +127,158 @@ object BenchRun {
|
|||||||
val batteryLine = battery.finish()
|
val batteryLine = battery.finish()
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
|
" 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",
|
" process CPU time over this run: ${cpuMs}ms",
|
||||||
rssLine,
|
rssLine,
|
||||||
batteryLine,
|
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. */
|
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||||
private fun peakRssLine(): String {
|
private fun peakRssLine(): String {
|
||||||
val kb =
|
val kb =
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ fun debugReport(
|
|||||||
* render-report button reads exactly as it did before this existed.
|
* render-report button reads exactly as it did before this existed.
|
||||||
*/
|
*/
|
||||||
extra: List<String> = emptyList(),
|
extra: List<String> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
|
||||||
|
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
|
||||||
|
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
|
||||||
|
* [extra].
|
||||||
|
*/
|
||||||
|
phaseFrames: List<String> = emptyList(),
|
||||||
): String = buildString {
|
): String = buildString {
|
||||||
appendLine("ai-app render report")
|
appendLine("ai-app render report")
|
||||||
appendLine(device)
|
appendLine(device)
|
||||||
@@ -148,6 +155,11 @@ fun debugReport(
|
|||||||
appendLine("transcript:")
|
appendLine("transcript:")
|
||||||
transcript.forEach { appendLine(it) }
|
transcript.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
|
if (phaseFrames.isNotEmpty()) {
|
||||||
|
appendLine("per phase:")
|
||||||
|
phaseFrames.forEach { appendLine(it) }
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
appendLine("frames:")
|
appendLine("frames:")
|
||||||
frames.forEach { appendLine(it) }
|
frames.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A line across the transcript saying what left the session's context.
|
* A line across the transcript saying what left the session's context.
|
||||||
@@ -47,3 +51,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
|||||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
fun ClearedRow(modifier: Modifier = Modifier) {
|
||||||
TranscriptDivider("Context cleared", clearedColor, modifier)
|
TranscriptDivider("Context cleared", clearedColor, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark running out of quota leaves.
|
||||||
|
*
|
||||||
|
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
|
||||||
|
* place: colour by consequence, so "there is nothing left to spend" is learned once.
|
||||||
|
*
|
||||||
|
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
|
||||||
|
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
|
||||||
|
* which is not a promise about when the session picks back up. A limit the session was told no
|
||||||
|
* reset time for says nothing about one -- that state has its own words rather than a plausible
|
||||||
|
* number.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
|
||||||
|
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the row says. Split out so the wording is testable without a screen, since the two states it
|
||||||
|
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
|
||||||
|
* that reads the same when it goes wrong.
|
||||||
|
*
|
||||||
|
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
|
||||||
|
*/
|
||||||
|
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
|
||||||
|
val at = resetsAt?.let {
|
||||||
|
try {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(Instant.ofEpochSecond(it.toLong()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
|
|||||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
||||||
* saw as the new cursor.
|
* saw as the new cursor.
|
||||||
*/
|
*/
|
||||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
|
||||||
private val stream = Sse(settings)
|
private val stream = Sse(settings)
|
||||||
|
|
||||||
fun close() = stream.close()
|
fun close() = stream.close()
|
||||||
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
|||||||
// one and the screen folds the other, and they have to be the same line.
|
// one and the screen folds the other, and they have to be the same line.
|
||||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
|
||||||
// A named frame carries no payload and a data frame has no name.
|
// A named frame carries no payload and a data frame has no name.
|
||||||
if (name == RESET_EVENT) onReset()
|
if (name == RESET_EVENT) onReset()
|
||||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data object Cleared : SessionEvent()
|
data object Cleared : SessionEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session stopped because its account's usage limit was reached.
|
||||||
|
*
|
||||||
|
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
|
||||||
|
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
|
||||||
|
* limit reached|1788546972`, which is not readable by the person it is shown to.
|
||||||
|
*
|
||||||
|
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
|
||||||
|
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
|
||||||
|
*/
|
||||||
|
data class LimitReached(val resetsAt: Double?) : SessionEvent()
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +273,10 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
trigger = body.optString("trigger").ifEmpty { null },
|
trigger = body.optString("trigger").ifEmpty { null },
|
||||||
)
|
)
|
||||||
"cleared" -> SessionEvent.Cleared
|
"cleared" -> SessionEvent.Cleared
|
||||||
|
"limitReached" ->
|
||||||
|
SessionEvent.LimitReached(
|
||||||
|
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||||
|
)
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ object FrameStats {
|
|||||||
private val gpu = ArrayList<Long>()
|
private val gpu = ArrayList<Long>()
|
||||||
private var since = System.currentTimeMillis()
|
private var since = System.currentTimeMillis()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
|
||||||
|
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
|
||||||
|
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
|
||||||
|
*/
|
||||||
|
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
|
||||||
|
|
||||||
|
private val phaseMarks = ArrayList<PhaseMark>()
|
||||||
|
|
||||||
|
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
|
||||||
|
@Synchronized
|
||||||
|
fun markPhase(name: String) {
|
||||||
|
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun add(metrics: FrameMetrics) {
|
fun add(metrics: FrameMetrics) {
|
||||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||||
@@ -69,6 +84,7 @@ object FrameStats {
|
|||||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||||
it.clear()
|
it.clear()
|
||||||
}
|
}
|
||||||
|
phaseMarks.clear()
|
||||||
since = System.currentTimeMillis()
|
since = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +111,38 @@ object FrameStats {
|
|||||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
|
||||||
|
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
|
||||||
|
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
|
||||||
|
* that finished before a frame was drawn) still get a line rather than being silently dropped
|
||||||
|
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun phaseLines(refreshHz: Float): List<String> {
|
||||||
|
if (phaseMarks.isEmpty()) return emptyList()
|
||||||
|
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||||
|
val lines = ArrayList<String>()
|
||||||
|
phaseMarks.forEachIndexed { i, mark ->
|
||||||
|
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
|
||||||
|
val endMs =
|
||||||
|
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
|
||||||
|
else System.currentTimeMillis()
|
||||||
|
val samples = total.subList(mark.startIndex, endIndex)
|
||||||
|
val seconds = (endMs - mark.startMs) / 1000.0
|
||||||
|
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
|
||||||
|
if (samples.isEmpty()) {
|
||||||
|
lines += " no frames recorded in this phase"
|
||||||
|
} else {
|
||||||
|
val late = samples.count { it / 1_000_000.0 > budget }
|
||||||
|
lines += " late: $late (${percent(late, samples.size)})"
|
||||||
|
lines += " " + phase("total ", samples)
|
||||||
|
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||||
|
|
||||||
|
|||||||
@@ -187,13 +187,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
model = null,
|
model = null,
|
||||||
keepsOwnTranscript = false,
|
keepsOwnTranscript = false,
|
||||||
permissionMode = null,
|
permissionMode = null,
|
||||||
|
effort = null,
|
||||||
|
takesEffort = false,
|
||||||
imported = false,
|
imported = false,
|
||||||
notify = false,
|
notify = false,
|
||||||
|
autoResume = false,
|
||||||
|
autoResumeMessage = "",
|
||||||
|
resumeAt = null,
|
||||||
cwd = null,
|
cwd = null,
|
||||||
contextTokens = null,
|
contextTokens = null,
|
||||||
maxImageEdge = null,
|
maxImageEdge = null,
|
||||||
|
usageProvider = null,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
lastActivity = 0.0,
|
lastActivity = 0.0,
|
||||||
|
subagents = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ fun MainScreen(
|
|||||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
||||||
share: ShareRequest? = null,
|
share: ShareRequest? = null,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
|
/** Opens one session's subagent, from the expander under its card. */
|
||||||
|
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
onImported: (SessionSummary) -> Unit,
|
onImported: (SessionSummary) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
@@ -139,6 +141,7 @@ fun MainScreen(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
reloadToken = token,
|
reloadToken = token,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
|
onOpenSubagent = onOpenSubagent,
|
||||||
onSpawn = onSpawn,
|
onSpawn = onSpawn,
|
||||||
)
|
)
|
||||||
MainTab.Import ->
|
MainTab.Import ->
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -17,6 +20,7 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -30,6 +34,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -47,12 +53,40 @@ fun SessionListScreen(
|
|||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
reloadToken: Int,
|
reloadToken: Int,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
|
/** Opens one session's subagent, from the expander under its card. */
|
||||||
|
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
||||||
|
|
||||||
|
// Which session cards are expanded to show their subagents, and what each expansion fetched.
|
||||||
|
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
|
||||||
|
// from
|
||||||
|
// whatever the server last said, and this belongs to the reader's own choice, which survives a
|
||||||
|
// refresh.
|
||||||
|
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
var subagentLoads by remember {
|
||||||
|
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSubagents(sessionId: String) {
|
||||||
|
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
|
||||||
|
scope.launch {
|
||||||
|
subagentLoads =
|
||||||
|
subagentLoads +
|
||||||
|
(sessionId to
|
||||||
|
try {
|
||||||
|
LoadState.Loaded(
|
||||||
|
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
|
||||||
|
)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
||||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||||
// so this says nothing about the other rows.
|
// so this says nothing about the other rows.
|
||||||
@@ -84,6 +118,14 @@ fun SessionListScreen(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
||||||
}
|
}
|
||||||
|
// A session gone from this answer cannot still be expanded, and an expanded one
|
||||||
|
// that is still here asks again -- its subagents may have changed since the
|
||||||
|
// last
|
||||||
|
// fetch.
|
||||||
|
val ids = loaded.value.map { it.id }.toSet()
|
||||||
|
expandedSessions = expandedSessions intersect ids
|
||||||
|
subagentLoads = subagentLoads.filterKeys { it in ids }
|
||||||
|
expandedSessions.forEach(::loadSubagents)
|
||||||
loaded
|
loaded
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
@@ -127,6 +169,17 @@ fun SessionListScreen(
|
|||||||
deleting = session.id in deleting,
|
deleting = session.id in deleting,
|
||||||
onOpen = { onOpen(session) },
|
onOpen = { onOpen(session) },
|
||||||
onLongPress = { confirmingDelete = session },
|
onLongPress = { confirmingDelete = session },
|
||||||
|
expanded = session.id in expandedSessions,
|
||||||
|
subagents = subagentLoads[session.id],
|
||||||
|
onToggleSubagents = {
|
||||||
|
if (session.id in expandedSessions) {
|
||||||
|
expandedSessions = expandedSessions - session.id
|
||||||
|
} else {
|
||||||
|
expandedSessions = expandedSessions + session.id
|
||||||
|
loadSubagents(session.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
@@ -225,7 +278,7 @@ fun SessionListScreen(
|
|||||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||||
// After it succeeded, not before: a refused delete leaves the
|
// After it succeeded, not before: a refused delete leaves the
|
||||||
// session exactly as it was, and its transcript with it.
|
// session exactly as it was, and its transcript with it.
|
||||||
transcriptCache.session(session.id).purge()
|
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
||||||
}
|
}
|
||||||
// Only this row, and only what changed. Refetching the list instead
|
// Only this row, and only what changed. Refetching the list instead
|
||||||
// put every other session back through loading and handed the
|
// put every other session back through loading and handed the
|
||||||
@@ -276,6 +329,12 @@ private fun SessionCard(
|
|||||||
deleting: Boolean,
|
deleting: Boolean,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
|
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
|
||||||
|
expanded: Boolean,
|
||||||
|
/** What the expander's own fetch answered, or null before it has been asked. */
|
||||||
|
subagents: LoadState<List<SubagentSummary>>?,
|
||||||
|
onToggleSubagents: () -> Unit,
|
||||||
|
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||||
) {
|
) {
|
||||||
BusyItem(label = if (deleting) "deleting" else null) {
|
BusyItem(label = if (deleting) "deleting" else null) {
|
||||||
Card(
|
Card(
|
||||||
@@ -332,11 +391,105 @@ private fun SessionCard(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Nothing at all for a card with no subagents: a disabled expander here would be
|
||||||
|
// noise on every ordinary session's card. Its own row at the bottom rather than
|
||||||
|
// beside the title or the machine line, so opening it never displaces text that was
|
||||||
|
// already on screen -- see UI_RULES on a control not displacing the text beside it.
|
||||||
|
if (session.subagents > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// The platform's minimum touch height, not the chevron's own ten or so dp:
|
||||||
|
// at the chevron's height a tap meant for it landed on the first subcard
|
||||||
|
// beneath and opened a subagent instead.
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clickable(enabled = !deleting, onClick = onToggleSubagents)
|
||||||
|
.semantics {
|
||||||
|
contentDescription =
|
||||||
|
if (expanded) "Collapse subagents" else "Expand subagents"
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
when (subagents) {
|
||||||
|
null,
|
||||||
|
is LoadState.Loading ->
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(20.dp).height(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
is LoadState.Error ->
|
||||||
|
// Said here rather than left silent: a fetch that failed and an
|
||||||
|
// expander that simply found nothing must not look the same --
|
||||||
|
// see UI_RULES on designing the unknown state first.
|
||||||
|
Text(
|
||||||
|
subagents.message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
subagents.value.forEach { subagent ->
|
||||||
|
SubagentCard(
|
||||||
|
subagent,
|
||||||
|
onClick = { onOpenSubagent(subagent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
|
||||||
|
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
|
||||||
|
* filled one, so the nesting reads as one step rather than as another session.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||||
|
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
subagentStatusLabel(subagent.status),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
relativeTime(subagent.lastActivity),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
|
||||||
|
* rather than a branch inside [StatusText], because a subagent's three states are not that
|
||||||
|
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
|
||||||
|
* and never something of its own to have merely stopped.
|
||||||
|
*/
|
||||||
|
private fun subagentStatusLabel(status: String) =
|
||||||
|
when (status) {
|
||||||
|
"running" -> "running"
|
||||||
|
"exited" -> "finished"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusText(status: String) {
|
fun StatusText(status: String) {
|
||||||
val (label, color) =
|
val (label, color) =
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
@@ -216,16 +220,33 @@ fun SessionScreen(
|
|||||||
share: ShareRequest? = null,
|
share: ShareRequest? = null,
|
||||||
/** Said once [share] has been attached here, so it is not attached again. */
|
/** Said once [share] has been attached here, so it is not attached again. */
|
||||||
onShareTaken: () -> Unit = {},
|
onShareTaken: () -> Unit = {},
|
||||||
|
/**
|
||||||
|
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
|
||||||
|
*
|
||||||
|
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
|
||||||
|
* every gate below keyed on this switches off the composer, the files button, the settings cog,
|
||||||
|
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
|
||||||
|
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
|
||||||
|
* [address] instead of the session's own.
|
||||||
|
*/
|
||||||
|
subagent: SubagentSummary? = null,
|
||||||
) {
|
) {
|
||||||
DebugStats.count("session screen recomposed")
|
DebugStats.count("session screen recomposed")
|
||||||
|
val isSubagent = subagent != null
|
||||||
|
val address = TranscriptAddress(summary.id, subagent?.id)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val topEdgeHeld = remember { TopEdgeHold() }
|
val topEdgeHeld = remember { TopEdgeHold() }
|
||||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||||
var status by remember { mutableStateOf(summary.status) }
|
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
||||||
// Seeded from the row this screen was opened from, so a conversation already under way says how
|
// Seeded from the row this screen was opened from, so a conversation already under way says how
|
||||||
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a
|
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a
|
||||||
// different answer from an empty context and is drawn differently.
|
// different answer from an empty context and is drawn differently.
|
||||||
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) }
|
//
|
||||||
|
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
|
||||||
|
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
|
||||||
|
// that was measured.
|
||||||
|
var contextTokens by
|
||||||
|
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
|
||||||
// When the current compaction started. The moment comes off the `compacting` status event
|
// When the current compaction started. The moment comes off the `compacting` status event
|
||||||
// itself -- the server timestamps every transcript line -- rather than off this device noticing
|
// itself -- the server timestamps every transcript line -- rather than off this device noticing
|
||||||
// one, which is what makes it survive leaving the session and reopening it.
|
// one, which is what makes it survive leaving the session and reopening it.
|
||||||
@@ -241,7 +262,13 @@ fun SessionScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// Seeded from what was left in the box last time and written back on every keystroke, so
|
// Seeded from what was left in the box last time and written back on every keystroke, so
|
||||||
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
|
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
|
||||||
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) }
|
//
|
||||||
|
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
|
||||||
|
// which is what reading one keyed only by `summary.id` would do here.
|
||||||
|
var input by
|
||||||
|
remember(summary.id) {
|
||||||
|
mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id)))
|
||||||
|
}
|
||||||
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
||||||
// makes the session re-read the whole conversation.
|
// makes the session re-read the whole conversation.
|
||||||
var pendingModel by remember { mutableStateOf<String?>(null) }
|
var pendingModel by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -294,27 +321,26 @@ fun SessionScreen(
|
|||||||
// Reload throws away what it was reading from.
|
// Reload throws away what it was reading from.
|
||||||
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
val source =
|
val source =
|
||||||
remember(summary.id, epoch) {
|
remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
|
||||||
TranscriptSource(settings, summary.id, cache.session(summary.id))
|
|
||||||
}
|
|
||||||
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
|
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
|
||||||
// from a cached cursor until it has, and a probe that could not be made leaves this false for
|
// from a cached cursor until it has, and a probe that could not be made leaves this false for
|
||||||
// the stream loop to try again.
|
// the stream loop to try again.
|
||||||
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) }
|
var probePassed by remember(address, epoch) { mutableStateOf(false) }
|
||||||
// Whether the opening effect is still settling that question. It draws the cached rows and
|
// Whether the opening effect is still settling that question. It draws the cached rows and
|
||||||
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
||||||
// below waits for this rather than for `ready`, or it asks the same question twice.
|
// below waits for this rather than for `ready`, or it asks the same question twice.
|
||||||
var probing by remember(summary.id, epoch) { mutableStateOf(true) }
|
var probing by remember(address, epoch) { mutableStateOf(true) }
|
||||||
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
|
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
|
||||||
// what keeps opening a long session cheap.
|
// what keeps opening a long session cheap.
|
||||||
var oldestSeq by remember { mutableLongStateOf(0L) }
|
var oldestSeq by remember { mutableLongStateOf(0L) }
|
||||||
// Where this session was last being read, from this device's own store. Read once, because the
|
// Where this transcript was last being read, from this device's own store, keyed by the address
|
||||||
// answer stops being interesting the moment the list is on screen.
|
// rather than the session id so a subagent's saved position cannot collide with its session's.
|
||||||
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) }
|
// Read once, because the answer stops being interesting the moment the list is on screen.
|
||||||
|
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
|
||||||
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at
|
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at
|
||||||
// the newest end and then travelling to the anchor is exactly the journey a reader must never
|
// the newest end and then travelling to the anchor is exactly the journey a reader must never
|
||||||
// see.
|
// see.
|
||||||
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) }
|
var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
|
||||||
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
||||||
// them. From the event stream rather than from what this screen sent, so they survive leaving
|
// them. From the event stream rather than from what this screen sent, so they survive leaving
|
||||||
// the session -- and a message sent from another device is drawn waiting on this one too.
|
// the session -- and a message sent from another device is drawn waiting on this one too.
|
||||||
@@ -327,11 +353,22 @@ fun SessionScreen(
|
|||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
|
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
|
||||||
val replies = remember(summary.id) { ParsedReplies() }
|
val replies = remember(address) { ParsedReplies() }
|
||||||
// Keyed like everything else describing one session's transcript. `rememberLazyListState` saves
|
// Keyed like everything else describing one transcript. `rememberLazyListState` saves through
|
||||||
// through `rememberSaveable`, and this screen restores by its own anchor instead -- two
|
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||||
// restores would fight over the first frame.
|
// fight over the first frame.
|
||||||
val listState = remember(summary.id) { LazyListState() }
|
val listState = remember(address) { LazyListState() }
|
||||||
|
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
|
||||||
|
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
|
||||||
|
// screen's
|
||||||
|
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
|
||||||
|
// argument, so this is the same default it gets).
|
||||||
|
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||||
|
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
|
||||||
|
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
|
||||||
|
// unconditionally rather than behind a second code path only the bench build compiles.
|
||||||
|
val composerFocus = remember { FocusRequester() }
|
||||||
|
val view = LocalView.current
|
||||||
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
||||||
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
||||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||||
@@ -637,7 +674,7 @@ fun SessionScreen(
|
|||||||
// ended and carries live events only. The window comes from this phone's own copy when there is
|
// ended and carries live events only. The window comes from this phone's own copy when there is
|
||||||
// one, and then costs a single request to check that the server's transcript is still the one
|
// one, and then costs a single request to check that the server's transcript is still the one
|
||||||
// it came from. See TRANSCRIPT_CACHE.md.
|
// it came from. See TRANSCRIPT_CACHE.md.
|
||||||
LaunchedEffect(summary.id, epoch) {
|
LaunchedEffect(address, epoch) {
|
||||||
/**
|
/**
|
||||||
* One opening window onto the screen, whichever side it came from.
|
* One opening window onto the screen, whichever side it came from.
|
||||||
*
|
*
|
||||||
@@ -667,11 +704,16 @@ fun SessionScreen(
|
|||||||
// A replay is as old as the last visit; the row this screen was opened from was
|
// A replay is as old as the last visit; the row this screen was opened from was
|
||||||
// fetched moments ago. So the transcript comes from the cache and everything that
|
// fetched moments ago. So the transcript comes from the cache and everything that
|
||||||
// is not the transcript comes from the summary -- otherwise a session that finished
|
// is not the transcript comes from the summary -- otherwise a session that finished
|
||||||
// an hour ago opens saying "working" until the stream connects.
|
// an hour ago opens saying "working" until the stream connects. A subagent's status
|
||||||
status = summary.status
|
// comes from its own summary, never the parent session's: they are two different
|
||||||
|
// things running or not, and the parent's model and permission mode do not apply to
|
||||||
|
// it at all.
|
||||||
|
status = subagent?.status ?: summary.status
|
||||||
|
if (!isSubagent) {
|
||||||
model = summary.model
|
model = summary.model
|
||||||
permissionMode = summary.permissionMode ?: "auto"
|
permissionMode = summary.permissionMode ?: "auto"
|
||||||
if (summary.status != "compacting") compactingSince = null
|
}
|
||||||
|
if (status != "compacting") compactingSince = null
|
||||||
// Nothing to put back, so these rows are the screen and the probe can return under
|
// Nothing to put back, so these rows are the screen and the probe can return under
|
||||||
// them. A restore still has history to fetch and is gated below.
|
// them. A restore still has history to fetch and is gated below.
|
||||||
if (savedAnchor == null) ready = true
|
if (savedAnchor == null) ready = true
|
||||||
@@ -798,7 +840,7 @@ fun SessionScreen(
|
|||||||
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
|
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
|
||||||
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming
|
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming
|
||||||
// reconnects from the same cursor.
|
// reconnects from the same cursor.
|
||||||
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) {
|
LaunchedEffect(address, ready, epoch, lifecycleOwner) {
|
||||||
if (!ready) return@LaunchedEffect
|
if (!ready) return@LaunchedEffect
|
||||||
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
||||||
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without
|
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without
|
||||||
@@ -868,10 +910,14 @@ fun SessionScreen(
|
|||||||
// The screen going away entirely, which the lifecycle scope above does not cover: a composable
|
// The screen going away entirely, which the lifecycle scope above does not cover: a composable
|
||||||
// can leave the composition while the activity stays started. Keyed on the epoch as well, so
|
// can leave the composition while the activity stays started. Keyed on the epoch as well, so
|
||||||
// Reload's replacement source is the one a later disposal closes.
|
// Reload's replacement source is the one a later disposal closes.
|
||||||
DisposableEffect(summary.id, epoch) { onDispose { source.close() } }
|
DisposableEffect(address, epoch) { onDispose { source.close() } }
|
||||||
|
|
||||||
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
||||||
// RESUMED rather than STARTED because "looking at it" means the foreground.
|
// RESUMED rather than STARTED because "looking at it" means the foreground.
|
||||||
|
//
|
||||||
|
// Not for a subagent: it has no notifications of its own, and it is not the session this would
|
||||||
|
// otherwise mark as being read.
|
||||||
|
if (!isSubagent) {
|
||||||
LaunchedEffect(summary.id, lifecycleOwner) {
|
LaunchedEffect(summary.id, lifecycleOwner) {
|
||||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
NotificationService.showing(context, summary.id)
|
NotificationService.showing(context, summary.id)
|
||||||
@@ -882,6 +928,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
|
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
|
||||||
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
|
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
|
||||||
@@ -924,7 +971,7 @@ fun SessionScreen(
|
|||||||
val (index, offset, awayFromNewest) = settled
|
val (index, offset, awayFromNewest) = settled
|
||||||
saveScrollAnchor(
|
saveScrollAnchor(
|
||||||
context,
|
context,
|
||||||
summary.id,
|
address.cachePath,
|
||||||
// Nothing to restore at the newest end, which is where a session with no anchor
|
// Nothing to restore at the newest end, which is where a session with no anchor
|
||||||
// opens anyway. One *before* the index, because item zero is the "below" slot.
|
// opens anyway. One *before* the index, because item zero is the "below" slot.
|
||||||
if (!awayFromNewest) null
|
if (!awayFromNewest) null
|
||||||
@@ -947,7 +994,7 @@ fun SessionScreen(
|
|||||||
//
|
//
|
||||||
// There is no correction beside this one. Following the newest message is not an effect: the
|
// There is no correction beside this one. Following the newest message is not an effect: the
|
||||||
// list is reversed, so an arriving message extends the end the viewport is pinned to.
|
// list is reversed, so an arriving message extends the end the viewport is pinned to.
|
||||||
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
|
val unitSizes = remember(address) { HashMap<Any, Int>() }
|
||||||
LaunchedEffect(listState, moreHistory) {
|
LaunchedEffect(listState, moreHistory) {
|
||||||
snapshotFlow { listState.layoutInfo }
|
snapshotFlow { listState.layoutInfo }
|
||||||
.collect { info ->
|
.collect { info ->
|
||||||
@@ -983,6 +1030,8 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only for the model picker, which a subagent does not have.
|
||||||
|
if (!isSubagent) {
|
||||||
LaunchedEffect(summary.setupName, summary.provider) {
|
LaunchedEffect(summary.setupName, summary.provider) {
|
||||||
offeredModels =
|
offeredModels =
|
||||||
try {
|
try {
|
||||||
@@ -995,10 +1044,12 @@ fun SessionScreen(
|
|||||||
.orEmpty()
|
.orEmpty()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Not worth reporting: the picker simply has nothing to offer, which is visible.
|
// Not worth reporting: the picker simply has nothing to offer, which is
|
||||||
|
// visible.
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks the server to take back a message the session has not read yet.
|
* Asks the server to take back a message the session has not read yet.
|
||||||
@@ -1148,8 +1199,9 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One poll for the machines' limits, read by everything on this screen that reports them.
|
// One poll for the machines' limits, read by everything on this screen that reports them.
|
||||||
val usageFeed = rememberUsageFeed(settings)
|
// Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
|
||||||
val usage = usageFeed.forSetup(summary.setup)
|
val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
|
||||||
|
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
|
||||||
RecordFrames()
|
RecordFrames()
|
||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
@@ -1219,6 +1271,10 @@ fun SessionScreen(
|
|||||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||||
crash = lastCrash(context),
|
crash = lastCrash(context),
|
||||||
extra = extra,
|
extra = extra,
|
||||||
|
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
|
||||||
|
// the
|
||||||
|
// marks along with everything else, so an ordinary copy never has any to show.
|
||||||
|
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
|
||||||
)
|
)
|
||||||
context.copyToClipboard("ai-app render report", report)
|
context.copyToClipboard("ai-app render report", report)
|
||||||
// Also to the log, so a session driving the app over adb can read the same report the
|
// Also to the log, so a session driving the app over adb can read the same report the
|
||||||
@@ -1233,15 +1289,24 @@ fun SessionScreen(
|
|||||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
val copyRenderReport = { buildAndCopyReport() }
|
val copyRenderReport = { buildAndCopyReport() }
|
||||||
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
|
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||||
// fixture session opened below instead of a real server. Null everywhere else -- see
|
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||||
// [SessionSettingsDialog]'s onRunBenchmark.
|
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||||
val runBenchmark: (() -> Unit)? =
|
val runBenchmark: (() -> Unit)? =
|
||||||
if (BuildConfig.FIXTURE_MODE) {
|
if (BuildConfig.FIXTURE_MODE) {
|
||||||
{
|
{
|
||||||
settingsOpen = false
|
settingsOpen = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val extra = BenchRun.run(context, scope, listState)
|
val extra =
|
||||||
|
BenchRun.run(
|
||||||
|
context = context,
|
||||||
|
scope = scope,
|
||||||
|
listState = listState,
|
||||||
|
flingBehavior = flingBehavior,
|
||||||
|
composerFocus = composerFocus,
|
||||||
|
setComposerText = { text -> input = atEnd(text) },
|
||||||
|
view = view,
|
||||||
|
)
|
||||||
buildAndCopyReport(extra)
|
buildAndCopyReport(extra)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,20 +1321,35 @@ fun SessionScreen(
|
|||||||
// A ring's worth, which is what the arrow already keeps on its other three sides.
|
// A ring's worth, which is what the arrow already keeps on its other three sides.
|
||||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
|
// A subagent's own title, with the session's beneath it in a smaller style --
|
||||||
|
// the header says whose conversation this is as well as what it is. Otherwise
|
||||||
|
// just the session's title, as before.
|
||||||
|
if (subagent != null) {
|
||||||
|
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
// Machine first, then what runs on it -- the same order and the same wording
|
// Machine first, then what runs on it -- the same order and the same
|
||||||
// everywhere this pair appears, so it reads as one fact rather than two
|
// wording everywhere this pair appears, so it reads as one fact rather than
|
||||||
// sentences with different grammar.
|
// two sentences with different grammar.
|
||||||
//
|
//
|
||||||
// No model. The picker in the footer already shows what this session is set to,
|
// No model. The picker in the footer already shows what this session is set
|
||||||
// and showing it twice means two things to keep in step -- they disagreed for a
|
// to, and showing it twice means two things to keep in step -- they
|
||||||
// moment on every model change.
|
// disagreed for a moment on every model change.
|
||||||
Text(
|
Text(
|
||||||
"${summary.setupName} · ${summary.provider}",
|
"${summary.setupName} · ${summary.provider}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// None of this is a subagent's: it has no files of its own to browse, no settings,
|
||||||
|
// and nothing meters it -- see SUBAGENTS.md's "Phone".
|
||||||
|
//
|
||||||
// Beside the provider it reports on, which is the line directly to its left. Its
|
// Beside the provider it reports on, which is the line directly to its left. Its
|
||||||
// real home is this provider's settings, which do not exist yet. A session on a
|
// real home is this provider's settings, which do not exist yet. A session on a
|
||||||
// provider with no such service gets an honest "unavailable" rather than a hidden
|
// provider with no such service gets an honest "unavailable" rather than a hidden
|
||||||
@@ -1284,6 +1364,7 @@ fun SessionScreen(
|
|||||||
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog
|
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog
|
||||||
// stays at the end where every other screen keeps it. Asked for in this order by
|
// stays at the end where every other screen keeps it. Asked for in this order by
|
||||||
// Iris on 2026-09-03.
|
// Iris on 2026-09-03.
|
||||||
|
if (!isSubagent) {
|
||||||
Row {
|
Row {
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
USAGE_GLYPH,
|
USAGE_GLYPH,
|
||||||
@@ -1301,25 +1382,29 @@ fun SessionScreen(
|
|||||||
FilesTarget(
|
FilesTarget(
|
||||||
setup = summary.setup,
|
setup = summary.setup,
|
||||||
setupName = summary.setupName,
|
setupName = summary.setupName,
|
||||||
// Where this session works, and the machine's own home when it
|
// Where this session works, and the machine's own home when
|
||||||
// was never given a directory -- resolved there rather than
|
// it was never given a directory -- resolved there rather
|
||||||
// guessed at here, since this app does not know that home.
|
// than guessed at here, since this app does not know that
|
||||||
|
// home.
|
||||||
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
|
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// What it opens is about this session, so it sits at the end of the session's
|
// What it opens is about this session, so it sits at the end of the
|
||||||
// own row. A cog and not a word because there will be more, and a bar of words
|
// session's own row. A cog and not a word because there will be more, and a
|
||||||
// has nowhere to put it.
|
// bar of words has nowhere to put it.
|
||||||
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
|
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Under the header, above everything the session itself says: it is a fact about the
|
// Under the header, above everything the session itself says: it is a fact about the
|
||||||
// machine rather than a turn in the conversation, and it is the number that decides
|
// machine rather than a turn in the conversation, and it is the number that decides
|
||||||
// whether to keep going.
|
// whether to keep going. Nothing meters a subagent.
|
||||||
|
if (!isSubagent) {
|
||||||
SessionUsageBar(usage)
|
SessionUsageBar(usage)
|
||||||
|
}
|
||||||
|
|
||||||
(streamError ?: actionError)?.let { message ->
|
(streamError ?: actionError)?.let { message ->
|
||||||
Text(
|
Text(
|
||||||
@@ -1565,6 +1650,7 @@ fun SessionScreen(
|
|||||||
is TranscriptItem.ClearedNote -> ClearedRow()
|
is TranscriptItem.ClearedNote -> ClearedRow()
|
||||||
is TranscriptItem.CompactedNote ->
|
is TranscriptItem.CompactedNote ->
|
||||||
CompactedRow(item)
|
CompactedRow(item)
|
||||||
|
is TranscriptItem.LimitNote -> LimitRow(item)
|
||||||
// Never reached: a peer message is flattened into
|
// Never reached: a peer message is flattened into
|
||||||
// its own units. Here because a `when` over the
|
// its own units. Here because a `when` over the
|
||||||
// item kinds has to stay exhaustive.
|
// item kinds has to stay exhaustive.
|
||||||
@@ -1663,24 +1749,34 @@ fun SessionScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
|
||||||
|
// "exited" into "finished" for one, since it has no process to leave running or stop.
|
||||||
SessionStatusRow(
|
SessionStatusRow(
|
||||||
status = status,
|
status = status,
|
||||||
compactingFor = compactingFor,
|
compactingFor = compactingFor,
|
||||||
contextTokens = contextTokens,
|
contextTokens = contextTokens,
|
||||||
|
subagent = isSubagent,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Between the transcript and the box: above what is being typed, so the list does not
|
// Everything from here down is the composer: a subagent cannot be messaged, so none of
|
||||||
// cover the thing the command is about, and below everything that explains it.
|
// it applies -- see SUBAGENTS.md's "Phone".
|
||||||
|
if (!isSubagent) {
|
||||||
|
// Between the transcript and the box: above what is being typed, so the list does
|
||||||
|
// not cover the thing the command is about, and below everything that explains it.
|
||||||
CommandSuggestions(
|
CommandSuggestions(
|
||||||
// Nothing to suggest about a suggestion that was just taken. `/compact` is a whole
|
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
|
||||||
// command *and* a prefix of itself, so picking it left the list standing there with
|
// whole command *and* a prefix of itself, so picking it left the list standing
|
||||||
// the one row already chosen. Held by what was picked rather than by a flag, so
|
// there with the one row already chosen. Held by what was picked rather than by
|
||||||
// typing anything else brings the list back without a second thing to reset.
|
// a flag, so typing anything else brings the list back without a second thing
|
||||||
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
// to
|
||||||
|
// reset.
|
||||||
|
commands =
|
||||||
|
if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
||||||
onPick = { command ->
|
onPick = { command ->
|
||||||
// At the end of what was inserted, which is where the reader carries on typing:
|
// At the end of what was inserted, which is where the reader carries on
|
||||||
// a command with an argument is put in the box half-written, and a cursor left
|
// typing: a command with an argument is put in the box half-written, and a
|
||||||
// at the front makes the next keystroke the first character of "/rename".
|
// cursor left at the front makes the next keystroke the first character of
|
||||||
|
// "/rename".
|
||||||
input = atEnd(command.typed())
|
input = atEnd(command.typed())
|
||||||
picked = command.typed()
|
picked = command.typed()
|
||||||
},
|
},
|
||||||
@@ -1689,11 +1785,14 @@ fun SessionScreen(
|
|||||||
// Always enabled -- a send while the session is running becomes a steering message
|
// Always enabled -- a send while the session is running becomes a steering message
|
||||||
// injected at the next tool boundary, which is the point of the whole app.
|
// injected at the next tool boundary, which is the point of the whole app.
|
||||||
//
|
//
|
||||||
// The field gets a row of its own, above the buttons: sharing one put the full width
|
// The field gets a row of its own, above the buttons: sharing one put the full
|
||||||
// behind three controls, so the thing being typed into was the narrowest on the row.
|
// width
|
||||||
|
// behind three controls, so the thing being typed into was the narrowest on the
|
||||||
|
// row.
|
||||||
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
// Directly above the box they will be sent from, so what is attached is visible
|
// Directly above the box they will be sent from, so what is attached is visible
|
||||||
// rather than counted: the "+2" on the button below said how many and never which.
|
// rather than counted: the "+2" on the button below said how many and never
|
||||||
|
// which.
|
||||||
PendingAttachments(
|
PendingAttachments(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessionId = summary.id,
|
sessionId = summary.id,
|
||||||
@@ -1706,9 +1805,12 @@ fun SessionScreen(
|
|||||||
input = it
|
input = it
|
||||||
saveDraft(context, summary.id, it.text)
|
saveDraft(context, summary.id, it.text)
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// BenchRun's type phase requests focus on this exact field
|
||||||
// No longer "(+image)": the images are on screen above this, and a placeholder
|
// (`composerFocus`)
|
||||||
// saying so said it in words beside the thing itself.
|
// so it types through the real composer rather than a stand-in.
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
|
||||||
|
// No longer "(+image)": the images are on screen above this, and a
|
||||||
|
// placeholder saying so said it in words beside the thing itself.
|
||||||
placeholder = { Text("Message") },
|
placeholder = { Text("Message") },
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
)
|
)
|
||||||
@@ -1716,17 +1818,19 @@ fun SessionScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
// Photo or file, asked here rather than by two buttons: the row is full, and
|
// Photo or file, asked here rather than by two buttons: the row is full,
|
||||||
|
// and
|
||||||
// attaching is one action whichever picker answers it.
|
// attaching is one action whichever picker answers it.
|
||||||
var attaching by remember { mutableStateOf(false) }
|
var attaching by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
// Just "+". The count it used to carry was standing in for showing them.
|
// Just "+". The count it used to carry was standing in for showing
|
||||||
|
// them.
|
||||||
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = attaching,
|
expanded = attaching,
|
||||||
onDismissRequest = { attaching = false },
|
onDismissRequest = { attaching = false },
|
||||||
// See PickerButton: without this the menu opens a status bar's height
|
// See PickerButton: without this the menu opens a status bar's
|
||||||
// away from the button in an edge-to-edge activity.
|
// height away from the button in an edge-to-edge activity.
|
||||||
properties = PopupProperties(clippingEnabled = false),
|
properties = PopupProperties(clippingEnabled = false),
|
||||||
shape = BubbleMenuShape,
|
shape = BubbleMenuShape,
|
||||||
) {
|
) {
|
||||||
@@ -1750,11 +1854,11 @@ fun SessionScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The settings share what is left after the actions have taken what they need.
|
// The settings share what is left after the actions have taken what they
|
||||||
// A Row hands out intrinsic widths in order and clips whatever runs past the
|
// need. A Row hands out intrinsic widths in order and clips whatever runs
|
||||||
// edge, so with these laid out first the arrival of Stop pushed Send off the
|
// past the edge, so with these laid out first the arrival of Stop pushed
|
||||||
// screen entirely -- the app's central control, gone at the moment it is most
|
// Send off the screen entirely -- the app's central control, gone at the
|
||||||
// in use.
|
// moment it is most in use.
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -1762,16 +1866,18 @@ fun SessionScreen(
|
|||||||
if (offeredModels.isNotEmpty()) {
|
if (offeredModels.isNotEmpty()) {
|
||||||
PickerButton(
|
PickerButton(
|
||||||
current = modelLabel(model),
|
current = modelLabel(model),
|
||||||
// What the machine offers, plus the state a session is in when it
|
// What the machine offers, plus the state a session is in when
|
||||||
// has chosen none of them. The button has always been able to say
|
// it has chosen none of them. The button has always been able
|
||||||
// "default"; until this the list could not, so leaving it was a
|
// to
|
||||||
// one-way trip.
|
// say "default"; until this the list could not, so leaving it
|
||||||
|
// was a one-way trip.
|
||||||
options = listOf(DEFAULT_MODEL) + offeredModels,
|
options = listOf(DEFAULT_MODEL) + offeredModels,
|
||||||
// Not set here. The button follows what the session reports it is
|
// Not set here. The button follows what the session reports it
|
||||||
// set to, which arrives a moment later and is sometimes a different
|
// is set to, which arrives a moment later and is sometimes a
|
||||||
// answer -- a name the CLI resolved, or no change at all on a
|
// different answer -- a name the CLI resolved, or no change at
|
||||||
// provider whose model is fixed. Asked about first, unless there is
|
// all on a provider whose model is fixed. Asked about first,
|
||||||
// nothing to lose by it -- see [ModelSwitchWarning].
|
// unless there is nothing to lose by it -- see
|
||||||
|
// [ModelSwitchWarning].
|
||||||
onPick = { chosen ->
|
onPick = { chosen ->
|
||||||
if (
|
if (
|
||||||
modelLabel(chosen) == modelLabel(model) ||
|
modelLabel(chosen) == modelLabel(model) ||
|
||||||
@@ -1792,14 +1898,16 @@ fun SessionScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The same filled shape as the button beside it, not an outlined one: these are
|
// The same filled shape as the button beside it, not an outlined one: these
|
||||||
// two things you can do about the session, and weighting one as secondary said
|
// are two things you can do about the session, and weighting one as
|
||||||
// they were a primary action and its qualifier. What separates them is the
|
// secondary said they were a primary action and its qualifier. What
|
||||||
// colour and the mark, which is what they mean.
|
// separates them is the colour and the mark, which is what they mean.
|
||||||
//
|
//
|
||||||
// Always here, rather than arriving with the turn as it used to. A control that
|
// Always here, rather than arriving with the turn as it used to. A control
|
||||||
// comes and goes makes its own presence the signal, and a button always in the
|
// that comes and goes makes its own presence the signal, and a button
|
||||||
// same place also cannot push Send off the end of the row by turning up.
|
// always
|
||||||
|
// in the same place also cannot push Send off the end of the row by turning
|
||||||
|
// up.
|
||||||
val process =
|
val process =
|
||||||
when {
|
when {
|
||||||
running -> ProcessAction.Pause
|
running -> ProcessAction.Pause
|
||||||
@@ -1819,18 +1927,21 @@ fun SessionScreen(
|
|||||||
Glyph(
|
Glyph(
|
||||||
process.glyph,
|
process.glyph,
|
||||||
colour = LocalContentColor.current,
|
colour = LocalContentColor.current,
|
||||||
modifier = Modifier.semantics { contentDescription = process.label },
|
modifier =
|
||||||
|
Modifier.semantics { contentDescription = process.label },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// The paper plane, with a clock on it while a turn is in flight: sending then
|
// The paper plane, with a clock on it while a turn is in flight: sending
|
||||||
// queues the message for the next tool boundary rather than starting a turn of
|
// then queues the message for the next tool boundary rather than starting a
|
||||||
// its own, and the two have to be told apart at a glance. The label says the
|
// turn of its own, and the two have to be told apart at a glance. The label
|
||||||
// same thing to a screen reader.
|
// says the same thing to a screen reader.
|
||||||
//
|
//
|
||||||
// Disabled while there is nothing to send, rather than pressable and silent:
|
// Disabled while there is nothing to send, rather than pressable and
|
||||||
// `send` has always returned early on an empty composer, so the button promised
|
// silent:
|
||||||
// something it would not do. Disabled and not hidden, for the reason above.
|
// `send` has always returned early on an empty composer, so the button
|
||||||
|
// promised something it would not do. Disabled and not hidden, for the
|
||||||
|
// reason above.
|
||||||
Button(
|
Button(
|
||||||
onClick = { send() },
|
onClick = { send() },
|
||||||
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||||
@@ -1847,12 +1958,13 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Beside the other two dialogs, and outside the list for the same reason as them: what is open
|
// Beside the other two dialogs, and outside the list for the same reason as them: what is open
|
||||||
// is the screen's business rather than any row's. See [SessionImageViewer].
|
// is the screen's business rather than any row's. See [SessionImageViewer].
|
||||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||||
if (usageOpen) {
|
if (usageOpen) {
|
||||||
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
|
||||||
}
|
}
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
||||||
@@ -1866,6 +1978,8 @@ fun SessionScreen(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
sessionId = summary.id,
|
sessionId = summary.id,
|
||||||
title = title,
|
title = title,
|
||||||
|
effort = summary.effort.takeIf { summary.takesEffort },
|
||||||
|
takesEffort = summary.takesEffort,
|
||||||
cachedBytes = cachedBytes,
|
cachedBytes = cachedBytes,
|
||||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||||
// reads the same directory and would otherwise draw what is about to be deleted. The
|
// reads the same directory and would otherwise draw what is about to be deleted. The
|
||||||
@@ -2143,6 +2257,13 @@ private fun SessionStatusRow(
|
|||||||
/** Context the session is holding, or null where nothing has measured it. */
|
/** Context the session is holding, or null where nothing has measured it. */
|
||||||
contextTokens: Long?,
|
contextTokens: Long?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/**
|
||||||
|
* Whether this row is for a subagent rather than a session, which changes only one word:
|
||||||
|
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
|
||||||
|
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
|
||||||
|
* ordinary way one of these ends.
|
||||||
|
*/
|
||||||
|
subagent: Boolean = false,
|
||||||
) {
|
) {
|
||||||
DebugStats.count("status row recomposed")
|
DebugStats.count("status row recomposed")
|
||||||
Row(
|
Row(
|
||||||
@@ -2199,7 +2320,7 @@ private fun SessionStatusRow(
|
|||||||
Text(
|
Text(
|
||||||
when (status) {
|
when (status) {
|
||||||
"idle" -> "idle"
|
"idle" -> "idle"
|
||||||
"exited" -> "exited"
|
"exited" -> if (subagent) "finished" else "exited"
|
||||||
"awaitingInput" -> "your turn"
|
"awaitingInput" -> "your turn"
|
||||||
"unknown" -> "can't tell"
|
"unknown" -> "can't tell"
|
||||||
else -> status
|
else -> status
|
||||||
@@ -2270,7 +2391,7 @@ private const val ONE_TAP_MS = 250L
|
|||||||
* session is set to without spending a second line on saying it.
|
* session is set to without spending a second line on saying it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
// When an outside touch last closed the menu.
|
// When an outside touch last closed the menu.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -54,6 +60,16 @@ fun SessionSettingsDialog(
|
|||||||
*/
|
*/
|
||||||
title: String,
|
title: String,
|
||||||
onRenamed: (String) -> Unit,
|
onRenamed: (String) -> Unit,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Taken from the row this dialog was opened over rather than fetched, because unlike the
|
||||||
|
* notification switch there is nothing else that changes it: the level is this app's to set and
|
||||||
|
* the server does not resolve it into something else.
|
||||||
|
*/
|
||||||
|
effort: String?,
|
||||||
|
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||||
|
takesEffort: Boolean,
|
||||||
/**
|
/**
|
||||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||||
* the Reload row below, which is what would discard it.
|
* the Reload row below, which is what would discard it.
|
||||||
@@ -76,6 +92,8 @@ fun SessionSettingsDialog(
|
|||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
var name by remember(sessionId) { mutableStateOf(title) }
|
||||||
|
var level by remember(sessionId) { mutableStateOf(effort) }
|
||||||
|
var effortError by remember { mutableStateOf<String?>(null) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
||||||
@@ -84,6 +102,16 @@ fun SessionSettingsDialog(
|
|||||||
// and a spinner sits beside it, which is what not knowing looks like.
|
// and a spinner sits beside it, which is what not knowing looks like.
|
||||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
var notifyError by remember { mutableStateOf<String?>(null) }
|
||||||
|
// The same three-state shape the notification switch has, for the same reason: until the
|
||||||
|
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
|
||||||
|
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||||
|
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
|
||||||
|
// When the server next intends to ask whether the limit has lifted, or null when nothing is
|
||||||
|
// waiting. Read once with everything else: it moves on the server's schedule, not this
|
||||||
|
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
|
||||||
|
// told.
|
||||||
|
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
|
||||||
|
var resumeError by remember { mutableStateOf<String?>(null) }
|
||||||
// Where the session works. Null until the server has been asked, for the same reason the switch
|
// Where the session works. Null until the server has been asked, for the same reason the switch
|
||||||
// above is. An empty answer is a session that was never given a directory, which is not the
|
// above is. An empty answer is a session that was never given a directory, which is not the
|
||||||
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
||||||
@@ -97,6 +125,9 @@ fun SessionSettingsDialog(
|
|||||||
try {
|
try {
|
||||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
||||||
notify = fresh.notify
|
notify = fresh.notify
|
||||||
|
autoResume = fresh.autoResume
|
||||||
|
resumeMessage = fresh.autoResumeMessage
|
||||||
|
resumeAt = fresh.resumeAt
|
||||||
cwd = fresh.cwd.orEmpty()
|
cwd = fresh.cwd.orEmpty()
|
||||||
typedCwd = fresh.cwd.orEmpty()
|
typedCwd = fresh.cwd.orEmpty()
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -104,6 +135,8 @@ fun SessionSettingsDialog(
|
|||||||
// instead of offering a position nothing confirmed.
|
// instead of offering a position nothing confirmed.
|
||||||
notifyError = e.message
|
notifyError = e.message
|
||||||
notify = null
|
notify = null
|
||||||
|
resumeError = e.message
|
||||||
|
autoResume = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +165,26 @@ fun SessionSettingsDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses a thinking level, which ends the process the old level was launched with.
|
||||||
|
*
|
||||||
|
* Put back if the request is refused, for the reason the notification switch below gives: a
|
||||||
|
* control that stays where it was put after a refusal is stating something untrue.
|
||||||
|
*/
|
||||||
|
fun setEffort(chosen: String?) {
|
||||||
|
val was = level
|
||||||
|
level = chosen
|
||||||
|
effortError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
level = was
|
||||||
|
effortError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
||||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
||||||
// and one that stays moved after a refusal lies.
|
// and one that stays moved after a refusal lies.
|
||||||
@@ -149,6 +202,39 @@ fun SessionSettingsDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns auto-resume on or off, or changes what it would say.
|
||||||
|
*
|
||||||
|
* One request for both, because the server takes one: switching it on and typing the message
|
||||||
|
* are two halves of the same decision, and sending them separately would leave a moment where
|
||||||
|
* the session is armed with the old words.
|
||||||
|
*
|
||||||
|
* Put back if refused, like the notification switch. Turning it off also clears what was
|
||||||
|
* scheduled -- said here rather than only on the server, or the row would go on naming a time
|
||||||
|
* that no longer exists.
|
||||||
|
*/
|
||||||
|
fun setAutoResume(on: Boolean, message: String) {
|
||||||
|
val wasOn = autoResume
|
||||||
|
val wasMessage = resumeMessage
|
||||||
|
val wasAt = resumeAt
|
||||||
|
autoResume = on
|
||||||
|
resumeMessage = message
|
||||||
|
if (!on) resumeAt = null
|
||||||
|
resumeError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
setSessionAutoResume(settings, sessionId, on, message)
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
autoResume = wasOn
|
||||||
|
resumeMessage = wasMessage
|
||||||
|
resumeAt = wasAt
|
||||||
|
resumeError = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
||||||
// request whose success would look exactly like the failure of having typed nothing.
|
// request whose success would look exactly like the failure of having typed nothing.
|
||||||
val changed = name.trim().isNotEmpty() && name.trim() != title
|
val changed = name.trim().isNotEmpty() && name.trim() != title
|
||||||
@@ -175,7 +261,10 @@ fun SessionSettingsDialog(
|
|||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Session settings") },
|
title = { Text("Session settings") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
|
||||||
|
// its own height and clips what does not fit, so the last control on the list is one
|
||||||
|
// large system font away from being unreachable with nothing on screen to say so.
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
@@ -219,6 +308,70 @@ fun SessionSettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
|
||||||
|
if (autoResume == null && resumeError == null) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = autoResume == true,
|
||||||
|
onCheckedChange = { setAutoResume(it, resumeMessage) },
|
||||||
|
enabled = autoResume != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Disabled rather than hidden while the switch is off: a field that comes and goes
|
||||||
|
// makes its own presence the signal, and a visible one teaches what the switch will
|
||||||
|
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
|
||||||
|
// sentence is one request instead of one per letter.
|
||||||
|
OutlinedTextField(
|
||||||
|
value = resumeMessage,
|
||||||
|
onValueChange = { resumeMessage = it },
|
||||||
|
label = { Text("Message to send") },
|
||||||
|
// What an empty field means, in the field: the server's own word rather than a
|
||||||
|
// session poked with nothing to read.
|
||||||
|
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = autoResume == true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
||||||
|
)
|
||||||
|
// What it does and what it costs, in the order it happens. The last sentence is the
|
||||||
|
// one that matters: the time below is when the server will *ask*, not a promise
|
||||||
|
// about when the session speaks.
|
||||||
|
Text(
|
||||||
|
"When this session stops because the account is out of quota, the server " +
|
||||||
|
"checks the limit and sends this message once it has lifted. It checks " +
|
||||||
|
"again if the limit is still on.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
// Only where something is actually waiting. Absent is not a state worth a row: a
|
||||||
|
// session that has not hit a limit has nothing scheduled, which the reader can see
|
||||||
|
// from the switch.
|
||||||
|
resumeAt?.let { at ->
|
||||||
|
Text(
|
||||||
|
"Waiting now -- next check ${formatCheckTime(at)}.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resumeError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -264,6 +417,44 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Left out rather than disabled, the one place this dialog does that: a disabled
|
||||||
|
// control teaches what the thing can do, and a llama session cannot do this at all
|
||||||
|
// -- the row would be teaching something false about it.
|
||||||
|
if (takesEffort) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Thinking", modifier = Modifier.weight(1f))
|
||||||
|
PickerButton(
|
||||||
|
current = level ?: DEFAULT_EFFORT,
|
||||||
|
// The level the CLI picks for itself is in the list as well as in the
|
||||||
|
// button, so leaving a level is not a one-way trip -- the same
|
||||||
|
// correction the model picker carries.
|
||||||
|
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||||
|
onPick = { chosen ->
|
||||||
|
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// What it costs, said where it is about to be pressed, like Move above: the
|
||||||
|
// CLI reads the level when it launches and has no control request for
|
||||||
|
// changing one.
|
||||||
|
Text(
|
||||||
|
"Changing this stops the session's process. It starts again with the " +
|
||||||
|
"next message, or with Start.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
effortError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -351,3 +542,21 @@ fun SessionSettingsDialog(
|
|||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the server will next look, as a local time.
|
||||||
|
*
|
||||||
|
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
|
||||||
|
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
|
||||||
|
* is looking at it.
|
||||||
|
*/
|
||||||
|
private fun formatCheckTime(epochSeconds: Double): String =
|
||||||
|
try {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// A time that cannot be read is not a time to show: the sentence above still says a check
|
||||||
|
// is coming, which is the part the reader can act on.
|
||||||
|
"soon"
|
||||||
|
}
|
||||||
@@ -70,12 +70,21 @@ class UsageFeed(
|
|||||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||||
val refresh: () -> Unit,
|
val refresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
/**
|
||||||
fun forSetup(setup: String): SessionUsage =
|
* What meters [session], and what that meter came back as. See [usageFor] for the states.
|
||||||
when (val state = snapshots) {
|
*
|
||||||
|
* A session rather than a machine, because a machine is not what is metered: one machine runs
|
||||||
|
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
|
||||||
|
*/
|
||||||
|
fun forSession(session: SessionSummary): SessionUsage {
|
||||||
|
// Settled without asking anybody: a session nothing meters has nothing to check, and
|
||||||
|
// "checking" is what the fetch's own states would say about it for as long as one is out.
|
||||||
|
val provider = session.usageProvider ?: return SessionUsage.NotMetered
|
||||||
|
return when (val state = snapshots) {
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
is LoadState.Loading -> SessionUsage.Waiting
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +167,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a
|
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||||
// problem about a setup somebody chose, on every screen, forever.
|
// a problem about a setup somebody chose, on every screen, forever.
|
||||||
if (usage is SessionUsage.NotMetered) {
|
//
|
||||||
|
// And nothing while the first fetch is out, which is a different silence. A request in flight
|
||||||
|
// is not a state to report -- and the session that meters nothing is exactly the one this
|
||||||
|
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
|
||||||
|
// second and was then taken away. A row that has to be withdrawn is worse than one that
|
||||||
|
// arrives late.
|
||||||
|
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +186,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
||||||
// answer from "this much is used", and only words carry a difference in kind.
|
// answer from "this much is used", and only words carry a difference in kind.
|
||||||
when (val state = usage) {
|
when (val state = usage) {
|
||||||
SessionUsage.NotMetered -> Unit
|
// Both handled above, before the row exists at all.
|
||||||
|
SessionUsage.NotMetered,
|
||||||
|
SessionUsage.Waiting -> Unit
|
||||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
||||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
|
||||||
is SessionUsage.Known -> {
|
is SessionUsage.Known -> {
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
val window = state.windows.firstOrNull { it.kind == "session" }
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
@@ -234,16 +250,22 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One machine's snapshot, out of every machine's.
|
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
|
||||||
|
*
|
||||||
|
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
|
||||||
|
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||||
|
* service on one machine.
|
||||||
*
|
*
|
||||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
||||||
* machine having no quota rather than the question going unanswered.
|
* machine having no quota rather than the question going unanswered.
|
||||||
*/
|
*/
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
|
||||||
// No snapshot at all means the backend never asked, which it only does for a machine with
|
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||||
// nothing metered on it. That is a different answer from having asked and failed.
|
// to ask about. That is a different answer from having asked and failed.
|
||||||
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
val mine =
|
||||||
|
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
|
||||||
|
?: return SessionUsage.NotMetered
|
||||||
if (mine.state != "ok") {
|
if (mine.state != "ok") {
|
||||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ private fun AddSetupDialog(
|
|||||||
var address by remember { mutableStateOf("") }
|
var address by remember { mutableStateOf("") }
|
||||||
var identity by remember { mutableStateOf("") }
|
var identity by remember { mutableStateOf("") }
|
||||||
var attachmentsDir by remember { mutableStateOf("") }
|
var attachmentsDir by remember { mutableStateOf("") }
|
||||||
|
var modelsDir by remember { mutableStateOf("") }
|
||||||
var tested by remember { mutableStateOf<String?>(null) }
|
var tested by remember { mutableStateOf<String?>(null) }
|
||||||
var testing by remember { mutableStateOf(false) }
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -250,6 +251,7 @@ private fun AddSetupDialog(
|
|||||||
port = typedPort,
|
port = typedPort,
|
||||||
identityFile = identity.trim().ifEmpty { null },
|
identityFile = identity.trim().ifEmpty { null },
|
||||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
||||||
|
modelsDir = modelsDir.trim().ifEmpty { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +295,14 @@ private fun AddSetupDialog(
|
|||||||
label = { Text("Folder for attached files (optional)") },
|
label = { Text("Folder for attached files (optional)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
|
||||||
|
// the same place this backend keeps its own downloads, read on that machine.
|
||||||
|
OutlinedTextField(
|
||||||
|
value = modelsDir,
|
||||||
|
onValueChange = { modelsDir = it },
|
||||||
|
label = { Text("Folder for models (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
tested?.let {
|
tested?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|||||||
@@ -61,18 +61,31 @@ fun SpawnScreen(
|
|||||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
||||||
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
var permissionMode by remember { mutableStateOf("auto") }
|
||||||
|
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
|
||||||
|
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
|
||||||
|
// arrives is one you can spawn at without having chosen it.
|
||||||
|
var effort by remember { mutableStateOf<String?>(null) }
|
||||||
|
var defaultsAsked by remember { mutableStateOf(false) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
||||||
// form worth keeping, and that one leaves nothing to fill in.
|
// form worth keeping, and that one leaves nothing to fill in.
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||||
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a
|
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
|
||||||
// Claude session needs none, so failing to list them must not stop the screen rendering.
|
// from the setups: a Claude session needs none, so failing to list them must not stop the
|
||||||
|
// screen rendering. Refetched when the machine changes, because a model is a file on one
|
||||||
|
// machine -- see [fetchSetupModels].
|
||||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||||
var contextSize by remember { mutableStateOf("") }
|
var contextSize by remember { mutableStateOf("") }
|
||||||
var temperature by remember { mutableStateOf("") }
|
var temperature by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
|
||||||
|
// default must leave a screen you can still spawn from, so the picker stays on "default"
|
||||||
|
// and says so rather than the whole form refusing to draw.
|
||||||
|
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
||||||
|
.onSuccess { effort = it }
|
||||||
|
defaultsAsked = true
|
||||||
options =
|
options =
|
||||||
try {
|
try {
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||||
@@ -83,9 +96,6 @@ fun SpawnScreen(
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
models =
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
@@ -115,6 +125,17 @@ fun SpawnScreen(
|
|||||||
is LoadState.Loaded -> state.value
|
is LoadState.Loaded -> state.value
|
||||||
}
|
}
|
||||||
val setup = setups.firstOrNull { it.name == setupName }
|
val setup = setups.firstOrNull { it.name == setupName }
|
||||||
|
// Whichever machine is chosen now, asked again when that changes. The old machine's list
|
||||||
|
// is dropped first rather than left on screen: a file name from another machine looks
|
||||||
|
// exactly like one from this one.
|
||||||
|
LaunchedEffect(setup?.id) {
|
||||||
|
models = emptyList()
|
||||||
|
modelKey = null
|
||||||
|
val id = setup?.id ?: return@LaunchedEffect
|
||||||
|
models =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
||||||
// Only the Claude CLI has models, a working directory and permission modes; keying the
|
// Only the Claude CLI has models, a working directory and permission modes; keying the
|
||||||
// extra fields on the kind rather than the provider name keeps a second Claude provider
|
// extra fields on the kind rather than the provider name keeps a second Claude provider
|
||||||
@@ -175,12 +196,13 @@ fun SpawnScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (isLlama) {
|
if (isLlama) {
|
||||||
// A llama session names one of the models this backend has downloaded, so the choice is
|
// A llama session names one of the models on the machine it will run on, so the
|
||||||
// that list rather than free text -- a name that is not on disk is a session that
|
// choice is that list rather than free text -- a name that is not on that machine's
|
||||||
// cannot start.
|
// disk is a session that cannot start.
|
||||||
if (models.isEmpty()) {
|
if (models.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No models downloaded yet. Get one from the Models screen first.",
|
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
|
||||||
|
"to the backend; another machine needs the file put there itself.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -251,6 +273,19 @@ fun SpawnScreen(
|
|||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
onSelect = { permissionMode = it },
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Says what it does to *later* spawns as well, because it does: the level chosen here
|
||||||
|
// is stored as the default, which is the whole way that default is set. A picker that
|
||||||
|
// quietly changed a global would be the same control with the fact left out.
|
||||||
|
ChipGroup(
|
||||||
|
label = "Thinking (kept as the default for new sessions)",
|
||||||
|
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||||
|
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
|
||||||
|
// Disabled-looking until the server has answered, for the reason above.
|
||||||
|
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
|
||||||
|
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -268,6 +303,13 @@ fun SpawnScreen(
|
|||||||
try {
|
try {
|
||||||
val spawned =
|
val spawned =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
// Stored before the spawn and not after it: choosing a level is
|
||||||
|
// an intent about new sessions in general, so a spawn that then
|
||||||
|
// fails must not also lose the choice. Non-fatal for the same
|
||||||
|
// reason the fetch above is -- the session is what was asked for.
|
||||||
|
if (isClaude) {
|
||||||
|
runCatching { setDefaultEffort(settings, effort) }
|
||||||
|
}
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
// The id, not the label: labels are editable and the server
|
// The id, not the label: labels are editable and the server
|
||||||
@@ -280,6 +322,7 @@ fun SpawnScreen(
|
|||||||
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { isClaude },
|
||||||
permissionMode = permissionMode.takeIf { isClaude },
|
permissionMode = permissionMode.takeIf { isClaude },
|
||||||
|
effort = effort.takeIf { isClaude },
|
||||||
// Sent only when set, so blank means "whatever llama.cpp does
|
// Sent only when set, so blank means "whatever llama.cpp does
|
||||||
// by default" rather than a zero.
|
// by default" rather than a zero.
|
||||||
params =
|
params =
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one transcript lives: a session's own, or one of its subagents'.
|
||||||
|
*
|
||||||
|
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
||||||
|
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
||||||
|
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
||||||
|
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||||
|
*/
|
||||||
|
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||||
|
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
|
||||||
|
val urlPath: String
|
||||||
|
get() =
|
||||||
|
if (subagentId == null) "sessions/$sessionId"
|
||||||
|
else "sessions/$sessionId/subagents/$subagentId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this transcript's cache lives on the phone, relative to the cache root.
|
||||||
|
*
|
||||||
|
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
|
||||||
|
* session's cache directory takes its subagents' with it -- the same one-way door the server's
|
||||||
|
* own storage describes.
|
||||||
|
*/
|
||||||
|
val cachePath: String
|
||||||
|
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
|
||||||
|
}
|
||||||
@@ -35,8 +35,15 @@ class TranscriptCache(
|
|||||||
private val root: File,
|
private val root: File,
|
||||||
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||||
) {
|
) {
|
||||||
/** The cache for one session, whether or not anything has been stored for it yet. */
|
/**
|
||||||
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
|
* The cache for one transcript, whether or not anything has been stored for it yet.
|
||||||
|
*
|
||||||
|
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
|
||||||
|
* deleting the session (below) takes its subagents' caches with it -- there is no separate
|
||||||
|
* purge for one.
|
||||||
|
*/
|
||||||
|
fun session(address: TranscriptAddress): SessionCache =
|
||||||
|
SessionCache(File(root, address.cachePath), warn)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes every session directory not in [ids], called after a successful list fetch. The path
|
* Deletes every session directory not in [ids], called after a successful list fetch. The path
|
||||||
|
|||||||
@@ -175,6 +175,18 @@ sealed class TranscriptItem {
|
|||||||
val preTokens: Long?,
|
val preTokens: Long?,
|
||||||
val postTokens: Long?,
|
val postTokens: Long?,
|
||||||
) : TranscriptItem()
|
) : TranscriptItem()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account ran out of quota, so the turn stopped here.
|
||||||
|
*
|
||||||
|
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
|
||||||
|
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
|
||||||
|
* line.
|
||||||
|
*
|
||||||
|
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
|
||||||
|
* row has words for rather than a time it invents.
|
||||||
|
*/
|
||||||
|
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,6 +470,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
||||||
}
|
}
|
||||||
|
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
|
||||||
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
||||||
is SessionEvent.Compacted ->
|
is SessionEvent.Compacted ->
|
||||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
|
|||||||
*/
|
*/
|
||||||
class TranscriptSource(
|
class TranscriptSource(
|
||||||
private val settings: ServerSettings,
|
private val settings: ServerSettings,
|
||||||
private val sessionId: String,
|
private val address: TranscriptAddress,
|
||||||
val cache: SessionCache,
|
val cache: SessionCache,
|
||||||
) {
|
) {
|
||||||
private val stream = AtomicReference<EventStream?>(null)
|
private val stream = AtomicReference<EventStream?>(null)
|
||||||
@@ -65,7 +65,7 @@ class TranscriptSource(
|
|||||||
val tail = cache.tail() ?: return false
|
val tail = cache.tail() ?: return false
|
||||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
||||||
// the cursor when the server still has one there.
|
// the cursor when the server still has one there.
|
||||||
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
|
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
|
||||||
val matches =
|
val matches =
|
||||||
answer.size == 1 &&
|
answer.size == 1 &&
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +83,7 @@ class TranscriptSource(
|
|||||||
*/
|
*/
|
||||||
suspend fun fetchOpening(): List<SeqEvent> {
|
suspend fun fetchOpening(): List<SeqEvent> {
|
||||||
DebugStats.count("transcript page from server")
|
DebugStats.count("transcript page from server")
|
||||||
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
|
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
|
||||||
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
||||||
cache.flush()
|
cache.flush()
|
||||||
return page.map { it.second }
|
return page.map { it.second }
|
||||||
@@ -108,7 +108,7 @@ class TranscriptSource(
|
|||||||
val page =
|
val page =
|
||||||
fetchTranscript(
|
fetchTranscript(
|
||||||
settings,
|
settings,
|
||||||
sessionId,
|
address,
|
||||||
before = before,
|
before = before,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
coalesce = coalesce,
|
coalesce = coalesce,
|
||||||
@@ -131,7 +131,7 @@ class TranscriptSource(
|
|||||||
* well lose.
|
* well lose.
|
||||||
*/
|
*/
|
||||||
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||||
val opened = EventStream(settings, sessionId)
|
val opened = EventStream(settings, address)
|
||||||
stream.getAndSet(opened)?.close()
|
stream.getAndSet(opened)?.close()
|
||||||
try {
|
try {
|
||||||
opened.run(after, onOpen, onReset) { raw, entry ->
|
opened.run(after, onOpen, onReset) { raw, entry ->
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the transcript says where a session ran out of quota.
|
||||||
|
*
|
||||||
|
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
|
||||||
|
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
|
||||||
|
* reader has no way of telling an invented one from a reported one.
|
||||||
|
*/
|
||||||
|
class LimitRowTest {
|
||||||
|
private val utc = ZoneId.of("UTC")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reported reset time is shown as a time`() {
|
||||||
|
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
|
||||||
|
// whole string: the platform's own short-time format is what this asks for, and it
|
||||||
|
// differs by JDK and locale down to which space character separates the meridiem.
|
||||||
|
val summary = limitSummary(1_788_609_600.0, utc)
|
||||||
|
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
|
||||||
|
assertTrue(summary.contains("12:00"), summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a limit with no reset time says only what is known`() {
|
||||||
|
assertEquals("Usage limit reached", limitSummary(null, utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
|
|||||||
|
|
||||||
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
||||||
|
|
||||||
private fun session(id: String = "s") = cache().session(id)
|
private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
|
||||||
|
|
||||||
private fun line(seq: Long, type: String = "toolStart") =
|
private fun line(seq: Long, type: String = "toolStart") =
|
||||||
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -173,6 +174,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -425,6 +435,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -661,12 +690,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
event-model = { path = "../event-model" }
|
event-model = { path = "../event-model" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
# "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
|
||||||
|
# bytes the server sent, not this crate's own re-serialization of a parsed
|
||||||
|
# `Value`, so a cached line and a live SSE frame for the same event agree
|
||||||
|
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
|
||||||
|
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||||
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
||||||
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
||||||
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||||
@@ -27,6 +32,12 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
|
|||||||
# no need of an async runtime, and RUST.md's brief for this port is
|
# no need of an async runtime, and RUST.md's brief for this port is
|
||||||
# "lightweight" throughout.
|
# "lightweight" throughout.
|
||||||
ureq = { version = "3", features = ["json"] }
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
# The markdown block split (`markdown_blocks`), which has to agree with the
|
||||||
|
# renderer in `iris/transcript-ui` about where a block begins -- so it is
|
||||||
|
# the same parser at the same version, rather than a hand-written splitter
|
||||||
|
# that would drift from it.
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -116,6 +117,14 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
Self { transport }
|
Self { transport }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The transport underneath, for a caller that needs the raw SSE
|
||||||
|
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||||
|
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||||
|
/// is the one that does.
|
||||||
|
pub fn transport(&self) -> &T {
|
||||||
|
&self.transport
|
||||||
|
}
|
||||||
|
|
||||||
fn json_request<R: for<'de> Deserialize<'de>>(
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -266,6 +275,61 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
) -> Result<Vec<Value>, ApiError> {
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
|
self.json_request(
|
||||||
|
"GET",
|
||||||
|
&transcript_path(session_id, before, limit, coalesce, None),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of transcript history, each line handed back paired with the
|
||||||
|
/// exact text it came from, and bounded below by `after` -- the shape
|
||||||
|
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
||||||
|
/// fetched in the transcript cache without a second round trip to fetch
|
||||||
|
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||||
|
///
|
||||||
|
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||||
|
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||||
|
/// sent (key order and float literal included) rather than this
|
||||||
|
/// crate's own idea of how to write them back out -- the cache and a
|
||||||
|
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||||
|
/// exactly what caught the `serde_json` float-rounding bug this
|
||||||
|
/// project's `AGENTS.md` records.
|
||||||
|
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 query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||||
|
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||||
|
/// parameter is written rather than keeping two copies to drift.
|
||||||
|
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}");
|
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||||
if let Some(before) = before {
|
if let Some(before) = before {
|
||||||
path.push_str(&format!("&before={before}"));
|
path.push_str(&format!("&before={before}"));
|
||||||
@@ -273,8 +337,10 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
if coalesce {
|
if coalesce {
|
||||||
path.push_str("&coalesce=true");
|
path.push_str("&coalesce=true");
|
||||||
}
|
}
|
||||||
self.json_request("GET", &path, None)
|
if let Some(after) = after {
|
||||||
|
path.push_str(&format!("&after={after}"));
|
||||||
}
|
}
|
||||||
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||||
|
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||||
|
//!
|
||||||
|
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||||
|
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||||
|
//! bar, and nothing in this crate needs it yet.
|
||||||
|
|
||||||
|
/// A span of milliseconds, written the way somebody reads it.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||||
|
/// is measured in milliseconds.
|
||||||
|
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(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as a span when it is a whole number of milliseconds, and
|
||||||
|
/// unchanged when it is not.
|
||||||
|
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::*;
|
||||||
|
|
||||||
|
/// The two ways a span of time is written here, and the rule each of
|
||||||
|
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||||
|
/// both are read off a screen to make a decision, so what matters is
|
||||||
|
/// that the shortest form that answers the question is what appears.
|
||||||
|
#[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");
|
||||||
|
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||||
|
// half than two and four.
|
||||||
|
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() {
|
||||||
|
// The figure this rule was written for: a tool timeout, which
|
||||||
|
// arrives as milliseconds and is unreadable as 480000.
|
||||||
|
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");
|
||||||
|
// Empty units are left out rather than written as zero: the labels
|
||||||
|
// say which is which, and "5d 0h 4m" is only longer.
|
||||||
|
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");
|
||||||
|
// A timeout a tool expressed some other way is its own words,
|
||||||
|
// passed through rather than guessed at.
|
||||||
|
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||||
|
assert_eq!(format_millis_text(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,15 @@
|
|||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod tool_summary;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
|
pub mod transcript_source;
|
||||||
|
|
||||||
pub use event_model::*;
|
pub use event_model::*;
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
//! Split a markdown message into its top-level **blocks** -- one
|
||||||
|
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||||
|
//! byte slice of the original source.
|
||||||
|
//!
|
||||||
|
//! This exists for streaming. A transcript row used to be one text widget
|
||||||
|
//! holding the whole message, so a single streamed delta re-shaped every
|
||||||
|
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||||
|
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||||
|
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||||
|
//! block now, and a delta that lands in the last block leaves every
|
||||||
|
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
|
||||||
|
//! what that rejected and why the split lives here rather than in the UI
|
||||||
|
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||||
|
//! keeping it here means iris stays a text renderer that knows nothing
|
||||||
|
//! about markdown.
|
||||||
|
//!
|
||||||
|
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||||
|
//! renderer's own job, per block -- this deliberately does not build a
|
||||||
|
//! full AST, because nothing needs one yet.
|
||||||
|
//!
|
||||||
|
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||||
|
//!
|
||||||
|
//! It nearly always does, which is what makes the fast path worth having,
|
||||||
|
//! but markdown has no such rule: appending a "```" line can turn text
|
||||||
|
//! that was three paragraphs into one fenced block, and appending "---"
|
||||||
|
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||||
|
//! taking the O(last block) path **must compare the prefix it is about to
|
||||||
|
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||||
|
//! it is cheap next to laying the text out again.
|
||||||
|
|
||||||
|
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,
|
||||||
|
/// A fenced or indented code block.
|
||||||
|
Code,
|
||||||
|
List,
|
||||||
|
Table,
|
||||||
|
Quote,
|
||||||
|
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||||
|
/// distinguished treatment here.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One top-level block: its kind and the exact source that produced it.
|
||||||
|
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||||
|
/// two splits of the same prefix compare equal even when one of them had a
|
||||||
|
/// delta arriving after it.
|
||||||
|
#[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 {
|
||||||
|
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||||
|
// boundary here and the styling there cannot disagree about what the
|
||||||
|
// source means.
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||||
|
/// whitespace-only input gives no blocks; text the parser does not put
|
||||||
|
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||||
|
/// as `Other`, rather than being dropped.
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A top-level event that is not part of any block -- a
|
||||||
|
// thematic break, a block of raw HTML. Inside one, it is the
|
||||||
|
// enclosing block's business and this does nothing.
|
||||||
|
_ => {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the streaming fast path rests on, in its ordinary
|
||||||
|
/// shape: a delta landing in the last paragraph must leave every
|
||||||
|
/// earlier block byte-identical.
|
||||||
|
#[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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that starts a *new* block keeps every old block, including
|
||||||
|
/// the one that was last -- so the fast path appends rather than
|
||||||
|
/// replacing.
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code fence arrives one delta at a time and is unterminated for
|
||||||
|
/// most of its life. It must still be *one* block the whole way, or
|
||||||
|
/// every delta would re-split the message into a different number of
|
||||||
|
/// pieces.
|
||||||
|
#[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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the fast path had no reason to touch, and the reason
|
||||||
|
/// `common_prefix` is a comparison rather than an assumption:
|
||||||
|
/// appending can rewrite what came before. `---` under a paragraph
|
||||||
|
/// turns that paragraph into a setext heading, so the block that was
|
||||||
|
/// already laid out is not the block it is now.
|
||||||
|
#[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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shapes a real transcript actually contains, each checked for
|
||||||
|
/// the one property the streaming fast path needs: the *number* of
|
||||||
|
/// blocks and every earlier block's source stay put while the message
|
||||||
|
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||||
|
/// list and a table are all places where a naive line-based split
|
||||||
|
/// would break the message into more pieces than there are blocks.
|
||||||
|
#[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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apply_delta`'s precondition, stated as the property rather than
|
||||||
|
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||||
|
/// the blocks before the last one must be exactly the blocks the
|
||||||
|
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||||
|
/// above), `common_prefix` has to *say* so -- which is what the
|
||||||
|
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||||
|
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||||
|
/// would keep a widget whose text is no longer what it holds.
|
||||||
|
#[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.";
|
||||||
|
// Every character boundary, so a delta landing mid-word and one
|
||||||
|
// landing exactly on a fence's closing backtick are both covered.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half a growing message cannot show: a fence that never closes.
|
||||||
|
/// The stream ends there and the block must still be the code block
|
||||||
|
/// it has been all along, not re-split into paragraphs.
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that closes a fence changes the *last* block only, so the
|
||||||
|
/// fast path takes it -- the case the module doc says is the reason
|
||||||
|
/// `common_prefix` is a comparison.
|
||||||
|
#[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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! A tool call's input, read rather than dumped -- the port of
|
||||||
|
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||||
|
//! card's one-line summary and the expanded card's key/value list are
|
||||||
|
//! derived from.
|
||||||
|
//!
|
||||||
|
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||||
|
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||||
|
//! line they care about. So the fields that carry the meaning are pulled
|
||||||
|
//! out, and anything left over is still shown, because dropping a field
|
||||||
|
//! would be claiming the tool has no other input when it might.
|
||||||
|
//!
|
||||||
|
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||||
|
//! of this crate exists: the derivation is the same on a phone and on a
|
||||||
|
//! desktop, and it is testable without a renderer.
|
||||||
|
|
||||||
|
use crate::durations::format_millis_text;
|
||||||
|
use crate::highlight::Language;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A tool call's input, split into the parts a card draws separately.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ToolInput {
|
||||||
|
/// The thing that will actually be run or read, if this tool has one.
|
||||||
|
pub subject: Option<String>,
|
||||||
|
/// The language [`ToolInput::subject`] is written in, for
|
||||||
|
/// highlighting.
|
||||||
|
pub language: Option<Language>,
|
||||||
|
/// The tool's own one-line summary, when it wrote one.
|
||||||
|
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 {
|
||||||
|
/// The one line to show when there is only room for one: what this
|
||||||
|
/// call is for.
|
||||||
|
pub fn title(&self) -> Option<&str> {
|
||||||
|
self.description
|
||||||
|
.as_deref()
|
||||||
|
.or(self.subject.as_deref())
|
||||||
|
// A subject that is only whitespace would draw as an empty
|
||||||
|
// summary line, which reads as a tool with nothing to say
|
||||||
|
// rather than as one whose subject was blank.
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which field of which tool is the subject.
|
||||||
|
///
|
||||||
|
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||||
|
/// shape stops any of them from being the special case that gets its own
|
||||||
|
/// code path. Unknown tools fall through to "no subject, everything is
|
||||||
|
/// rest".
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Fields that are the tool's own prose about itself rather than input to
|
||||||
|
/// it.
|
||||||
|
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||||
|
|
||||||
|
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||||
|
/// string is its own characters, anything else is its JSON form.
|
||||||
|
///
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||||
|
///
|
||||||
|
/// Input that is not a JSON object -- older transcripts and some tools
|
||||||
|
/// send a bare string -- is still the input, so it is still shown, as the
|
||||||
|
/// whole of `rest`.
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Sorted, so the leftovers are in the same order every time this call
|
||||||
|
// is drawn rather than in whatever order the JSON happened to arrive
|
||||||
|
// in. A field is left out only when it is already drawn somewhere
|
||||||
|
// else on the card.
|
||||||
|
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() {
|
||||||
|
// One assertion per row of `SUBJECTS`, because the table is the
|
||||||
|
// whole of the rule and a row lost in an edit would otherwise
|
||||||
|
// only show up as a card with no summary line.
|
||||||
|
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() {
|
||||||
|
// The description wins over the subject: it is the tool's own
|
||||||
|
// prose about what this call is for, which is what a reader
|
||||||
|
// scanning a collapsed run is looking for.
|
||||||
|
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() {
|
||||||
|
// The half the "never dropped" promise is about: a tool this
|
||||||
|
// build has never heard of has no subject, so *everything* is
|
||||||
|
// rest -- and a known tool's extra fields are too.
|
||||||
|
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() {
|
||||||
|
// Older transcripts and some tools send a bare string; a card
|
||||||
|
// that dropped it would claim the call had no input at all.
|
||||||
|
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);
|
||||||
|
// Not dropped just because it was blank -- it is still a field
|
||||||
|
// the call carried.
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,6 +361,10 @@ impl SessionCache {
|
|||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
debug_assert!(
|
||||||
|
lines.iter().all(|l| !l.contains('\n')),
|
||||||
|
"a stored page's lines must each be one line"
|
||||||
|
);
|
||||||
fs::create_dir_all(&this.dir)?;
|
fs::create_dir_all(&this.dir)?;
|
||||||
let kind = if rows { "rows" } else { "raw" };
|
let kind = if rows { "rows" } else { "raw" };
|
||||||
let mut content = lines.join("\n");
|
let mut content = lines.join("\n");
|
||||||
@@ -389,8 +393,16 @@ impl SessionCache {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Written as it arrived. A newline inside it would split one
|
// Written as it arrived. A newline inside it would split one
|
||||||
// event into two unreadable halves, but neither source can
|
// event into two unreadable halves. No source here can produce
|
||||||
// produce one.
|
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
||||||
|
// fetched line is one element of a compact JSON array -- but
|
||||||
|
// that is a fact about the *server's* serializer rather than
|
||||||
|
// anything this file controls, so it is checked rather than
|
||||||
|
// trusted.
|
||||||
|
debug_assert!(
|
||||||
|
!line.contains('\n'),
|
||||||
|
"a cached transcript line must be one line: {line}"
|
||||||
|
);
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
writer.write_all(line.as_bytes())?;
|
writer.write_all(line.as_bytes())?;
|
||||||
writer.write_all(b"\n")?;
|
writer.write_all(b"\n")?;
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ pub enum TranscriptItem {
|
|||||||
input: String,
|
input: String,
|
||||||
output: String,
|
output: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
/// Whether the result that arrived said the call failed
|
||||||
|
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||||
|
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||||
|
/// pair, so the two cannot be combined wrongly at a call site.
|
||||||
|
failed: bool,
|
||||||
asks: Vec<QuestionCard>,
|
asks: Vec<QuestionCard>,
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -112,6 +117,13 @@ pub enum TranscriptItem {
|
|||||||
ClearedNote {
|
ClearedNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
},
|
},
|
||||||
|
/// The account's usage limit stopped the turn; `resets_at` is epoch
|
||||||
|
/// seconds when the dialect said when it lifts (`LimitNote` in
|
||||||
|
/// `TranscriptItems.kt`).
|
||||||
|
LimitNote {
|
||||||
|
seq: u64,
|
||||||
|
resets_at: Option<f64>,
|
||||||
|
},
|
||||||
CompactedNote {
|
CompactedNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
pre_tokens: Option<u64>,
|
pre_tokens: Option<u64>,
|
||||||
@@ -131,6 +143,7 @@ impl TranscriptItem {
|
|||||||
| Self::CommandRow { seq, .. }
|
| Self::CommandRow { seq, .. }
|
||||||
| Self::Note { seq, .. }
|
| Self::Note { seq, .. }
|
||||||
| Self::ClearedNote { seq }
|
| Self::ClearedNote { seq }
|
||||||
|
| Self::LimitNote { seq, .. }
|
||||||
| Self::CompactedNote { seq, .. } => *seq,
|
| Self::CompactedNote { seq, .. } => *seq,
|
||||||
Self::QuestionCard(card) => card.seq,
|
Self::QuestionCard(card) => card.seq,
|
||||||
}
|
}
|
||||||
@@ -286,6 +299,201 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts a page of older items in front of the ones already loaded, healing
|
||||||
|
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
|
||||||
|
/// `joinPages`.
|
||||||
|
///
|
||||||
|
/// Two things straddle a boundary: a tool call separated from its result,
|
||||||
|
/// and a message separated from the rest of itself. Both were one thing
|
||||||
|
/// before the transcript was cut into pages.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// The third thing is the *run*, and it is the one the Kotlin original used
|
||||||
|
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
|
||||||
|
/// here, but `adopt_run` must run on *every* join, not only the one where a
|
||||||
|
/// split call was found -- a boundary landing cleanly between two finished
|
||||||
|
/// calls, which is most of them, would otherwise leave the older page's
|
||||||
|
/// calls under the run name they were folded with. On screen: one run of
|
||||||
|
/// tool calls drawn as two groups, with the seam wherever the reader
|
||||||
|
/// happened to have paged.
|
||||||
|
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
|
let (older, newer) = heal_split_message(earlier, later);
|
||||||
|
let started_earlier: std::collections::HashSet<&str> = older
|
||||||
|
.iter()
|
||||||
|
.filter_map(TranscriptItem::as_tool_run)
|
||||||
|
.collect();
|
||||||
|
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
|
||||||
|
// value, and a map borrowing it would keep that alive.
|
||||||
|
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||||
|
.filter(|(id, _)| started_earlier.contains(id.as_str()))
|
||||||
|
.collect();
|
||||||
|
let healed: Vec<TranscriptItem> = older
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| match row {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
asks: row_asks,
|
||||||
|
images: row_images,
|
||||||
|
..
|
||||||
|
} if ended_later.contains_key(id.as_str()) => {
|
||||||
|
let &TranscriptItem::ToolRun {
|
||||||
|
ref output,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
asks: ref half_asks,
|
||||||
|
images: ref half_images,
|
||||||
|
..
|
||||||
|
} = &ended_later[id.as_str()]
|
||||||
|
else {
|
||||||
|
unreachable!("filtered to ToolRun above");
|
||||||
|
};
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output: output.clone(),
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
// Kept from both halves: a question or an image can be
|
||||||
|
// attached to either, depending on which side of the
|
||||||
|
// boundary its event fell.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejoins a message the page boundary cut, and hands back the two pages to
|
||||||
|
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
|
||||||
|
///
|
||||||
|
/// `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.
|
||||||
|
///
|
||||||
|
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
|
||||||
|
/// gives. It grows by what the older half brings, which is safe here and
|
||||||
|
/// nowhere else -- the join is at the oldest end of what is loaded, so the
|
||||||
|
/// growth extends off the top of the screen.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the older calls at the join the name of the run they are joining.
|
||||||
|
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
|
||||||
|
///
|
||||||
|
/// The two pages were folded separately, so a run split by the boundary
|
||||||
|
/// came back as two runs with two names. Naming the joined run after the
|
||||||
|
/// *older* half would be the obvious way round and is wrong: the newer half
|
||||||
|
/// is the part already on screen, and renaming it is renaming the row the
|
||||||
|
/// reader is looking at, which is how a list loses its anchor.
|
||||||
|
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
|
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||||
|
return earlier.to_vec();
|
||||||
|
};
|
||||||
|
// A question is in a run of its own on both sides of the join, the same as it would be
|
||||||
|
// had the two pages been folded as one. Without this the heal would merge a group
|
||||||
|
// straight through the row the reader was asked something on.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||||
@@ -361,6 +569,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -371,15 +580,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Event::ToolEnd { id, output } => {
|
Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
update_tool(items, id, |item| {
|
update_tool(items, id, |item| {
|
||||||
if let TranscriptItem::ToolRun {
|
if let TranscriptItem::ToolRun {
|
||||||
output: out, done, ..
|
output: out,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
*done = true;
|
*done = true;
|
||||||
|
*failed = *is_error;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -393,6 +610,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: *is_error,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -449,6 +667,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
images,
|
images,
|
||||||
} if asks.iter().any(|a| &a.id == id) => {
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
for ask in asks.iter_mut() {
|
for ask in asks.iter_mut() {
|
||||||
@@ -464,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -525,6 +745,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
items.push(TranscriptItem::ClearedNote { seq });
|
items.push(TranscriptItem::ClearedNote { seq });
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
Event::LimitReached { resets_at } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::LimitNote {
|
||||||
|
seq,
|
||||||
|
resets_at: *resets_at,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
Event::Compacted {
|
Event::Compacted {
|
||||||
pre_tokens,
|
pre_tokens,
|
||||||
post_tokens,
|
post_tokens,
|
||||||
@@ -541,6 +769,74 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What became of one tool call -- every state a card has to be able to
|
||||||
|
/// draw, including the two that are not answers.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
/// Started, no result yet, and the session is still working -- the
|
||||||
|
/// ordinary state of a call in flight.
|
||||||
|
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,
|
||||||
|
/// A result arrived and the tool did not report a failure.
|
||||||
|
Succeeded,
|
||||||
|
/// A result arrived and the tool reported that the call failed
|
||||||
|
/// (`is_error`).
|
||||||
|
Failed,
|
||||||
|
/// No result ever arrived and the session is not working any more --
|
||||||
|
/// the turn was interrupted, or the process went away. Not a verdict
|
||||||
|
/// on the call: it says only that nobody found out.
|
||||||
|
NoResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolState {
|
||||||
|
/// The state of one call. `session_working` is
|
||||||
|
/// [`session_working`]'s answer for the session this call is in --
|
||||||
|
/// the only thing here that is not a property of the call itself, and
|
||||||
|
/// what separates "still running" from "never came back".
|
||||||
|
///
|
||||||
|
/// Written once, over the fields rather than per call site, because
|
||||||
|
/// the five states are decided by four conditions and every place
|
||||||
|
/// that re-derived a subset of them got a different subset.
|
||||||
|
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()) {
|
||||||
|
// Ahead of `done`: a call waiting on permission has not
|
||||||
|
// finished either, and which of the two the reader is being
|
||||||
|
// told about is the one they can act on.
|
||||||
|
Self::Deciding
|
||||||
|
} else if !*done {
|
||||||
|
match session_working {
|
||||||
|
true => Self::Running,
|
||||||
|
false => Self::NoResult,
|
||||||
|
}
|
||||||
|
} else if *failed {
|
||||||
|
Self::Failed
|
||||||
|
} else {
|
||||||
|
Self::Succeeded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||||
@@ -780,6 +1076,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "x".to_string(),
|
id: "x".to_string(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -792,6 +1089,7 @@ mod tests {
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}]
|
}]
|
||||||
@@ -939,4 +1237,284 @@ mod tests {
|
|||||||
let err = fold_page(&values).unwrap_err();
|
let err = fold_page(&values).unwrap_err();
|
||||||
assert!(err.contains("couldn't parse"));
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AGENTS.md's "things that have bitten": `joinPages` used to run
|
||||||
|
/// `adoptRun` only on the path where a *split* call was found, so a
|
||||||
|
/// boundary landing cleanly between two already-finished calls -- most
|
||||||
|
/// of them -- left the older page's calls under the run name they were
|
||||||
|
/// folded with, drawing one run of tool calls as two groups. Two
|
||||||
|
/// finished, unrelated calls (no id in common) must still end up under
|
||||||
|
/// one run name after the join.
|
||||||
|
#[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(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A question is in a run of its own on both sides of a join -- healing
|
||||||
|
/// must never rename the run of calls the reader was asked something
|
||||||
|
/// on, the same rule `splitRun` enforces for a live turn boundary.
|
||||||
|
#[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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`ToolState`] is what a card colours itself by, so each of its five
|
||||||
|
/// states is asserted from the events that actually produce it rather than
|
||||||
|
/// from a hand-built item -- a mapping that agreed with a fixture and
|
||||||
|
/// disagreed with the fold would be invisible until it was on screen.
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pair this enum exists for. Both calls have an empty `output`
|
||||||
|
/// and nothing else distinguishes them, so a card that only looked at
|
||||||
|
/// the text would draw the interrupted one as a call that ran fine and
|
||||||
|
/// printed nothing.
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same call, mid-turn: still running rather than abandoned. The
|
||||||
|
/// only thing separating the two is the session's own status, which is
|
||||||
|
/// why `of` takes it.
|
||||||
|
#[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()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Ahead of both "still running" and "no result": the reader can
|
||||||
|
// act on this one, and cannot act on either of those.
|
||||||
|
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,588 @@
|
|||||||
|
//! Where a session screen gets a transcript from: this phone's copy first,
|
||||||
|
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
||||||
|
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
||||||
|
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
||||||
|
//!
|
||||||
|
//! One seam rather than a cache the screen has to remember to consult.
|
||||||
|
//! Everything fetched before is asked of this, and everything the server
|
||||||
|
//! sends is written into the cache on the way past, so a caller never
|
||||||
|
//! learns which side answered. The one rule worth keeping in mind: the
|
||||||
|
//! cache is never load-bearing. Every read here has a network path beside
|
||||||
|
//! it producing the same result.
|
||||||
|
//!
|
||||||
|
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
||||||
|
//! ability to close a live stream from another thread. Both are wall-clock
|
||||||
|
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
||||||
|
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
||||||
|
//! scope) rather than to this pure logic -- `follow` below is the same
|
||||||
|
//! decorator shape `iris/desktop-app/src/app.rs` and
|
||||||
|
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
||||||
|
//! `event_stream::follow_session_events`, just with the cache write built
|
||||||
|
//! in so a future caller does not have to repeat it a third time.
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
|
use crate::api::{ApiClient, ApiError, Transport};
|
||||||
|
use crate::event_stream::{self, StreamItem};
|
||||||
|
use crate::transcript_cache::SessionCache;
|
||||||
|
|
||||||
|
/// How many events a session screen opens with, cached or fetched.
|
||||||
|
///
|
||||||
|
/// 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 {}
|
||||||
|
|
||||||
|
/// Either half of what can go wrong asking for a page: the network, or a
|
||||||
|
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
||||||
|
/// read.
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||||
|
/// one possibly-empty list.
|
||||||
|
///
|
||||||
|
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||||
|
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||||
|
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||||
|
/// question could not be asked yet, which it must not. Collapsing the two
|
||||||
|
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||||
|
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||||
|
/// same silently.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum OlderPage {
|
||||||
|
/// The events before the cursor, oldest first. Empty means the start
|
||||||
|
/// of the conversation has been reached.
|
||||||
|
Events(Vec<SeqEvent>),
|
||||||
|
/// Nothing is loaded, so there was no cursor to page back from
|
||||||
|
/// (`before == 0`). Not an answer about the conversation at all.
|
||||||
|
NothingLoaded,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||||
|
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This phone's copy of one session's transcript, plus the server it
|
||||||
|
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
||||||
|
/// whole point of the feature: the rows are on screen while the check
|
||||||
|
/// that they are still the server's rows is in flight, and a failed
|
||||||
|
/// check replaces them exactly as a reset does.
|
||||||
|
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),
|
||||||
|
// A line this build cannot read at all, which the cache's own checks cannot
|
||||||
|
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
||||||
|
// open.
|
||||||
|
Err(ParseError(_)) => {
|
||||||
|
self.cache.purge();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server's event at the cached cursor is still the cached
|
||||||
|
/// one.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// What this cannot see is a line changed in the middle of the file
|
||||||
|
/// with the tail intact -- that is what a full reload is for.
|
||||||
|
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||||
|
let Some(tail) = self.cache.tail() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
||||||
|
// event *at* the cursor when the server still has one there.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's opening fetch, kept as the start of the live run. Only
|
||||||
|
/// called when the cache has nothing to open with, or when
|
||||||
|
/// [`Self::probe`] said what it had was not the server's.
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||||
|
/// coalesced page reaches back as far as its row count takes it -- a
|
||||||
|
/// single reply is hundreds of lines -- so a page fetched after the
|
||||||
|
/// reader has been away could run straight past the cached run and
|
||||||
|
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||||
|
/// this phone's copy starts, the server stops there instead.
|
||||||
|
///
|
||||||
|
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||||
|
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||||
|
/// have bitten": there is no event before the first one, so the
|
||||||
|
/// request is not a harmless no-op, and its empty answer is
|
||||||
|
/// indistinguishable from having reached the start of history.
|
||||||
|
/// Guarded here rather than left to every caller, because it is a fact
|
||||||
|
/// about the question, not about who is asking it.
|
||||||
|
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() {
|
||||||
|
// `before` rather than the newest line's seq: a coalesced page covers
|
||||||
|
// everything up to the cursor it was asked with, and nothing in its lines
|
||||||
|
// says so.
|
||||||
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`event_stream::follow_session_events`], with every frame written to
|
||||||
|
/// the cache before `on_item` sees it.
|
||||||
|
///
|
||||||
|
/// 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::api::{Body, RawResponse};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// A transport that answers fixed bodies in call order, and records
|
||||||
|
/// every path it was asked for -- so a test can assert *how many*
|
||||||
|
/// requests a method made, which is the point for the `before == 0`
|
||||||
|
/// guard (AGENTS.md's regression: the guard must stop the request
|
||||||
|
/// before it happens, not merely tolerate the empty answer).
|
||||||
|
#[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::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);
|
||||||
|
// The fetch wrote through: reopening the same cache now has something to show.
|
||||||
|
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::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();
|
||||||
|
|
||||||
|
// The server now answers with a different event at the same seq -- the file
|
||||||
|
// behind this session was replaced.
|
||||||
|
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::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::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression this module exists to close: `before == 0` must
|
||||||
|
/// never reach the network or the cache, because an empty answer there
|
||||||
|
/// is indistinguishable from "there is genuinely no more history" --
|
||||||
|
/// AGENTS.md's `loadOlderPage` incident.
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With nothing older cached there is no floor to give the server, so
|
||||||
|
/// the request carries no `after` at all.
|
||||||
|
#[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::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the test above cannot show: when the cache *does* hold an
|
||||||
|
/// older run, the fetch is floored at its end, or the page would run
|
||||||
|
/// straight past it and overlap -- which `store_page` then refuses,
|
||||||
|
/// silently costing the phone the page it just paid for.
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||||
|
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||||
|
// cache reads the directory as damaged and discards it.
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page the server could not answer is an error, never an empty
|
||||||
|
/// page: the caller would read the second as "this conversation has no
|
||||||
|
/// more history" and stop paging for good.
|
||||||
|
#[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(_)),));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cached line this build cannot read is told apart from the network
|
||||||
|
/// failing, for the same reason: neither is "no more history".
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::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::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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,16 +25,19 @@ next (a Masonry or iris transcript screen, most likely).
|
|||||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
| `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 |
|
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
|
||||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
| `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
|
||||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
| *(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`,
|
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||||
cases ported alongside it, plus new tests for the pieces that had none
|
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
|
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
|
||||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
`transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
|
||||||
|
had no JVM unit tests of their own, so these were written fresh against the
|
||||||
|
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
|
||||||
|
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
|
||||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||||
them via `session::transcript`'s round-trip coverage).
|
them via `session::transcript`'s round-trip coverage).
|
||||||
|
|
||||||
@@ -88,13 +91,27 @@ the full table to work from when one of these is next.
|
|||||||
including tool-call/question/image attachment and peer-message placement.
|
including tool-call/question/image attachment and peer-message placement.
|
||||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||||
|
|
||||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
|
||||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
|
||||||
merges a tool call split across two fetched pages and re-merges a run a
|
call split across two fetched pages, rejoins a message a boundary cut
|
||||||
boundary cut through. This matters the moment paging backward through
|
through, and renames a run of tool calls onto whichever name is already on
|
||||||
history is exercised; it is deliberately left rather than rushed, since
|
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
|
||||||
it is exactly the kind of boundary logic this project's own "things that
|
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
|
||||||
have bitten" section warns reads fine and is wrong at the edges.
|
own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||||||
|
is the regression test for the bug that shipped -- `adopt_run` must run on
|
||||||
|
*every* join, not only the one where a split call was found, or a boundary
|
||||||
|
landing cleanly between two already-finished calls (most of them) leaves
|
||||||
|
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
||||||
|
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
||||||
|
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
||||||
|
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||||
|
that no tool id survives in both halves -- the duplicate row it exists to
|
||||||
|
prevent, checked rather than assumed. What it deliberately does *not*
|
||||||
|
assert is seq ordering across the boundary: a peer note carries the seq
|
||||||
|
its turn began at (`place_peer_note`), which can be older than the page
|
||||||
|
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||||
|
earlier draft asserted it and would have panicked in debug builds on an
|
||||||
|
ordinary transcript.
|
||||||
|
|
||||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
**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.
|
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||||
@@ -119,20 +136,72 @@ 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
|
caller today is `desktop-app`; a future Android build of this crate would
|
||||||
be a second one, not a reason to move the type.
|
be a second one, not a reason to move the type.
|
||||||
|
|
||||||
|
## What `transcript_source.rs` covers, and what it does not
|
||||||
|
|
||||||
|
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
|
||||||
|
page, ported test-for-test against the Kotlin doc rather than a JVM test
|
||||||
|
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
|
||||||
|
`page` and `follow`, each matching its Kotlin namesake's contract --
|
||||||
|
including `probe`'s three-way outcome (matches / cache purged /
|
||||||
|
unreachable, told apart so a caller never treats "couldn't ask" as "was
|
||||||
|
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
|
||||||
|
|
||||||
|
Two additions beyond a literal port, both load-bearing:
|
||||||
|
|
||||||
|
- **`page(before, ..)` refuses `before == 0` before touching the cache or
|
||||||
|
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
|
||||||
|
`loadOlderPage` incident (`before = 0` is "no event before the first
|
||||||
|
one," indistinguishable from "reached the start of history" if a caller
|
||||||
|
ever asks it) moved out of the Kotlin screen and into this layer, so
|
||||||
|
every future caller gets the guard rather than having to remember it.
|
||||||
|
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
|
||||||
|
The Kotlin's two falses are different answers -- `oldestSeq == 0`
|
||||||
|
returns without touching `moreHistory`, an empty page latches it false
|
||||||
|
-- so a port that answered both with an empty list would have moved the
|
||||||
|
bug rather than fixed it, one layer down and out of sight of the screen
|
||||||
|
that used to hold the check. `OlderPage::Events(vec![])` means the start
|
||||||
|
of the conversation; `OlderPage::NothingLoaded` is not an answer about
|
||||||
|
the conversation at all. Reviewed 2026-09-06.
|
||||||
|
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
|
||||||
|
transport calls, not just the variant, since a request that happens to
|
||||||
|
answer empty is exactly what caused the original bug, and
|
||||||
|
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
|
||||||
|
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
|
||||||
|
are the same rule for the two ways a page can fail.
|
||||||
|
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
|
||||||
|
paired with the exact server bytes it came from, via
|
||||||
|
`serde_json::value::RawValue` rather than re-serializing a parsed
|
||||||
|
`Value` -- the cache and a live SSE frame for the same event have to
|
||||||
|
agree byte-for-byte, which is exactly what the `serde_json`
|
||||||
|
float-rounding bug (AGENTS.md) was about. The existing
|
||||||
|
`fetch_transcript_page` is untouched (other callers under `iris/`
|
||||||
|
depend on its signature); the two share a `transcript_path` helper so
|
||||||
|
the query string is written in one place.
|
||||||
|
|
||||||
|
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
|
||||||
|
`TranscriptSource.close`'s ability to cancel a live stream from another
|
||||||
|
thread. Both are wall-clock/thread-lifetime policy that belongs to
|
||||||
|
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
|
||||||
|
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
|
||||||
|
"write to the cache, then hand the frame to the caller" decorator
|
||||||
|
`iris/desktop-app/src/app.rs` and `iris/android-app/src/transcript_client.rs`
|
||||||
|
already hand-wrote around `event_stream::follow_session_events` before this
|
||||||
|
existed; the cache write moved into one shared place so a third caller
|
||||||
|
does not repeat it again by hand.
|
||||||
|
|
||||||
## What is not started at all
|
## What is not started at all
|
||||||
|
|
||||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
|
||||||
from the transcript cache or the server, and stitches the two. Needs
|
into its *top-level* blocks -- heading, paragraph, fence, list, table,
|
||||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
quote -- with each block's own source, which is what a renderer needs to
|
||||||
which exist now, so this is unblocked whenever picked up.
|
lay out prose versus code and what lets a streamed delta re-lay out one
|
||||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
block instead of the message (docs/RUST.md's Task B). What it
|
||||||
colours a `.md` file or fence for the highlighter, but does not build the
|
deliberately does **not** build is the tree below that: nested list
|
||||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
items, table cells, inline spans. Inline styling is still the renderer's
|
||||||
renderer walks to lay out prose versus code versus a table.
|
own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
|
||||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
|
||||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
for a full CommonMark AST is Compose rendering plumbing, not something
|
||||||
UI layer will want its own block parser or a crate for it, decided
|
to port as-is.
|
||||||
alongside the framework choice in RUST.md.
|
|
||||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||||
it flattens a row into bounded units for a *specific* lazy-list
|
it flattens a row into bounded units for a *specific* lazy-list
|
||||||
framework's composition cost, which is a fact about that framework
|
framework's composition cost, which is a fact about that framework
|
||||||
@@ -142,5 +211,6 @@ be a second one, not a reason to move the type.
|
|||||||
|
|
||||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||||
same way it always has). From `client-core/` directly: `cargo test`,
|
same way it always has). From `client-core/` directly: `cargo test`
|
||||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
(119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
||||||
|
this writing (2026-09-06).
|
||||||
@@ -5,6 +5,157 @@ 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
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-06 (how a tool call looks, P1b)
|
||||||
|
|
||||||
|
- **A card that never got a result says "no result", in yellow, and it is
|
||||||
|
a state Compose cannot say.** A call that finished having printed
|
||||||
|
nothing and a call whose turn was interrupted before anything came back
|
||||||
|
both leave an empty output. Compose draws both as an ordinary finished
|
||||||
|
call, which reads as a fact somebody established. There are five states
|
||||||
|
now, each with a word and a colour: nothing at all for a call that
|
||||||
|
worked, "running" (grey), "your turn" (peach, Compose's own wording and
|
||||||
|
colour), "failed" (red), "no result" (yellow).
|
||||||
|
|
||||||
|
- **A failed call is drawn as failed, which needed a field on the wire.**
|
||||||
|
`is_error` is on the CLI's `tool_result` and was being dropped; the
|
||||||
|
server now carries it to the phone. Reversible, but the alternative is a
|
||||||
|
card that says a call succeeded because it cannot tell.
|
||||||
|
|
||||||
|
- **A group's cards do not each carry their own surface.** Compose gives
|
||||||
|
each card a fill and squares the corners where it faces a neighbour, so
|
||||||
|
a run reads as one object broken into parts. iris has no per-corner
|
||||||
|
radius, and -- more to the point -- a group built the way Compose builds
|
||||||
|
it hit a framework layout defect that drew every card's text a card
|
||||||
|
below its own box. So a group is one surface with its cards on it,
|
||||||
|
separated by a small gap, and the 4dp inset Compose holds them off the
|
||||||
|
edge by is gone. Worth revisiting once the layout defect is fixed
|
||||||
|
(docs/IRIS_TODO.md).
|
||||||
|
|
||||||
|
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
|
||||||
|
lines".** Compose draws the whole thing, and gets away with it because
|
||||||
|
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
|
||||||
|
text widget and shaping a hundred kilobytes of it costs what the file
|
||||||
|
editor's 32 kB limit was measured against. If iris's text gets cheaper,
|
||||||
|
this is the number to move.
|
||||||
|
|
||||||
|
- **A card's command is clipped, not pannable, and its summary line is
|
||||||
|
clipped rather than ellipsised.** Both are framework gaps rather than
|
||||||
|
choices (`scrollable_on` on a non-editable text draws nothing; there is
|
||||||
|
no overflow ellipsis), and both are worse than Compose today. Named here
|
||||||
|
because they are visible.
|
||||||
|
|
||||||
|
## 2026-09-06 (how a markdown block looks, P1a)
|
||||||
|
|
||||||
|
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
||||||
|
call to reverse. Compose draws a real grid: cells on a tint, each
|
||||||
|
column with a 136dp floor, scrolling sideways when there are too many.
|
||||||
|
iris has no grid widget, and building one would be a widget per
|
||||||
|
markdown feature -- which is the thing the block model exists to avoid.
|
||||||
|
In a monospace face a character count *is* a pixel width, so padding
|
||||||
|
each cell to its column's width is alignment, the widths are still
|
||||||
|
measured from the cells, and a table that is too wide pans sideways
|
||||||
|
through the same mechanism a code fence already uses. The header is
|
||||||
|
bold with a rule under it, and a long cell wraps inside its column
|
||||||
|
(capped at 28 characters, which is what fits three columns across a
|
||||||
|
phone). **What it trades:** no cell borders, and a table looks like
|
||||||
|
code rather than like a table. If you want the grid, it is a new widget
|
||||||
|
and it is a day's work.
|
||||||
|
- **Three block frames, and only three.** A heading, paragraph and list
|
||||||
|
are plain text with spans; a fence and a table are a rounded panel that
|
||||||
|
does not wrap; a quote is a bar with the text padded past it.
|
||||||
|
Everything else markdown says is expressed in span styles, which cost
|
||||||
|
no widgets and no layout nodes. So a new markdown feature is a span,
|
||||||
|
not a widget.
|
||||||
|
- **A list's marker is part of the text, so a wrapped item's second line
|
||||||
|
returns to the left margin.** Compose keeps it indented by giving the
|
||||||
|
marker its own column. Doing the same here needs per-line indent in
|
||||||
|
iris's text attributes; it is written down rather than done, because
|
||||||
|
the list items in a real reply are usually one line.
|
||||||
|
- **A link opens on a tap and not on the end of a drag.** A press that
|
||||||
|
panned the transcript past a link, or that held long enough to start a
|
||||||
|
selection, does not follow it -- decided by the same gesture machine
|
||||||
|
that decides pan-versus-select, so there is one rule rather than two
|
||||||
|
that can disagree.
|
||||||
|
|
||||||
|
## 2026-09-06 (composer scroll and the streaming block model)
|
||||||
|
|
||||||
|
- **A streamed message becomes a column of per-block widgets.** Decided by
|
||||||
|
the design agent; recorded here because it is the shape of every message
|
||||||
|
on screen. A transcript row is one `TextEdit` today, so a streamed delta
|
||||||
|
re-shapes the entire message through parley on every event -- the stream
|
||||||
|
phase is the one place iris is behind Compose on your phone (p50 18.2ms
|
||||||
|
vs 13.4ms). A row becomes a column of one widget per markdown block
|
||||||
|
(paragraph, heading, fence, list, table) and a delta replaces only the
|
||||||
|
last block, keeping every earlier block's layout. **Rejected:** splitting
|
||||||
|
parley's layout at block boundaries inside one text widget (couples
|
||||||
|
iris's text widget to markdown structure, and parley has no incremental
|
||||||
|
API), and caching shaped runs per paragraph inside `TextEdit` (a second
|
||||||
|
cache with its own invalidation beside the glyph cache). Chosen because
|
||||||
|
P1's markdown block model is needed anyway, so the split happens once, in
|
||||||
|
`client-core`, and iris stays a text renderer. **Status: designed, not
|
||||||
|
built** -- this pass spent its budget on the composer's three layout
|
||||||
|
defects; docs/RUST.md has the design and the pass conditions.
|
||||||
|
- **The composer's overflowing text now scrolls on a finger**, capped at
|
||||||
|
six lines and clipped to the bar. Reverses the "still does not scroll"
|
||||||
|
item below.
|
||||||
|
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
|
||||||
|
widget authors, enforced by a `debug_assert!`; nothing changes for app
|
||||||
|
code.
|
||||||
|
|
||||||
|
## 2026-09-06 (stale-primitives and touch-scroll pass)
|
||||||
|
|
||||||
|
- **A vertical drag inside a focused composer now scrolls rather than
|
||||||
|
selects.** Android's own `EditText` does this -- a vertical drag scrolls
|
||||||
|
the field, and only a long press starts a selection -- so the platform
|
||||||
|
decided it. What it costs: you can no longer drag straight down inside
|
||||||
|
the composer to select several lines of what you typed; use a long press
|
||||||
|
and then drag, or drag sideways. Say if that trade is wrong for you.
|
||||||
|
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
|
||||||
|
does not, because it has no per-frame tick to animate one and the areas
|
||||||
|
it wraps are at most a screenful (Android does not fling a six-line text
|
||||||
|
box either). Easy to add later if a scroll area ever wraps something long.
|
||||||
|
- **The composer still does not scroll its overflowed text**, though the
|
||||||
|
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
|
||||||
|
was tried and reverted the same day: `Scroll` measures its content and
|
||||||
|
container against the *window*, so inside the `MaxSize` that caps the
|
||||||
|
composer at six lines the two are in different spaces and the field pans
|
||||||
|
itself entirely out of the bar (measured on the emulator with 474
|
||||||
|
characters in it -- the bar collapsed to its padding). Fixing that means
|
||||||
|
`Scroll` measuring against its own offered box, which is a change to a
|
||||||
|
widget the transcript and the bench shell both use, so it is its own
|
||||||
|
piece of work rather than a rider on this one.
|
||||||
|
|
||||||
|
## 2026-09-06 (defect pass)
|
||||||
|
|
||||||
|
- **The keyboard-open diagnostics overlay is gone; the capture only
|
||||||
|
logs now.** It was added when `on_insets_changed` was not firing at all
|
||||||
|
and there was no way to get a report off the phone. It fires reliably
|
||||||
|
since the activity went edge-to-edge -- and what that looks like in
|
||||||
|
use is a full-screen report covering the app **every time the keyboard
|
||||||
|
opens**, with its own Copy/Close buttons sitting underneath the
|
||||||
|
keyboard, so it cannot be dismissed (reproduced on the emulator this
|
||||||
|
pass: two `tap 'CLOSE'` runs left it up). An interruption for something
|
||||||
|
nobody asked for, over the app you are trying to type into. The named
|
||||||
|
`Diagnostics` button still shows the same text on demand, and the new
|
||||||
|
`iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat`
|
||||||
|
pull needs. Reversible: `capture_keyboard_diagnostics` is still the one
|
||||||
|
place this is decided, and `PlatformHandle::show_diagnostics_overlay`
|
||||||
|
is still there.
|
||||||
|
|
||||||
|
- **The bench shell's report pane is sized to its report, not to a share
|
||||||
|
of the window.** It held `.height(rest(1))` beside the transcript's
|
||||||
|
`rest(2)`, so an *empty* `TextEdit` reserved a third of every screen --
|
||||||
|
which is what Iris's "the app does not start with keyboard spacing
|
||||||
|
correct" screenshot was showing, with the composer two thirds down and
|
||||||
|
black below it. It is `.max_height(dp(260))` now and sits above the
|
||||||
|
transcript rather than under the composer, where it was eating the
|
||||||
|
navigation-bar clearance. Cost: a filled report is clipped at 260dp
|
||||||
|
rather than scrolling (a `Scroll` there drew itself off the top of the
|
||||||
|
screen, since `Scroll` pins to the end of its content and reports its
|
||||||
|
content's full length to the parent -- worth fixing in `Scroll`, not
|
||||||
|
worked around here). "Copy report" and `logcat` still have the whole
|
||||||
|
thing.
|
||||||
|
|
||||||
## 2026-09-05
|
## 2026-09-05
|
||||||
|
|
||||||
- **iris no longer asks every device for compute-shader limits it never
|
- **iris no longer asks every device for compute-shader limits it never
|
||||||
@@ -60,6 +211,25 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
|
|||||||
flagged here because it is the first half of something Iris explicitly
|
flagged here because it is the first half of something Iris explicitly
|
||||||
asked to see before P1.
|
asked to see before P1.
|
||||||
|
|
||||||
|
- **P0's iris half is also built and smoke-tested on the emulator,
|
||||||
|
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
|
||||||
|
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
|
||||||
|
asset pipeline needed), the same 24-swipe scroll loop animated through
|
||||||
|
`List::scroll` and the same 400-event/20s streaming phase through
|
||||||
|
`fold_event`, "Run benchmark"/"Copy report" as named accessible
|
||||||
|
controls, and the same three added report fields (process CPU time,
|
||||||
|
peak RSS, battery current) via direct JNI calls
|
||||||
|
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
|
||||||
|
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
|
||||||
|
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
|
||||||
|
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
|
||||||
|
`GlobalRef` it can call Java through from any thread. Packaged with a
|
||||||
|
new `release` build type on `iris-android-app`'s own Gradle project
|
||||||
|
(there was previously only `debug`), signed with the same key
|
||||||
|
`app/build-apk.sh` generates. Smoke run and the full report are in
|
||||||
|
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
|
||||||
|
Iris's pass/fail call, which is the actual gate.
|
||||||
|
|
||||||
- **The intermittent touch-scroll dropout is root-caused and fixed: a
|
- **The intermittent touch-scroll dropout is root-caused and fixed: a
|
||||||
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
|
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
|
||||||
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
|
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
|
||||||
@@ -245,24 +415,30 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
|
|||||||
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
||||||
| iris (`FrameReport`) | **release**, `force-gles` | host (virgl) | 62 | 41.94% | 15.0ms | 21.8ms | 37.1ms | 37.1ms | 0.2ms | 12.9ms |
|
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
|
||||||
|
|
||||||
Under real GPU rendering iris's median frame is *faster* than
|
Under real GPU rendering iris's median frame is *faster* than
|
||||||
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
||||||
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
||||||
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
||||||
0.2ms -- almost the entire frame is time spent handing the frame to the
|
~1ms -- almost the entire frame is time spent handing the frame to the
|
||||||
driver, not in iris's layout/text/primitive code. This is consistent
|
driver, not in iris's layout/text/primitive code. This is consistent
|
||||||
with the earlier software-mode gap being mostly SwiftShader's CPU
|
with the earlier software-mode gap being mostly SwiftShader's CPU
|
||||||
rasterisation cost rather than an iris-specific slowness, but is not
|
rasterisation cost rather than an iris-specific slowness. **Still not
|
||||||
proof of it: a same-mode software `force-gles` run to isolate the
|
proof, and now closed as unanswerable rather than merely untaken**: a
|
||||||
backend crashed for an unrelated reason (SwiftShader's GL path reports
|
same-mode software `force-gles` run to isolate the backend was retried
|
||||||
itself as OpenGL ES 3.0, which has no compute shaders, and iris's device
|
2026-09-05 after fixing the compute-limit crash the first attempt hit,
|
||||||
request assumes them unconditionally) — real scope to fix, not done
|
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
|
||||||
here — and the two apps' frame populations still differ in kind the same
|
path has no storage-buffer capacity at all, and `shader.wgsl` reads
|
||||||
way the software-mode caveats describe. A real intermittent touch-
|
`var<storage>` buffers unconditionally, so reaching that path needs a
|
||||||
scroll dropout was also reproduced this pass (six consecutive swipes
|
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
|
||||||
produced zero redraws while taps kept working; an identical retry then
|
remaining I5 verifications, closed 2026-09-05," item 2). The
|
||||||
succeeded) and is not explained. RUST.md's I5 box, "Where iris's frame
|
intermittent touch-scroll dropout this pass also reproduced is
|
||||||
time goes, 2026-09-05, the `-gpu host` pass," has the full account. The
|
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
|
||||||
iris-vs-Masonry choice itself is still Iris's to make.
|
row's padding/header left `DragArbiter` stuck in `Idle`); three clean
|
||||||
|
`iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing
|
||||||
|
the single-attempt 62-frame reading this table used to carry. RUST.md's
|
||||||
|
I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host`
|
||||||
|
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||||
|
have the full account. The iris-vs-Masonry choice itself is still
|
||||||
|
Iris's to make.
|
||||||
@@ -8,6 +8,474 @@ 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
|
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.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
|
||||||
|
|
||||||
|
Layer 1 and 2 of docs/RUST.md's "Three test layers".
|
||||||
|
|
||||||
|
**New: `iris::harness`** -- a screen driven in-process with no window, no
|
||||||
|
compositor and no GPU, on a clock the caller advances. `Harness::new(size,
|
||||||
|
density)` gives you an `Rsc`, a `UiRenderState` and a state that
|
||||||
|
implements `FocusHost`/`OpenUrl` by *recording* what the platform was
|
||||||
|
asked for (`keyboard_shown`, `opened_urls`) rather than doing it;
|
||||||
|
`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)`
|
||||||
|
feeds one pointer sample the way Android's `on_touch_event` does, and
|
||||||
|
`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses
|
||||||
|
a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the
|
||||||
|
batched 120Hz flick shape your phone actually delivers is a file that
|
||||||
|
`cargo test` can replay -- something the emulator cannot produce at all.
|
||||||
|
|
||||||
|
**New: `List::fling_velocity() -> Option<f32>`**, what the release
|
||||||
|
measured, readable where it landed rather than by re-timing the gesture.
|
||||||
|
|
||||||
|
**Changed: `List` starts a fling's curve at its first `tick_fling`, not
|
||||||
|
at the release.** The only clock it reads is now the one its driver hands
|
||||||
|
it; in a running app the difference is at most a frame.
|
||||||
|
|
||||||
|
**Changed: the desktop backend lays out in physical pixels with a
|
||||||
|
density, exactly as Android does.** `iris::default::content_scale(window)`
|
||||||
|
is the desktop's `content_scale` -- winit's scale factor, overridable with
|
||||||
|
the `IRIS_SCALE` environment variable -- and it now feeds
|
||||||
|
`UiRenderState::set_density`/`TextData::density` instead of dividing
|
||||||
|
coordinates into a separate "logical" space. That division had
|
||||||
|
`UiRenderState::resize` (physical) and the window uniform (logical)
|
||||||
|
disagreeing on any display whose scale factor is not 1.0, and rasterised
|
||||||
|
glyphs at one resolution to display them at another. `Input::event` lost
|
||||||
|
its `scale_factor` parameter as a result, and `DefaultUiState::
|
||||||
|
window_size()` now answers physical pixels. On a 1.0 display nothing
|
||||||
|
changes. The override is what lets `run-headless.sh --phone` open a window
|
||||||
|
at your phone's own 1080x2424 and 2.55.
|
||||||
|
|
||||||
|
## 2026-09-07: the fling curve was the identity function
|
||||||
|
|
||||||
|
You said the fling "seems to just be linear velocity with an abrupt stop."
|
||||||
|
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
|
||||||
|
`t`. Two halves of AOSP's spline build loop had been transposed, which made
|
||||||
|
its two tables identical, and the lookup interpolated one against the
|
||||||
|
other -- which reduces algebraically to `t`. So a fling coasted at its
|
||||||
|
release speed for the whole (correctly computed) duration and stopped dead
|
||||||
|
at the end of it.
|
||||||
|
|
||||||
|
Ported exactly now from `OverScroller.java` and Compose's
|
||||||
|
`SplineBasedDecay.kt`, which agree line for line. One public addition:
|
||||||
|
|
||||||
|
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
|
||||||
|
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
|
||||||
|
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
|
||||||
|
rather than inferred, and it is what `List::tick_fling`'s new
|
||||||
|
`iris fling tick:` debug line reports each frame.
|
||||||
|
|
||||||
|
The lesson worth keeping, since it cost two builds on your phone: every
|
||||||
|
test the calculator had compared it with itself -- monotonic, correctly
|
||||||
|
signed, integrates to the closed form, per-tick deltas non-increasing --
|
||||||
|
and **all of them pass on a straight line**. The numbers now come from
|
||||||
|
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
|
||||||
|
the two sources, checked in beside the tests.
|
||||||
|
|
||||||
|
## 2026-09-07: the Android insets bridge counts its own dispatches
|
||||||
|
|
||||||
|
`AndroidUiState::insets_report() -> String` is new, and the bench app's
|
||||||
|
Diagnostics pane shows it. It carries the last insets plus **how many times
|
||||||
|
the platform has delivered any**, because "the keyboard did not push
|
||||||
|
anything up" has two causes that look identical on screen -- the listener
|
||||||
|
never fired, or it fired with a zero height -- and you have no logcat on
|
||||||
|
the phone. `dispatches=0` prints a sentence saying so rather than the
|
||||||
|
numbers, which would be defaults rather than measurements.
|
||||||
|
|
||||||
|
## 2026-09-07: widgets can animate, and a fling finally moves
|
||||||
|
|
||||||
|
Iris's phone said "fling still doesn't work" twice. The velocity was only
|
||||||
|
half of it: **nothing in iris advanced an animation between input
|
||||||
|
events**, so `List::fling` stored a speed that nothing ever applied. Three
|
||||||
|
public changes come out of fixing that.
|
||||||
|
|
||||||
|
**`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method,
|
||||||
|
defaulted to `false`, so no existing widget changes. A widget that
|
||||||
|
overrides it is animating; answering `false` is how it stops.
|
||||||
|
|
||||||
|
**`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are
|
||||||
|
the registry and its driver. A gesture that starts an animation registers
|
||||||
|
the widget; each backend calls `tick_animations` once per frame before the
|
||||||
|
draw and asks for another frame while it answers `true`. That answer is
|
||||||
|
the *only* thing in iris that makes a frame happen without an input event,
|
||||||
|
and an animation's path out is its own `tick` returning false -- nothing
|
||||||
|
has to remember to unregister it.
|
||||||
|
|
||||||
|
// before: the velocity was stored and never applied
|
||||||
|
list(ui).fling(-v);
|
||||||
|
// after
|
||||||
|
list(ui).fling(-v);
|
||||||
|
let id = list.id();
|
||||||
|
ui.ui_mut().animate(id);
|
||||||
|
|
||||||
|
The two calls are deliberate rather than folded into `fling`: the velocity
|
||||||
|
is the list's business and whether anything animates at all is the frame
|
||||||
|
loop's, and a caller driving its own frames (the benchmark, the headless
|
||||||
|
tests) still calls `tick_fling` directly.
|
||||||
|
|
||||||
|
**`FlingCalculator` needs the real display density, and its coefficient
|
||||||
|
was wrong.** `new(density)` takes physical pixels per `dp` and the
|
||||||
|
velocity handed to it must be in those same physical pixels -- the
|
||||||
|
density does *not* cancel out, contrary to what that type's doc used to
|
||||||
|
claim. Separately, `physical_coefficient` multiplied by the scroll
|
||||||
|
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
|
||||||
|
factor of 56 inside an exponential. Together they gave an ordinary flick a
|
||||||
|
**45-second** coast, which nobody could see while flings never animated.
|
||||||
|
`List` reads its density from the painter now, and
|
||||||
|
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
|
||||||
|
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
|
||||||
|
formula -- the check every previous test could not make, because they all
|
||||||
|
compared the calculator with itself.
|
||||||
|
|
||||||
|
**`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and
|
||||||
|
`shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang
|
||||||
|
either side; it was never meant as a claim about tree depth, and the
|
||||||
|
transcript screen's composer field sits 17 slots below the root. Past the
|
||||||
|
bound both walks silently stop summing, so a widget draws and hit-tests
|
||||||
|
short with nothing to say so; the CPU assert now prints the chain, so a
|
||||||
|
cycle and a deep tree can be told apart.
|
||||||
|
|
||||||
|
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
||||||
|
|
||||||
|
`transcript_ui::tool` is new: a card per tool call, a group per run
|
||||||
|
(P1b). Three things in the public surface follow from it.
|
||||||
|
|
||||||
|
**`client_core::transcript_fold::ToolState`** is what a card colours
|
||||||
|
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
|
||||||
|
built by `ToolState::of(&item, session_working)`. The pair it exists for
|
||||||
|
is `Succeeded` against `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.
|
||||||
|
Only the session's own status separates them, which is why `of` takes it.
|
||||||
|
|
||||||
|
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
|
||||||
|
so an older transcript still parses), and
|
||||||
|
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
|
||||||
|
Without them a result was everything a card knew and a broken call drew
|
||||||
|
exactly as confidently as one that worked -- the missing state, not a
|
||||||
|
wrong one. Every construction site of both had to gain a field; the value
|
||||||
|
comes from the CLI's own `tool_result`, read in one place
|
||||||
|
(`import::tool_result_is_error`) by both the live translator and the
|
||||||
|
import replay.
|
||||||
|
|
||||||
|
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
|
||||||
|
only thing that writes it. Before: a card with no result was drawn the
|
||||||
|
same whether its turn was still going or had been interrupted. After:
|
||||||
|
only the *newest* row can say "running", because every row behind it
|
||||||
|
belongs to a turn that has ended, and changing the flag redraws that one
|
||||||
|
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
|
||||||
|
bool)` joins it, answering whether there was a tool run to act on -- a
|
||||||
|
group's expanded appearance is otherwise unreachable from anything that
|
||||||
|
cannot press the screen.
|
||||||
|
|
||||||
|
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
|
||||||
|
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
|
||||||
|
markdown block) or `Tools` for a run (an arriving result costs one card).
|
||||||
|
One mechanism for "what can this row change cheaply", asked of the row
|
||||||
|
rather than decided again at each call site. It also takes the row's own
|
||||||
|
`working` flag.
|
||||||
|
|
||||||
|
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
|
||||||
|
`ToolInput.kt`'s subject/description/timeout/rest split, and
|
||||||
|
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
|
||||||
|
both with the Kotlin's own tests ported.
|
||||||
|
|
||||||
|
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
||||||
|
|
||||||
|
Three related additions, all for following a markdown link.
|
||||||
|
|
||||||
|
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
|
||||||
|
has the same shape: declared in `iris`, implemented once per backend (a
|
||||||
|
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
|
||||||
|
on Android, deferred to the next view callback exactly the way
|
||||||
|
`pending_show_keyboard` is). A widget asks for the capability by bound --
|
||||||
|
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
|
||||||
|
callback down through every builder. One method, not a general "run an
|
||||||
|
intent": a narrower capability is a narrower thing to get wrong. Nothing
|
||||||
|
is returned; the platform either shows a browser or does not, and both
|
||||||
|
are outside the process.
|
||||||
|
|
||||||
|
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
|
||||||
|
"the press ended having selected something" and "the press ended having
|
||||||
|
done nothing at all", and only the second is a tap. Any caller that acts
|
||||||
|
on a tap -- following a link -- must not also act when the finger was
|
||||||
|
panning the list past that link, so the distinction is made once, in the
|
||||||
|
gesture machine every widget already shares, rather than timed again per
|
||||||
|
widget. `DragArbiter::is_undecided()` is what answers it.
|
||||||
|
`Selection::drag` returns the outcome now instead of `()`.
|
||||||
|
|
||||||
|
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
|
||||||
|
still vertical). A code fence pans across its own long lines exactly the
|
||||||
|
way a transcript pans down its rows, and the two were the same state
|
||||||
|
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
|
||||||
|
joins `scrollable()` for the same reason. Before this, a horizontal
|
||||||
|
`Scroll` existed but could not be dragged by a finger at all -- its
|
||||||
|
arbiter only ever committed on the vertical axis.
|
||||||
|
|
||||||
|
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
|
||||||
|
answers which byte of the text a tap landed on, doing the same
|
||||||
|
region-relative transform `select` does, without handing out the parley
|
||||||
|
layout a caller could shape against stale text. And **`Rect::radius` now
|
||||||
|
takes a `Len`**, so a corner can be written in `dp` and come out the same
|
||||||
|
physical size on every display; a bare number still means physical pixels.
|
||||||
|
|
||||||
|
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
|
||||||
|
answers `false` now. It answered `true`, and a `Rect` fills whatever
|
||||||
|
region it is given -- so `draw_inner`'s fast path, which rewrites a
|
||||||
|
widget's primitives in place instead of redrawing it, could not reproduce
|
||||||
|
what `draw` would have done. A `.background(rect(..))` behind
|
||||||
|
variable-height content kept the size of the provisional pass its parent
|
||||||
|
`Span` had drawn it at, which on the transcript screen meant one code
|
||||||
|
block's panel covering every block below it. Costs one primitive's redraw
|
||||||
|
when a rect is resized.
|
||||||
|
|
||||||
|
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
|
||||||
|
|
||||||
|
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
|
||||||
|
It makes one per top-level markdown block now -- heading, paragraph,
|
||||||
|
fenced code, list, table -- in a `Span::down`, because a streamed delta
|
||||||
|
into a single buffer re-shaped the whole message through parley on every
|
||||||
|
event. `client_core::markdown_blocks::split_blocks` does the splitting;
|
||||||
|
`row::RowBlocks::apply_delta` updates the block a delta lands in and
|
||||||
|
leaves the rest of the message's layout alone.
|
||||||
|
|
||||||
|
**The change to judge, since it is what a reader feels**:
|
||||||
|
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
|
||||||
|
so **a block, not a row, is the unit a selection steps in**. A drag still
|
||||||
|
runs from a reply into the tool output beneath it and copies as one
|
||||||
|
thing; what changed is that the row under the finger is filled in block by
|
||||||
|
block rather than all at once, which is if anything closer to what the
|
||||||
|
old shortcut in `Selection`'s module doc was apologising for. `register`
|
||||||
|
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
|
||||||
|
block of it (dropping only the first is how a freed widget gets left in
|
||||||
|
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||||
|
|
||||||
|
`Selection::locate(ui, render, pos_window)` is new: which block is under a
|
||||||
|
window position, with that block's own local position and size. The
|
||||||
|
list-level handler uses it for the pointer-captured half of a drag,
|
||||||
|
instead of computing a row-local position from `List::extent`.
|
||||||
|
|
||||||
|
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
|
||||||
|
the third is the per-block state a caller keeps only for the row a reply
|
||||||
|
is streaming into, and is `None` for a tool run, which never streams.
|
||||||
|
|
||||||
|
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
|
||||||
|
|
||||||
|
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
|
||||||
|
(`dp` becomes physical pixels), but staying a `Len` so `rest` survives.
|
||||||
|
|
||||||
|
**New rule, and it is a rule about every widget, not about the two that
|
||||||
|
broke it**: a `Len` a widget *reports* from `draw` must not carry an
|
||||||
|
unresolved `dp`. `dp` is an input unit -- a number the widget author wrote
|
||||||
|
-- and the containers that consume a reported length read `abs`, `rel` and
|
||||||
|
`rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition),
|
||||||
|
so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both
|
||||||
|
returned the caller's declared `Len` as written; a `.max_height(dp(168))`
|
||||||
|
therefore gave its child a slot of nothing the moment the cap actually
|
||||||
|
applied, which is what made the composer's bar collapse. Both put their
|
||||||
|
declared lengths through `fold_dp` now, and
|
||||||
|
`UiRenderState::draw_inner` `debug_assert!`s the invariant after every
|
||||||
|
`Widget::draw`, so a widget that gets this wrong says so at the mistake
|
||||||
|
rather than laying out at zero somewhere else.
|
||||||
|
|
||||||
|
Nothing changes for a caller: `.max_height(dp(48))` is written the same
|
||||||
|
way. It is only widget *authors* who now have a rule to follow, and a
|
||||||
|
debug build that enforces it.
|
||||||
|
|
||||||
|
## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields
|
||||||
|
|
||||||
|
**`Painter::set_mask(region)` allocates its widget's mask slot once and
|
||||||
|
rewrites it in place** on every later draw, instead of pushing a new one
|
||||||
|
each time. It has to: `draw_inner`'s unchanged-region fast path does not
|
||||||
|
revisit a descendant whose own region did not change, so those descendants
|
||||||
|
go on referencing whichever slot they were first drawn under. Pushing a
|
||||||
|
fresh slot per draw left the composer's field clipped to a box the bar had
|
||||||
|
long since moved away from -- four live mask entries, none of them the
|
||||||
|
`Masked`'s current region -- and it drew nothing at all. Same call, same
|
||||||
|
signature; only the lifetime changed.
|
||||||
|
|
||||||
|
**`ActiveData` gains `own_mask` and `move_applied`** (both public, since
|
||||||
|
`ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a
|
||||||
|
widget that sets no mask. `move_applied` is how much of a widget's own
|
||||||
|
move-slot delta its `region` already accounts for: `mov` shifts both,
|
||||||
|
`Painter::reposition` shifts only the slot, and `resolved_region` -- and so
|
||||||
|
every hit test -- has to subtract it. Without that a widget that had been
|
||||||
|
panned had its *own* hit box at twice the pan while its descendants were
|
||||||
|
correct, which made the composer's field untappable after a finger drag.
|
||||||
|
|
||||||
|
## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
|
||||||
|
|
||||||
|
Three related public changes, all in aid of IRIS_TODO.md's "the composer
|
||||||
|
has no touch-drag scroll".
|
||||||
|
|
||||||
|
**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and
|
||||||
|
`WidgetLike::scrollable()` now registers it alongside the wheel handler it
|
||||||
|
already registered -- so anything built with `.scrollable()` pans on a
|
||||||
|
finger drag with no extra wiring at the call site. It goes through the same
|
||||||
|
`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List`
|
||||||
|
with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a
|
||||||
|
second copy of that widget's wiring: `DragGesture` owns the mechanics and
|
||||||
|
each caller decides only what a committed pan *means*. `Scroll::amt()` is
|
||||||
|
new too, the read-only pan position a test or a scroll indicator needs.
|
||||||
|
|
||||||
|
There is deliberately **no fling** on `Scroll`. Unlike `List` it has no
|
||||||
|
per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`),
|
||||||
|
and the areas it wraps today are at most a screenful, where Android does not
|
||||||
|
fling either. The released velocity is dropped rather than approximated.
|
||||||
|
|
||||||
|
**A vertical drag inside an already-focused `TextEdit` no longer extends a
|
||||||
|
selection.** `iris::attr`'s `on_press` used to treat a focused field as the
|
||||||
|
plain `click_or_drag` case -- every `Pressing` frame updated the selection.
|
||||||
|
It now applies the same `DRAG_SLOP` rule the *unfocused* branch already
|
||||||
|
applied: a press that moves past the slop vertically abandons its pending
|
||||||
|
selection for the rest of the gesture, so the scroll area around the field
|
||||||
|
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
|
||||||
|
press still starts a selection. This is Android's own `EditText` behaviour
|
||||||
|
(a vertical drag scrolls; only a long press selects), and it is what makes
|
||||||
|
"swipe up over the composer to scroll the transcript" work without dragging
|
||||||
|
a highlight through the message you were typing.
|
||||||
|
|
||||||
|
**`UiRenderState::orphaned_primitives()` is new**, and `update` now
|
||||||
|
`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is
|
||||||
|
a primitive still bound for the GPU that no live `ActiveData` names -- a
|
||||||
|
copy nothing can move, clip or free. That was the doubled `Compacted:` row
|
||||||
|
on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The
|
||||||
|
per-frame guard is a count comparison (O(active widgets)); the walk that
|
||||||
|
names the offenders only runs when the counts disagree, because the walk is
|
||||||
|
O(primitives) and made a debug build on a phone too slow to finish a
|
||||||
|
benchmark run.
|
||||||
|
|
||||||
|
## 2026-09-06: a tap on a text field always leaves a caret
|
||||||
|
|
||||||
|
`TextEditCtx::select` used to compare the tap position against the
|
||||||
|
*laid-out text's* own box and set `selection = None` for anything outside
|
||||||
|
it. A press only reaches `select` after being hit-tested to the widget, so
|
||||||
|
that "outside" meant the field's own padding -- or, for an **empty** field,
|
||||||
|
everything, since an empty layout is a zero-width box. So tapping an empty
|
||||||
|
composer focused it and opened the keyboard while leaving no caret, and
|
||||||
|
`TextEditCtx::insert`/`insert_str` return early with no caret: every
|
||||||
|
keystroke was dropped in silence, and no glyph ever appeared. Parley's
|
||||||
|
`from_point`/`extend_to_point` already clamp a point outside the layout to
|
||||||
|
the nearest cursor position, which is also what a tap in a field's padding
|
||||||
|
should do.
|
||||||
|
|
||||||
|
Behaviour change a caller would notice, in one line: **`select` with a
|
||||||
|
non-drag position now always produces a selection; it no longer clears
|
||||||
|
one.** Clearing is `TextEditCtx::deselect`, which is what the backends'
|
||||||
|
focus handling already calls. A drag is unchanged -- with no previous
|
||||||
|
selection there is still nothing to extend, so it produces none.
|
||||||
|
|
||||||
|
`insert_str` also gained a `debug_assert!` for the no-caret case, so an
|
||||||
|
insert routed to an unfocused field fails at the mistake in a debug build
|
||||||
|
instead of silently swallowing input.
|
||||||
|
|
||||||
|
## 2026-09-06: `List::anchor_position_display`## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
|
||||||
|
|
||||||
|
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
||||||
|
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
||||||
|
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
|
||||||
|
benchmark reads to report fling travel. Note the anchor does not
|
||||||
|
necessarily change *slot* over a long scroll (this widget's own documented
|
||||||
|
design: the anchor is a stable identity, not re-derived from what's on
|
||||||
|
screen each frame), so this is not the same measurement as a Compose
|
||||||
|
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
|
||||||
|
visible row -- the `off` half is what actually reflects how far a fling
|
||||||
|
travelled.
|
||||||
|
|
||||||
|
`iris_core::render::frame_report::FrameReport` gained three methods for
|
||||||
|
per-phase benchmark reporting: `mark_phase(name)` records a named phase
|
||||||
|
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
|
||||||
|
returns one `PhaseStats` (frames, wall duration, late count/percent,
|
||||||
|
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
|
||||||
|
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
|
||||||
|
late count/percent judged against an arbitrary refresh rate rather than
|
||||||
|
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
|
||||||
|
separate method, not a parameter on `report()`, so nothing else changes
|
||||||
|
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
|
||||||
|
run without evicting earlier phases' samples.
|
||||||
|
|
||||||
|
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||||||
|
|
||||||
|
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||||||
|
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||||||
|
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||||||
|
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||||||
|
advances it and returns whether it is still going -- a caller that owns a
|
||||||
|
`RequestRedraw` handle can hand it to the list once via the new
|
||||||
|
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||||||
|
flinging with no further polling needed; a caller driving a scripted
|
||||||
|
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||||||
|
drives `scroll`.
|
||||||
|
|
||||||
|
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||||||
|
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||||||
|
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||||||
|
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||||||
|
the same distance a Compose `LazyColumn` would for the same initial
|
||||||
|
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||||||
|
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||||||
|
velocity from known samples, fling distance/duration against the closed-
|
||||||
|
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||||||
|
(a fling stops rather than scrolling into content that was never loaded).
|
||||||
|
|
||||||
|
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||||||
|
dead on release. After: releasing mid-drag continues scrolling and
|
||||||
|
decelerates, matching the muscle memory every other Android scroll view
|
||||||
|
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||||||
|
-- a release only flings if the gesture had committed to panning
|
||||||
|
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||||||
|
|
||||||
|
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
||||||
|
|
||||||
|
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
||||||
|
`Result<Self, String>` instead of `Self`. Why: it used to let a bind-group-
|
||||||
|
layout validation failure reach wgpu's default error handler, which panics
|
||||||
|
with no way for a caller to intervene -- exactly what aborted the P0 bench
|
||||||
|
APK on Iris's phone with the crash report truncated to "wgpu error:
|
||||||
|
Validation Error" and nothing else recoverable. It now runs its creation
|
||||||
|
calls inside wgpu error scopes and returns the full error text (wgpu's own
|
||||||
|
"Caused by" chain) as `Err` instead.
|
||||||
|
|
||||||
|
Both callers changed to match: `android::render::AndroidRenderer::new`
|
||||||
|
itself now returns `Result<Self, String>` too, building a fuller report
|
||||||
|
(adapter identity, the limits/downlevel flags a layout validates against,
|
||||||
|
then wgpu's text) on failure -- its caller,
|
||||||
|
`android::view::IrisViewPeer::surface_changed`, logs that report as one
|
||||||
|
logcat line and shows it on screen (a new `IrisView.showRendererError`,
|
||||||
|
called via an ordinary JNI method call rather than a new `native fn`)
|
||||||
|
instead of letting the process abort. `default::render::UiRenderer::new`
|
||||||
|
(the winit/desktop backend) still panics on failure -- there is no
|
||||||
|
on-screen fallback there -- but the panic message is now the same full
|
||||||
|
text rather than whatever wgpu's own handler would have printed.
|
||||||
|
|
||||||
|
No change for an app that never constructs a `UiRenderNode` directly (every
|
||||||
|
current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who
|
||||||
|
does needs an `?`/`.expect()`/`match` at the call site now. Full audit and
|
||||||
|
the named hypothesis for what actually failed on the phone are in
|
||||||
|
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
|
||||||
|
|
||||||
|
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
|
||||||
|
|
||||||
|
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
|
||||||
|
(`iris/src/android/view.rs`), called once from `new_peer` right after `new`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default does nothing, so every existing implementor (`Client`,
|
||||||
|
`TranscriptClient`) is unaffected. It exists for a caller that needs to call
|
||||||
|
into Java itself beyond what a `RequestRedraw` handle already covers --
|
||||||
|
P0's bench build (`iris-android-app`'s new `bench` feature,
|
||||||
|
`bench_client.rs`/`bench_jni.rs`) uses it to hold a `JavaVM` + `GlobalRef`
|
||||||
|
to the view so its "Copy report" control and once-a-second battery sampler
|
||||||
|
can call `BatteryManager`/`ClipboardManager` through the view's own
|
||||||
|
`Context`, from a background tokio task as well as the UI thread. `new`
|
||||||
|
itself was not extended with these two parameters: most implementors need
|
||||||
|
nothing here, and `new`'s job is building the widget tree, not holding a
|
||||||
|
platform handle. `vm`/`view` are independent handles from the ones
|
||||||
|
`new_peer` keeps for its own `RequestRedraw` (a fresh `get_java_vm`/
|
||||||
|
`new_global_ref` each), so storing them has no effect on that mechanism.
|
||||||
|
|
||||||
## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits
|
## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits
|
||||||
|
|
||||||
New public function, `iris_core::device_limits() -> wgpu::Limits`. Why:
|
New public function, `iris_core::device_limits() -> wgpu::Limits`. Why:
|
||||||
@@ -414,3 +882,188 @@ with a number instead of a guess (RUST.md's I5 box).
|
|||||||
blocked handing the frame to the driver," not a confirmed GPU-completion
|
blocked handing the frame to the driver," not a confirmed GPU-completion
|
||||||
time. Enough to separate "iris is slow building the frame" from "iris is
|
time. Enough to separate "iris is slow building the frame" from "iris is
|
||||||
slow handing it off," not enough to claim an exact GPU budget.
|
slow handing it off," not enough to claim an exact GPU budget.
|
||||||
|
|
||||||
|
## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply`
|
||||||
|
|
||||||
|
Fixes the "every client refolds and rebuilds the whole widget tree per
|
||||||
|
streamed event" cost RUST.md's P0 box measured (20 events/second against a
|
||||||
|
~3,200-row transcript). Two small additions to `iris::widget::List`
|
||||||
|
(`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s
|
||||||
|
`TranscriptScreen`.
|
||||||
|
|
||||||
|
- **`List::replace_back(row: ListRow) -> Option<ListRow>`**: swaps the
|
||||||
|
*last* row's widget for a new one without moving it — same slot index,
|
||||||
|
so an anchor already pinned there (in particular a list flush with its
|
||||||
|
own end) stays pinned, and a `List` scrolled elsewhere is untouched.
|
||||||
|
`None` if the list is empty. `RowKey` may differ between the old and new
|
||||||
|
row; only `heights`/`extents` care, and both are invalidated for the
|
||||||
|
evicted key the same way `pop_back` already does.
|
||||||
|
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
|
||||||
|
state (`more_before`/`more_after` untouched — a caller that wants those
|
||||||
|
cleared too calls `set_more_before(None)`/`set_more_after(None)` itself).
|
||||||
|
The fallback path for a change that touches more than the tail.
|
||||||
|
- **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**:
|
||||||
|
the incremental alternative to rebuilding the whole screen from
|
||||||
|
`transcript_ui::build_tree` on every folded event. Diffs the two
|
||||||
|
`group_tool_runs` outputs and picks the cheapest update: nothing changed
|
||||||
|
(no-op), a pure append (`push_row`, unchanged cost), or — the common
|
||||||
|
streaming case, a delta into a still-open assistant message — a rebuild
|
||||||
|
of just the one changed row via `List::replace_back`, with any further
|
||||||
|
new rows appended after it. A row changing *before* the tail (only
|
||||||
|
`group_tool_runs` retroactively grouping tool calls into a run does
|
||||||
|
this) falls back to `List::clear` plus a full rebuild, counted in
|
||||||
|
`TranscriptScreen::take_rebuilds()`. **A caller that keeps its own
|
||||||
|
row-keyed side table alongside `List` (`Selection`'s `rows:
|
||||||
|
BTreeMap<RowKey, WeakWidget<TextEdit>>` is the one this crate has) must
|
||||||
|
clear it in step with `List::clear()`** — the fallback drops every row
|
||||||
|
`List` was holding, so any side table not cleared the same way is left
|
||||||
|
pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md
|
||||||
|
finding 1, fixed 2026-09-06 by `Selection::clear()`, called from
|
||||||
|
`apply`'s `Rebuild` arm right before `List::clear()`). `bench_client.rs`, `transcript_client.rs`
|
||||||
|
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
||||||
|
event; only the opening page (and `apply`'s own fallback) still calls
|
||||||
|
`build_tree`.
|
||||||
|
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
|
||||||
|
`Vec<SpanStyle>` in one call, needed because a streamed row's markdown
|
||||||
|
re-renders to both a new string and a new span list on every delta and
|
||||||
|
the two have to land together — a stale span list drawn against new
|
||||||
|
text can point past its end. `set()` itself is unchanged (still clears
|
||||||
|
spans to none, as before).
|
||||||
|
|
||||||
|
Measured on this checkout's emulator (`iris/android-app/run-bench.sh`,
|
||||||
|
release, x86_64, `force-gles`): worst-frame and p99 during the streaming
|
||||||
|
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
|
||||||
|
~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box
|
||||||
|
for the full numbers and the comparison's caveats (different AVD
|
||||||
|
instances, not a controlled A/B on identical hardware state).
|
||||||
|
|
||||||
|
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
|
||||||
|
|
||||||
|
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
|
||||||
|
inset bugs the emulator never showed).
|
||||||
|
|
||||||
|
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
|
||||||
|
italic/bold-italic static faces, OFL) and registers them ahead of the
|
||||||
|
platform's own fonts in the `SansSerif`/`Monospace` generic-family
|
||||||
|
lists, rather than relying on the platform's font enumeration alone.
|
||||||
|
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
|
||||||
|
and what each style axis resolved to — logged once at startup and shown
|
||||||
|
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
|
||||||
|
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
|
||||||
|
delivered (compressed) number.
|
||||||
|
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
|
||||||
|
(`window_size: impl Into<Vec2>`) instead of deriving it from the
|
||||||
|
surface's physical `SurfaceConfiguration`. Existing callers pass a
|
||||||
|
*logical* size (physical ÷ density/scale-factor) now; this is what makes
|
||||||
|
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
|
||||||
|
high-density phone. Before this, `scale_factor` did not exist anywhere
|
||||||
|
in the crate, on either platform.
|
||||||
|
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
|
||||||
|
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
|
||||||
|
now divide every physical-pixel number before it reaches layout or
|
||||||
|
touch handling — see `content_scale`'s own field doc for the full list
|
||||||
|
of what depends on it.
|
||||||
|
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
|
||||||
|
a default-no-op hook called from `render()` exactly when
|
||||||
|
`AndroidUiState::insets()` changes. Nothing previously consumed
|
||||||
|
`insets().top` at all; a screen with chrome under the status bar
|
||||||
|
implements this to pad it, in the same logical units `content_scale`
|
||||||
|
converts everything else to.
|
||||||
|
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
|
||||||
|
on_uncaptured_error` on the Android device (wgpu's default handler is an
|
||||||
|
unconditional panic outside `UiRenderNode::new`'s own error scopes).
|
||||||
|
Explicit `Arc`-backed value passed to the callback and kept on
|
||||||
|
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
|
||||||
|
its own the same way.
|
||||||
|
|
||||||
|
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
|
||||||
|
|
||||||
|
Iris's phone report on build a9232ac (screenshots): text now the right
|
||||||
|
size but blurry; the keyboard still wipes every glyph; the header buttons
|
||||||
|
have nothing behind them. All three are fixed; this entry is the public
|
||||||
|
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
|
||||||
|
box has the full investigation and the phone verification still to do.
|
||||||
|
|
||||||
|
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
|
||||||
|
on every resize**, including an IME-driven one — a fresh, empty glyph
|
||||||
|
atlas while the CPU-side glyph cache kept UV coordinates from the old
|
||||||
|
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
|
||||||
|
the surface and window uniform only) when a renderer is already live,
|
||||||
|
and only builds a new one when there genuinely isn't one yet.
|
||||||
|
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
|
||||||
|
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
|
||||||
|
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
|
||||||
|
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
|
||||||
|
size should look the same physical size on any density. This is the
|
||||||
|
unit IRIS_TODO.md's "density-independent length unit" item asked for;
|
||||||
|
it replaces the previous stopgap (the whole rendered scene divided by
|
||||||
|
`content_scale` then implicitly stretched back up), which is also what
|
||||||
|
made text blurry — a glyph rasterised at the small, pre-stretch size and
|
||||||
|
then upscaled onto the real framebuffer.
|
||||||
|
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
|
||||||
|
pixels per dp). Every place a length resolves (`Len::apply_rest`,
|
||||||
|
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
|
||||||
|
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
|
||||||
|
number anywhere is unaffected — still `abs`, physical pixels.
|
||||||
|
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
|
||||||
|
takes `density` and multiplies `font_size`/`line_height` (and any span
|
||||||
|
override) by it before handing them to parley, so the atlas holds a
|
||||||
|
bitmap at the size it is actually shown at rather than a low-resolution
|
||||||
|
one stretched afterward.
|
||||||
|
- **Everything at the Android boundary is physical pixels now** — window
|
||||||
|
size, touch coordinates, insets (`LogicalInsets` renamed
|
||||||
|
`WindowInsets`). The previous "logical" division by `content_scale` is
|
||||||
|
gone; `content_scale` now feeds `set_density` instead.
|
||||||
|
- Not yet verified on Iris's actual phone (this pass had no device) —
|
||||||
|
built and checked on this checkout's emulator only. RUST.md's P0 box
|
||||||
|
says what she should check for: crisp text at two densities, the
|
||||||
|
keyboard no longer wiping, and the header's background.
|
||||||
|
|
||||||
|
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
|
||||||
|
|
||||||
|
Three small but public API changes, from the same phone-report pass as the
|
||||||
|
entry above (RUST.md's P0 box has the full account, including a real bug
|
||||||
|
still not root-caused).
|
||||||
|
|
||||||
|
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
|
||||||
|
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
|
||||||
|
request the IME) on the very first frame of *any* press, before it was
|
||||||
|
known whether the gesture was a tap or a drag — a swipe over a text
|
||||||
|
field wrongly summoned the keyboard. They now wait for a completed tap
|
||||||
|
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
|
||||||
|
field is already focused, in which case dragging inside it to select
|
||||||
|
text is unchanged. `TextEdit` gained one new `pub(crate)` field
|
||||||
|
(`press_origin`) to track this; no public surface change there.
|
||||||
|
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
|
||||||
|
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
|
||||||
|
called from `after_input`). Gboard was holding keystrokes back because
|
||||||
|
nothing ever told it where the app's own selection/composing region had
|
||||||
|
moved to — this is what android-view's own demo does in its `render()`
|
||||||
|
and this bridge never did.
|
||||||
|
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
|
||||||
|
together, once, from `android::view`'s `surface_changed` exactly when a
|
||||||
|
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
|
||||||
|
not a keyboard-triggered resize, which already reuses the renderer) —
|
||||||
|
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
||||||
|
device's textures, which is why text used to vanish again after leaving
|
||||||
|
and returning to the app.
|
||||||
|
|
||||||
|
## 2026-09-06: `take_counters` counts text layouts too
|
||||||
|
|
||||||
|
One public API change, from the verification pass over the composer-scroll
|
||||||
|
and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
|
||||||
|
|
||||||
|
- **`UiRenderState::take_counters` returns four numbers, not three**:
|
||||||
|
`(draws, region rewrites, move writes, **text shapes**)`. The new one is
|
||||||
|
bumped in `Painter::render_text`, which `TextView::render` only reaches
|
||||||
|
on a cache miss, so it counts layouts actually computed rather than
|
||||||
|
layouts asked for. Callers destructuring the tuple need one more `_`.
|
||||||
|
|
||||||
|
It exists because a draw counter cannot answer the question the
|
||||||
|
per-block transcript row was built for. A widget can be redrawn without
|
||||||
|
re-shaping (the layout is memoized by width) and re-shaped without any
|
||||||
|
extra draw, and re-shaping is the expensive half — so "a streamed delta
|
||||||
|
costs one block" was, until now, argued from the code rather than
|
||||||
|
measured. With the counter it is a test: one delta into a 100-paragraph
|
||||||
|
reply shapes exactly **1** text layout, the same as into a
|
||||||
|
one-paragraph one.
|
||||||
@@ -105,6 +105,387 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
were exactly the same root cause measured two different ways. Frame 2
|
were exactly the same root cause measured two different ways. Frame 2
|
||||||
now reports 0 (see the numbers above); not a separate fix.
|
now reports 0 (see the numbers above); not a separate fix.
|
||||||
|
|
||||||
|
- [ ] **A read-only text display has no widget of its own — P0's bench
|
||||||
|
report area is a `TextEdit` standing in for one (2026-09-05).** The only
|
||||||
|
way to get selectable text on screen today is `.editable(...)` plus
|
||||||
|
`.attr::<Selectable>(())` (`Selectable` is only implemented for
|
||||||
|
`TextEdit`, `iris/src/attr.rs`), which also makes the field focusable —
|
||||||
|
tapping the bench report opens the soft keyboard over text nothing lets
|
||||||
|
you type into. Harmless for a bench-only debug screen (not fixed this
|
||||||
|
pass), but a real "selectable, not editable" text primitive would
|
||||||
|
remove the keyboard side effect and is worth having before another
|
||||||
|
screen wants the same thing (P1's own transcript rows already read
|
||||||
|
their content from a `TextEdit` for the same reason).
|
||||||
|
|
||||||
|
## From the phone, 2026-09-06
|
||||||
|
|
||||||
|
Found on Iris's own phone while working RUST.md's P0 box's phone-report
|
||||||
|
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
||||||
|
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||||
|
`android/view.rs`/`android/sense.rs` changes.
|
||||||
|
|
||||||
|
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||||
|
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||||
|
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||||
|
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||||
|
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||||
|
test (fling distance against the closed-form spline result, cancel-on-
|
||||||
|
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||||
|
that is still open.
|
||||||
|
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||||
|
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||||
|
transition rather than by an on-device trace (no emulator was used this
|
||||||
|
pass): it was the first named suspect, not the second. `self.last` stays
|
||||||
|
at the press origin for every `Undecided` frame (nothing pans while the
|
||||||
|
gesture might still be a selection), so the frame that finally crosses
|
||||||
|
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||||
|
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||||
|
many frames it had taken to get there. Fixed by applying only the
|
||||||
|
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||||
|
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||||
|
touch handling follows. New regression test,
|
||||||
|
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||||
|
**Not yet done**: an emulator trace of the real per-frame offset
|
||||||
|
confirming this was the whole story on real touch input rather than
|
||||||
|
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||||
|
calling it fully closed.
|
||||||
|
- [x] **Composing text held back until a space, caret not moving, fixed
|
||||||
|
2026-09-06.** `InputMethodManager.updateSelection` was never called --
|
||||||
|
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
|
||||||
|
full account and the emulator evidence.
|
||||||
|
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
|
||||||
|
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
|
||||||
|
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
|
||||||
|
input_method`'s `mInputShown` on the emulator, not yet on the phone.
|
||||||
|
- [x] **Text disappears again after leaving and returning to the app,
|
||||||
|
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
|
||||||
|
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
|
||||||
|
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
|
||||||
|
not yet on the phone.
|
||||||
|
- [x] **Composed/typed text never becomes visible at all -- root-caused
|
||||||
|
and fixed 2026-09-06.** Not the renderer at all: **the composer's buffer
|
||||||
|
was empty the whole time.** `TextEditCtx::select` (`iris/src/widget/
|
||||||
|
text/edit.rs`) compared the tap against the *laid-out text's* box and
|
||||||
|
set `selection = None` for anything outside it -- and an empty field's
|
||||||
|
layout is a zero-width box, so tapping an empty composer granted focus
|
||||||
|
and opened the keyboard while leaving no caret; `insert_str` returns
|
||||||
|
early with no caret, so every keystroke after that was dropped in
|
||||||
|
silence. Gboard's suggestion strip is its own composing state, not a
|
||||||
|
read of our buffer, which is what made the earlier pass conclude the
|
||||||
|
buffer held the text. Fixed by letting parley clamp a tap outside the
|
||||||
|
layout to the nearest cursor position (a press that reaches `select`
|
||||||
|
has already been hit-tested to the widget, so there is no "outside"),
|
||||||
|
plus a `debug_assert!` in `insert_str` so an insert with no caret fails
|
||||||
|
at the mistake instead of dropping input -- it immediately caught
|
||||||
|
`layout_tests::composing_text_after_a_keyboard_resize_...` typing into
|
||||||
|
an unfocused field. Three new tests in `edit.rs`
|
||||||
|
(`tapping_an_empty_field_places_a_caret_so_typing_lands`,
|
||||||
|
`tapping_past_the_end_of_the_text_clamps_to_the_end`,
|
||||||
|
`dragging_without_a_previous_selection_selects_nothing`); the first
|
||||||
|
fails on the pre-fix code. Emulator evidence: `adb shell input text`
|
||||||
|
after `tap 'Message'` now shows the text in the bar
|
||||||
|
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
|
||||||
|
glyphs=5`, against `glyphs=0` on every keystroke before.
|
||||||
|
|
||||||
|
**The old, superseded diagnosis, kept because it was wrong in an
|
||||||
|
instructive way:** The composer bar stays empty even once the
|
||||||
|
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
||||||
|
own suggestion strip reacts correctly to each keystroke). A new unit
|
||||||
|
test proves the widget tree's own layout math resolves the field's
|
||||||
|
region correctly across a keyboard resize, so the bug is downstream of
|
||||||
|
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
|
||||||
|
or specific to this emulator's forced `force-gles` backend (untested on
|
||||||
|
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
|
||||||
|
writeup, what was ruled out, and where to look next. **Also unverified
|
||||||
|
because of this**: item 3's composer rebuild (one `Stack`-based widget,
|
||||||
|
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||||
|
inset) -- structurally in place and unit-tested, but its own visual
|
||||||
|
correctness cannot be screenshotted until text actually renders.
|
||||||
|
- [x] **The composer has no touch-drag scroll for overflowing text.**
|
||||||
|
**Done 2026-09-06.** `field.scrollable().masked()` in
|
||||||
|
`transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
|
||||||
|
message, the bar stays capped at six lines, and a vertical drag in the
|
||||||
|
focused field no longer extends a selection (Android `EditText`'s own
|
||||||
|
behaviour). Verified on this checkout's emulator with the
|
||||||
|
`transcript-screen bench force-gles` debug build -- six repetitions of a
|
||||||
|
13-word sentence typed in, then
|
||||||
|
`ui-trace record --do "swipe 540 1200 540 1460 300"`: the field's
|
||||||
|
`Message` box moved `31,1041..1048,1509` -> `31,1131..1048,1651` (the
|
||||||
|
content panned down with the finger) with its **height unchanged at
|
||||||
|
468px** (the bar did not grow), and the two screenshots either side show
|
||||||
|
different text in the same band.
|
||||||
|
Three real defects had to be fixed first, each with a headless
|
||||||
|
regression test in `iris/src/layout_tests.rs` and each confirmed to fail
|
||||||
|
without its fix (docs/RUST.md's plan box has the measurements):
|
||||||
|
a `MaxSize` reporting its cap as an unresolved `dp` (`Len::fold_dp`), a
|
||||||
|
`Masked` allocating a fresh mask slot per draw (`ActiveData::own_mask`),
|
||||||
|
and a panned widget's own hit box moving twice (`move_applied`).
|
||||||
|
`Scroll` itself turned out to measure the right number by a misleading
|
||||||
|
route -- it is written against `painter.px_size()` now, and the claim
|
||||||
|
below that it "measures against the window" was wrong.
|
||||||
|
**The grey background was not missing** -- that note (written here on
|
||||||
|
2026-09-06 and repeated as still open) is withdrawn. Re-measured the
|
||||||
|
same day on the same AVD by decoding the screencap rather than reading
|
||||||
|
it: the bar is `rgb(41,40,49)`, the declared `UiColor::new(40, 40, 46)`
|
||||||
|
after sRGB rounding, **full width and y2245..y2365** on 1080x2424, with
|
||||||
|
the field at `31,2277..1048,2329` and the 63px nav strip below it. It
|
||||||
|
is dark by design and sits on black, which is very likely what the
|
||||||
|
earlier reading was: at a glance the band and the background are hard
|
||||||
|
to tell apart. If it should read as a bar rather than as a slightly
|
||||||
|
different black, the colour is the thing to change, not the tree.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
||||||
|
|
||||||
|
Iris's report on the build with the composing-text, tap-vs-swipe and
|
||||||
|
atlas-reset fixes, with a screenshot, verbatim. Each is open until an
|
||||||
|
agent ticks it here with the evidence.
|
||||||
|
|
||||||
|
- [x] **"The app definitely does not start with keyboard spacing
|
||||||
|
correct. This is how it looks without me doing anything initially."**
|
||||||
|
**Not an inset bug at all -- fixed 2026-09-06.** The black third is the
|
||||||
|
bench shell's own empty *benchmark report* pane: `bench_client.rs`'s
|
||||||
|
root tree gave it `.height(rest(1))` beside `content.height(rest(2))`,
|
||||||
|
so an empty `TextEdit` reserved a third of the window at every launch
|
||||||
|
and pushed the composer up by exactly that. Measured on this checkout's
|
||||||
|
emulator at the phone's own size (1080x2424, density 420, gesture nav),
|
||||||
|
which reproduced Iris's screenshot exactly: new `iris insets:` log line
|
||||||
|
reported `bottom=63 ime_bottom=0` at launch (a nav bar, no keyboard --
|
||||||
|
so the inset the composer was fed was never large), while `ui-trace
|
||||||
|
show -m Message --field box` put the field at `31,1488..1048,1540` on a
|
||||||
|
2282px-tall surface, 789px clear of the bottom -- that pane's third.
|
||||||
|
**Unit mixing checked explicitly and cleared**: `set_bottom_inset` takes
|
||||||
|
physical px and stores `Len::abs`, `MainActivity.java`'s `1`/`0`
|
||||||
|
`ime_bottom` only ever reaches `insets.bottom.max(ime_bottom)` and
|
||||||
|
`> 0.0`, and every `dp` in the composer resolves at layout time. Fix:
|
||||||
|
the report pane is sized to its content (`.max_height(dp(260))
|
||||||
|
.scrollable()`), and moved above the transcript so it cannot eat the
|
||||||
|
composer's nav-bar clearance. After: field box `31,2277..1048,2329`,
|
||||||
|
grey bar ending at device y2361 with the 63px nav strip below it
|
||||||
|
(`/tmp/fix1.png` this pass).
|
||||||
|
The screenshot shows the composer bar (the grey band) sitting about
|
||||||
|
two thirds of the way down a 704x1568 screen, with black below it to
|
||||||
|
the bottom, and the transcript ending at "Claude / Results" just above
|
||||||
|
it -- at launch, no keyboard. So the composer's bottom padding, which
|
||||||
|
the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a
|
||||||
|
large value at start on the phone. Suspects, in order: the initial
|
||||||
|
inset delivery on the phone (GrapheneOS, gesture navigation) versus
|
||||||
|
the emulator; `ime_bottom` now carrying a `1`/`0` boolean through a
|
||||||
|
field the composer may still read as pixels or dp; a stale value from
|
||||||
|
before the first `on_insets_changed`. Reproduce with the phone's
|
||||||
|
screen size and density on the emulator before guessing.
|
||||||
|
- [~] **"Swiping still gets caught by the grey bar but keeps working
|
||||||
|
after I go past it."** Improved 2026-09-06 by the focused-field rule
|
||||||
|
below, still needs her phone to close. `attr.rs`'s `on_press` treated an
|
||||||
|
already-focused composer as the plain drag-to-select case, so a swipe
|
||||||
|
starting inside it dragged a highlight through the typed text for the
|
||||||
|
whole gesture; it now abandons that the moment the press passes
|
||||||
|
`DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one
|
||||||
|
of the two things that made the bar feel like it caught the swipe. The
|
||||||
|
residual `DRAG_SLOP` measured from the boundary crossing, described
|
||||||
|
below, is unchanged. Original note follows.
|
||||||
|
Not closeable from the emulator, annotated
|
||||||
|
2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
|
||||||
|
calls `capture_pointer` and never consumes a `Pressing` frame past
|
||||||
|
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
|
||||||
|
position leaves the composer's box and enters the list's, `List`
|
||||||
|
starts receiving ordinary hit-tested `Pressing` frames there --
|
||||||
|
`DragArbiter::is_idle()`'s 2026-09-05 recovery (a missed `PressStart`)
|
||||||
|
picks it up rather than leaving it stuck. What this does **not** do is
|
||||||
|
what "wherever it began" implies literally: `DragArbiter::press_start`
|
||||||
|
restarts from the *boundary-crossing* position, not from the original
|
||||||
|
touch-down inside the composer, so the pan still needs a fresh
|
||||||
|
`DRAG_SLOP` of travel measured from the boundary rather than from the
|
||||||
|
start of the gesture -- composer and list are adjacent, non-overlapping
|
||||||
|
widgets (`lib.rs`'s `(list, composer_bar).span(Dir::DOWN)`), and only
|
||||||
|
the composer forwarding its own drag to the list would remove that
|
||||||
|
residual slop entirely, which is more than this pass's merge changes.
|
||||||
|
RUST.md's merge-pass box has the reasoning in full and an emulator
|
||||||
|
swipe confirming the composer's own box never moves/resizes during it;
|
||||||
|
whether the residual slop is still perceptible as "caught" needs Iris's
|
||||||
|
phone, since the emulator's per-widget boundary is a few dp wide and
|
||||||
|
easy to cross without noticing on a real screen too.
|
||||||
|
- [ ] **"Flinging still does not work."** No longer expected to reproduce
|
||||||
|
after the `DragGesture` merge (`e12c708`, pointer capture +
|
||||||
|
`CursorSense::Drop`), 2026-09-06. Emulator evidence (RUST.md's
|
||||||
|
merge-pass box, check (b)): a real `ui-trace` finger swipe followed by
|
||||||
|
screenshot-hash sampling caught a post-release frame distinct from the
|
||||||
|
drag's own last frame in one run, and every run showed 28-32
|
||||||
|
`render()` frames per gesture against an idle baseline of 0 and ~8
|
||||||
|
expected from the drag alone -- redraw kept being requested well past
|
||||||
|
the finger lifting, which only happens while a fling is still
|
||||||
|
animating. Left unticked in spirit until Iris's phone confirms it,
|
||||||
|
since only she can say whether it *feels* like a fling now; the
|
||||||
|
emulator's screenshot timing could not always catch the tail of a
|
||||||
|
fast-settling one visually (same caveat noted in RUST.md).
|
||||||
|
- [~] **"Text still disappears if I leave and come back to the app."**
|
||||||
|
**Instrumented 2026-09-06 so the phone can answer it**, since no
|
||||||
|
emulator here has a Vulkan adapter. `iris/src/android/view.rs` now logs
|
||||||
|
one `log::info!` line per surface event with the glyph/atlas counts:
|
||||||
|
`iris surface: surface_destroyed, tearing the renderer down
|
||||||
|
(glyphs_cached=387 atlas_pages=1)`, `iris surface: surface_changed
|
||||||
|
1080x2424 already_live=false glyphs_cached=387 atlas_pages=1`, `iris
|
||||||
|
surface: new renderer built (Gl), clearing glyph atlas: glyphs=387
|
||||||
|
pages=1`, plus `iris insets: ... window=(1080, 2424)` on every insets
|
||||||
|
change. That is the emulator's own healthy app-switch cycle, verified
|
||||||
|
this pass (home, reopen, screenshot: all text intact,
|
||||||
|
`/tmp/appswitch.png`). **The one line to look for on the phone is
|
||||||
|
`already_live=`**: `true` on the return from backgrounding would mean
|
||||||
|
the surface came back *without* a `surface_destroyed`, so
|
||||||
|
`surface_changed` reconfigured a renderer whose Vulkan swapchain and
|
||||||
|
atlas textures belong to a window that is gone -- the reuse branch
|
||||||
|
never clears the atlas, by design. `false` with no `new renderer built`
|
||||||
|
line after it would mean the renderer failed to rebuild. Either answer
|
||||||
|
names the fix; guessing between them from here does not.
|
||||||
|
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
|
||||||
|
emulator under `force-gles` only; the phone runs Vulkan. So either the
|
||||||
|
reset is not reached on the phone's path (a different surface-
|
||||||
|
lifecycle sequence -- `surface_destroyed`/`surface_created` ordering,
|
||||||
|
or the renderer not being rebuilt but its textures lost), or the CPU
|
||||||
|
glyph cache and the GPU atlas still disagree after it. Needs logging
|
||||||
|
of the renderer lifecycle on the phone build, readable from `adb
|
||||||
|
logcat` when Iris next runs it, since no emulator here has a Vulkan
|
||||||
|
adapter under host GPU.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-06, 22:16 (build from 20303e0, delivered via ai-app-bench 95e25fe)
|
||||||
|
|
||||||
|
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||||
|
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||||
|
|
||||||
|
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||||
|
*"flinging now does technically do something, but it seems to just be
|
||||||
|
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||||
|
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||||
|
-- a constant-speed slide for the whole duration, then a stop at full
|
||||||
|
distance -- because two halves of AOSP's spline build loop were
|
||||||
|
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
|
||||||
|
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
|
||||||
|
between even time steps. The two cancelled to the identity. Ported
|
||||||
|
exactly now from `OverScroller.java` and
|
||||||
|
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||||
|
(they agree line for line), with `iris/benches/fling_spline_reference.py`
|
||||||
|
as an independent transcription supplying the numbers the tests assert
|
||||||
|
on. Emulator, 2026-09-07: a released `v=3750` decelerates
|
||||||
|
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
|
||||||
|
frames; a flick into the end of the list stops there in one tick with no
|
||||||
|
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
|
||||||
|
**Open until the phone says so** -- a flick should now visibly slow
|
||||||
|
before it stops. Its earlier three defects (the velocity, the missing
|
||||||
|
animation registration, the 56x coefficient) are all still fixed and were
|
||||||
|
never the linear part.* Second report; the emulator's
|
||||||
|
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
||||||
|
a finger on the phone does not. What differs: a real flick at 120Hz is
|
||||||
|
batched by Android into few `MotionEvent`s with *historical* samples
|
||||||
|
(`getHistoricalX/Y/EventTime`), and can be DOWN, one or two MOVEs, UP
|
||||||
|
inside `DRAG_SLOP`'s worth of frames; a `ui-trace` swipe is many
|
||||||
|
evenly-spaced MOVEs. Suspects, in order: `android/sense.rs` reading
|
||||||
|
only each event's final position (the velocity tracker sees two
|
||||||
|
samples, or one); the release path starting a fling only from a
|
||||||
|
gesture already in `Panning`, so a flick that crosses the slop on its
|
||||||
|
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
|
||||||
|
delivering no `Drop`. Log the release decision (samples, span,
|
||||||
|
velocity, outcome) at `info` so the next logcat settles it.
|
||||||
|
- [x] **"I can't reopen keyboard by tapping on message box after it
|
||||||
|
already happened once."** *(Fixed 2026-09-07: `attr.rs`'s already-
|
||||||
|
focused branch calls `focus_gained` on a tap inside `DRAG_SLOP`.
|
||||||
|
Emulator: first tap `mInputShown=true`, back gesture, second tap
|
||||||
|
`mInputShown=true`. Negative control with that one call removed leaves
|
||||||
|
the second tap at `false`; a horizontal and a vertical swipe over the
|
||||||
|
focused field both leave it at `false`, so the earlier "swiping over
|
||||||
|
the input bar brings up the keyboard" has not returned.)* The field stays focused after the keyboard
|
||||||
|
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
|
||||||
|
already-focused branch never requests the IME again. Android's
|
||||||
|
`EditText` shows the IME on every tap of a focused field; do the same
|
||||||
|
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||||
|
when it is already shown).
|
||||||
|
- [ ] **"Message box does not push up the scroll area."**
|
||||||
|
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||||
|
raising up does not push things upwards"* -- after being ticked on
|
||||||
|
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||||
|
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
|
||||||
|
what was wrong is one line of `iris/android-app/app/build.gradle`:
|
||||||
|
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
|
||||||
|
`app/` targets 37 and *does* push up on her phone. Below target 35 the
|
||||||
|
window keeps the legacy behaviour, where `adjustResize` shrinks it for
|
||||||
|
the IME and `getInsets(ime()).bottom` therefore measures zero;
|
||||||
|
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
|
||||||
|
the API 36 emulator here, which is why every test run passed. Now
|
||||||
|
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
|
||||||
|
where only the animation path carries the height -- which also makes the
|
||||||
|
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
|
||||||
|
jump). **This is a reading, not a measurement**: no Android 17 device is
|
||||||
|
reachable from here. So the Diagnostics pane now prints
|
||||||
|
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
|
||||||
|
**screenshot that line with the keyboard open.** `ime_bottom` in the
|
||||||
|
hundreds and the composer risen means fixed; `dispatches` climbing with
|
||||||
|
`ime_bottom=0` means the reading was wrong and the window is still being
|
||||||
|
resized; `dispatches=0` means the listener is not firing at all, which is
|
||||||
|
a third thing again.* Since
|
||||||
|
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||||
|
longer resizes the window, so the app owns the IME inset -- but
|
||||||
|
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
||||||
|
2026-09-06 "(b)" fix), so nothing has the inset's *height* to pad the
|
||||||
|
transcript and composer with. Pass both: `isVisible(ime())` and
|
||||||
|
`getInsets(ime()).bottom` in px; the list's bottom padding and the
|
||||||
|
composer's position follow the height, the visibility drives the
|
||||||
|
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
|
||||||
|
describes.
|
||||||
|
- [x] **"Picture is what happens if I leave the app and come back,
|
||||||
|
which completely removes text, and then I tap on the debug info. The
|
||||||
|
textures are definitely getting cooked for some reason after leaving
|
||||||
|
the app and resuming."** Screenshot: every glyph drawn *before* the
|
||||||
|
resume is fragments; the diagnostics text drawn *after* is perfect;
|
||||||
|
the report says `atlas format: Rgba8Unorm, views live: 0`. Reading:
|
||||||
|
`Textures::reset`/`GlyphAtlas::clear` on the new renderer emptied the
|
||||||
|
GPU atlas, but the per-widget cached text primitives (`TextView`'s
|
||||||
|
render cache -- the one `c3cfc67`'s shape counter is keyed on) still
|
||||||
|
carry the old atlas coordinates and are re-submitted as-is; only
|
||||||
|
widgets drawn fresh after the resume shape and upload again. Fix: a
|
||||||
|
renderer rebuild invalidates every cached text render (one
|
||||||
|
generation counter on the atlas, checked at `TextView::render`, or
|
||||||
|
a full-tree redraw with caches dropped), with a `debug_assert!` that
|
||||||
|
no submitted glyph quad references an atlas generation older than the
|
||||||
|
live one. Reproducible on the emulator by forcing a renderer rebuild
|
||||||
|
(home + return, or `surface_destroyed`/`surface_created`) on a screen
|
||||||
|
with text already drawn -- the earlier "verified" home/reopen check
|
||||||
|
screenshotted the emulator's GLES path, where a resume may not
|
||||||
|
destroy the surface at all.
|
||||||
|
|
||||||
|
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||||
|
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||||
|
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||||
|
not destroy the surface at all -- so the phone was the only place this
|
||||||
|
could be answered, and it has been. `clearing_the_atlas_re_renders_
|
||||||
|
cached_text_instead_of_reusing_it` is what keeps it.
|
||||||
|
|
||||||
|
The reading above is right and the mechanism is one step narrower than
|
||||||
|
"cached text primitives". `IrisViewPeer::surface_changed`
|
||||||
|
(`iris/src/android/view.rs`) *does* already force a full-tree redraw
|
||||||
|
after a rebuild: it calls `render.resize(...)` unconditionally, which
|
||||||
|
sets `UiRenderState::resized`, which makes the next `update` take
|
||||||
|
`redraw_all` rather than `redraw_updates`. So every widget's `draw`
|
||||||
|
really does run again after the resume. What survives it is one cache
|
||||||
|
further in: `TextView::render` (`iris/src/widget/text/mod.rs`) returns
|
||||||
|
its cached `RenderedText` whenever the wrap width, buffer and attrs are
|
||||||
|
unchanged -- true of every pre-resume row -- so `TextData::place` is
|
||||||
|
never reached, nothing is re-rasterised into the fresh atlas, and the
|
||||||
|
*old* atlas's `uv_min`/`uv_max`/`layer` are re-submitted verbatim. Only
|
||||||
|
text whose content changed after the resume (the diagnostics pane Iris
|
||||||
|
tapped) re-shapes, which is exactly the split in her screenshot.
|
||||||
|
`Painter::glyphs` has one call site in the whole workspace, that one,
|
||||||
|
so there is no second holder of a `RenderedText` to fix.
|
||||||
|
|
||||||
|
The fix, in `ba2afba`: `GlyphAtlas::generation`, bumped by
|
||||||
|
`GlyphAtlas::clear`; `RenderedText::generation` recording which atlas
|
||||||
|
its glyphs were placed against; `Painter::atlas_generation()`;
|
||||||
|
`TextView::render`'s cache key gains it; and a `debug_assert_eq!` in
|
||||||
|
`Painter::glyphs` that a submitted quad's generation is the live one.
|
||||||
|
Headless test
|
||||||
|
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
|
||||||
|
(`iris/src/widget/text/mod.rs`): draw, `atlas.clear()`, `resize`, draw
|
||||||
|
again, and assert the atlas holds the same glyph count again -- it
|
||||||
|
stays at 0 without the fix, because the cache short-circuits before
|
||||||
|
`place`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||||
@@ -294,22 +675,31 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
`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
|
stable per row (its sender + a short excerpt, matching what a screen
|
||||||
reader announcing a chat message would say).
|
reader announcing a chat message would say).
|
||||||
- [ ] **A tappable link and a background chip behind inline code.**
|
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
|
||||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
byte_at(pos, size)` answers which byte a tap landed on without
|
||||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
handing out the parley layout, `GestureOutcome::Tapped` says the
|
||||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
press committed to neither a pan nor a selection, and
|
||||||
would take (the same primitive `TextEdit::draw`'s own selection
|
`iris::platform::OpenUrl` is the capability each backend implements
|
||||||
highlight already uses internally,
|
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
|
||||||
`iris/src/widget/text/edit.rs:99`).
|
deferred to `after_input` the way `pending_show_keyboard` is).
|
||||||
|
- [ ] **A background chip behind inline code.** Still needs per-range
|
||||||
|
glyph *geometry* — a run's boxes, not one offset — which
|
||||||
|
`TextEditCtx` does not expose outside `iris::widget::text`
|
||||||
|
(`edit.rs`'s `layout()` helper is private). The same primitive
|
||||||
|
`TextEdit::draw`'s own selection highlight uses internally,
|
||||||
|
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
|
||||||
|
not open that up: a tap needs one offset and a chip needs the run.
|
||||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||||
is selected in full (`select_all`) the moment the drag leaves it,
|
is selected in full (`select_all`) the moment the drag leaves it,
|
||||||
rather than "from the click point to whichever edge points away from
|
rather than "from the click point to whichever edge points away from
|
||||||
the drag" — needs the same private `layout()` access as the item
|
the drag" — needs the same private `layout()` access as the item
|
||||||
above. `selection.rs`'s module doc has the exact reasoning.
|
above. `selection.rs`'s module doc has the exact reasoning.
|
||||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
- [x] **Syntax highlighting inside a fenced code block** — done
|
||||||
`client_core::highlight` exists (built for the file explorer) and
|
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
|
||||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
converted from its char indices to `SpanStyle`'s byte offsets, in
|
||||||
in was not attempted this pass.
|
the same Catppuccin palette `Theme.kt` uses. A language the scanner
|
||||||
|
has no rules for stays plain rather than being coloured by the
|
||||||
|
nearest one's.
|
||||||
|
|
||||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
by something *and also* applies mask B — a mask can reference a parent
|
||||||
@@ -329,6 +719,109 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
everything, the same way input is**. Whatever the mechanism, a widget
|
everything, the same way input is**. Whatever the mechanism, a widget
|
||||||
that does not animate must pay nothing and import nothing for it.
|
that does not animate must pay nothing and import nothing for it.
|
||||||
|
|
||||||
|
## Found by P1a (2026-09-06)
|
||||||
|
|
||||||
|
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
|
||||||
|
fills whatever region it is handed, so `draw_inner`'s size-independent
|
||||||
|
fast path -- which rewrites primitives with
|
||||||
|
`r.outside(&from).within(®ion)` rather than redrawing -- could not
|
||||||
|
reproduce its `draw`, and a `.background(rect(..))` kept the size of
|
||||||
|
the *provisional* full-region pass `Span` does in phase 1. One fenced
|
||||||
|
code block's panel covered every block below it and every row below
|
||||||
|
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
|
||||||
|
definition. Suspect the same cause for anything else tinted with a
|
||||||
|
background rect.
|
||||||
|
- [x] **A wrapped transcript row tripped `reposition`'s debug assert.**
|
||||||
|
Settled 2026-09-06 by giving the move slot one owner instead of two.
|
||||||
|
`mov` accumulates a delta on it, `reposition` overwrote it, and both
|
||||||
|
legitimately land on one widget in one frame: `List::place`'s
|
||||||
|
Bottom-known branch offers a row a same-size box that has *moved*
|
||||||
|
(`mov`), then corrects the placement inside it when the row's cached
|
||||||
|
height no longer matches what the row reports (`reposition`). The
|
||||||
|
slot now always means `move_applied + repositioned`
|
||||||
|
(`ActiveData::repositioned`, `iris/core/src/ui/render_state.rs`), so
|
||||||
|
`reposition` adds the move rather than dropping it -- the assert is
|
||||||
|
gone and the arithmetic is right. Test:
|
||||||
|
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
|
||||||
|
in `layout_tests.rs`, which lands the child at the *offered* position
|
||||||
|
(-100px) instead of the placement (100px) without the fix, and a
|
||||||
|
`debug_assert_eq!` in `reposition` that nothing but those two ever
|
||||||
|
writes the slot. Verified with the `.wrap(true)` repro (draws, no
|
||||||
|
panic) and an emulator bench run with assertions live.
|
||||||
|
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
|
||||||
|
the shader writes the palette's bytes as linear.** Mocha Crust
|
||||||
|
(17,17,27) is drawn as (73,73,91), measured off
|
||||||
|
`run-headless.sh --shot`. Android is correct, so this is the surface
|
||||||
|
format rather than the palette -- but it makes the desktop build
|
||||||
|
useless as a colour reference, which is exactly what P1a needed it for
|
||||||
|
when the emulator could not draw glyphs.
|
||||||
|
- [x] **Every glyph was a solid box on the GLES backend -- iris's bug,
|
||||||
|
not the emulator's.** Fixed 2026-09-06. The atlas is one
|
||||||
|
`texture_2d_array` and `GpuTextures::new` created it with **one
|
||||||
|
layer**; wgpu-hal picks the GL target from the descriptor
|
||||||
|
(`(false, 1) => TEXTURE_2D`), so under GLES that array was a
|
||||||
|
`GL_TEXTURE_2D` bound to the shader's `sampler2DArray`, the unit was
|
||||||
|
incomplete, every `textureSample` returned (0,0,0,1), and
|
||||||
|
`draw_glyph`'s `color.a *= texel.a` filled the quad. `MIN_ARRAY_LAYERS
|
||||||
|
= 2` in `iris/core/src/render/texture.rs`, with a `debug_assert!` at
|
||||||
|
`create_array_texture`. Vulkan (the phone, the desktop's default
|
||||||
|
backend) was never affected. Reproduce the class in seconds without an
|
||||||
|
emulator: `iris`'s `force-gles` feature now switches the **desktop**
|
||||||
|
backend too -- `./run-headless.sh transcript --shot /tmp/x.png -- -p
|
||||||
|
transcript-ui --features iris/force-gles`.
|
||||||
|
|
||||||
|
- [ ] **The bench report pane draws over the transcript rows instead of
|
||||||
|
replacing them.** Visible on the emulator for the first time now that
|
||||||
|
glyphs render there (`/tmp/emu-final.png`, 2026-09-06): after a bench
|
||||||
|
run the report's lines and the transcript's occupy the same rows in the
|
||||||
|
top third of the screen, both legible, neither on top. Pre-existing --
|
||||||
|
the same overlap is in a screenshot taken before the move-slot fix -- so
|
||||||
|
it is its own item, most likely the report pane not masking or not
|
||||||
|
claiming its region.
|
||||||
|
|
||||||
|
## Found by P1b (2026-09-06), all with a headless repro
|
||||||
|
|
||||||
|
Each was found by looking at `iris/run-headless.sh transcript -- -p
|
||||||
|
transcript-ui` rather than at a diff, and each is worked around in
|
||||||
|
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
|
||||||
|
has the fuller account.
|
||||||
|
|
||||||
|
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
|
||||||
|
children a slot out of step.** Each child drew its content one sibling's
|
||||||
|
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
|
||||||
|
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
|
||||||
|
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
|
||||||
|
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
|
||||||
|
removing the inner `Span` fixes it, and so does removing the children's
|
||||||
|
own `Pad`; the background `Stack`, the `Sized` wrappers and the
|
||||||
|
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
|
||||||
|
`reposition` fault f5b8893 fixed -- it survives that commit. The
|
||||||
|
workaround costs the group the 4dp inset its Compose counterpart holds
|
||||||
|
its cards off the edge by, so this is worth fixing.
|
||||||
|
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
|
||||||
|
The panel is drawn and the text inside it is not. A markdown fence does
|
||||||
|
the same to a `TextEdit` and is fine, so it is the widget kind rather
|
||||||
|
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
|
||||||
|
fixed, which means a long command is clipped rather than pannable.
|
||||||
|
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
|
||||||
|
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
|
||||||
|
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
|
||||||
|
nothing on screen says it was cut. Whichever end is cut has to be a
|
||||||
|
choice when this lands: a path is identified by its tail, a command by
|
||||||
|
its head.
|
||||||
|
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
|
||||||
|
because a chevron from a font is a glyph a system font may not have --
|
||||||
|
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
|
||||||
|
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
|
||||||
|
monospace face as a result. A real fix needs a line/path primitive;
|
||||||
|
iris has rects, text and textures only.
|
||||||
|
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
|
||||||
|
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
|
||||||
|
a card registers. Compose's `SelectionContainer` covers tool output,
|
||||||
|
which is the text people most want to copy. Needs a key for "the nth
|
||||||
|
text of this row" that a card can mint without colliding with a
|
||||||
|
message's blocks.
|
||||||
|
|
||||||
## Build (for the port)
|
## Build (for the port)
|
||||||
|
|
||||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||||
@@ -373,3 +866,92 @@ do not duplicate it there.
|
|||||||
redundant. Decide after the layout change lands, by writing a button
|
redundant. Decide after the layout change lands, by writing a button
|
||||||
both ways and keeping the one that is shorter to explain; delete the
|
both ways and keeping the one that is shorter to explain; delete the
|
||||||
other rather than keeping two ways.
|
other rather than keeping two ways.
|
||||||
|
|
||||||
|
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
||||||
|
|
||||||
|
- [x] **A third length kind beside relative and pixels, so display scales
|
||||||
|
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
|
||||||
|
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
|
||||||
|
additionally rasterises at the resolved (physical) size instead of
|
||||||
|
scaling a low-resolution bitmap afterward, which was making text blurry.
|
||||||
|
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
|
||||||
|
too; transcript-ui's row/composer padding and one example migrated.
|
||||||
|
`em` was not added — nothing in this pass needed a text-relative unit,
|
||||||
|
and `dp`'s own doc says why it and physical pixels are kept as separate
|
||||||
|
fields rather than one the caller pre-multiplies. Not yet verified on
|
||||||
|
Iris's own phone at two densities (this pass had no device) — see
|
||||||
|
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
|
||||||
|
check. Iris's words: "another length type similar to absolute &
|
||||||
|
relative, so instead there would be relative, pixels, and another unit
|
||||||
|
like em or whatever is standard. That way different display scales
|
||||||
|
should just work." Today a length is either a fraction of the parent
|
||||||
|
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
|
||||||
|
roughly a third of its intended size until the P0 fixes applied the
|
||||||
|
display's scale factor globally. That global scale is a stopgap for the
|
||||||
|
benchmark; the real shape is a unit resolved against the display's
|
||||||
|
density at layout time — Android's `dp` / CSS's reference pixel is the
|
||||||
|
standard (1 unit = 1/160 in), with `em` as the text-relative option —
|
||||||
|
so a widget author writes `16.dp()` once and never sees the scale.
|
||||||
|
Done when: `Length` (or whatever the enum is called) has the third
|
||||||
|
variant; every place that resolves a length takes the density; the
|
||||||
|
examples and `transcript-ui` use the new unit for text sizes, padding
|
||||||
|
and control sizes; the emulator at two densities and the phone draw the
|
||||||
|
same layout at the same physical size. After the bench setup is
|
||||||
|
finished, before P1 draws any new screen.
|
||||||
|
|
||||||
|
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
||||||
|
|
||||||
|
- [x] **Streaming a delta into a long message costs a full text layout of
|
||||||
|
that message.** **Done 2026-09-06** -- a row is a column of one
|
||||||
|
`TextEdit` per markdown block (`client_core::markdown_blocks`,
|
||||||
|
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
|
||||||
|
keeps every earlier block's layout. A block is the selection unit now
|
||||||
|
(`Selection`'s `SelKey`); selection across blocks and rows still works,
|
||||||
|
checked on the emulator with a real long-press drag. Pass condition met
|
||||||
|
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
|
||||||
|
a delta into a 100-paragraph reply redraws the same widget count as one
|
||||||
|
into a one-paragraph reply (30 either way). Emulator stream phase, same
|
||||||
|
AVD before and after: **p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99
|
||||||
|
342.6 -> 137.4ms, worst 403.6 -> 143.0ms**, 202 -> 293 frames in the same
|
||||||
|
21 seconds. docs/RUST.md's Task B box has the detail and the two dead
|
||||||
|
ends. **The phone is the measurement that decides it** -- these are
|
||||||
|
emulator numbers and only the ratio transfers.
|
||||||
|
|
||||||
|
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
||||||
|
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
||||||
|
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
||||||
|
the last row, but that row is the growing message, and replacing it
|
||||||
|
re-renders its markdown and re-shapes the entire paragraph run through
|
||||||
|
parley on every event. Compose pays a reparse (8.6 ms mean) for the
|
||||||
|
same event. What "done" looks like: a streamed delta re-lays out only
|
||||||
|
the block it lands in (the last paragraph or code block), with earlier
|
||||||
|
blocks' layouts kept -- which needs a row to be a column of per-block
|
||||||
|
`Text`s rather than one `TextEdit` for the whole message, or parley's
|
||||||
|
layout to be split at block boundaries; measured by the stream phase's
|
||||||
|
p50 dropping below Compose's on the phone. Do this after the four bench
|
||||||
|
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
||||||
|
closed, since they are what make the run unrepresentative today.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-07 (build from ed04d4c)
|
||||||
|
|
||||||
|
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
|
||||||
|
them."** Two screenshots of the bench app's transcript at the top
|
||||||
|
edge, both wrong in opposite directions: in one, rows scrolled above
|
||||||
|
the viewport are still drawn and bleed *through* the header bar
|
||||||
|
(`version = "0.1.0"` and a paragraph visible behind "Run benchmark /
|
||||||
|
Copy report / Diagnostics"), so the list's mask is not clipping at
|
||||||
|
the header's bottom edge; in the other, scrolled a little further,
|
||||||
|
the row that straddles the top edge is not drawn at all -- black from
|
||||||
|
the header down to "You", where the previous shot showed a paragraph
|
||||||
|
-- so a row is culled as soon as its *top* leaves the viewport rather
|
||||||
|
than when its *bottom* does. Suspects: the list's visible-range test
|
||||||
|
(`iris/src/widget/list.rs`) comparing a row's top against the
|
||||||
|
viewport top; the mask region for the transcript set from the
|
||||||
|
window rather than from the area under the header; and the two-phase
|
||||||
|
provisional/real draw noted in `03c6be8`'s header-duplicate
|
||||||
|
investigation, which was never root-caused and has the same shape.
|
||||||
|
Reproduce at layer 1 of the test rig: a headless screen with a row
|
||||||
|
straddling the top edge must place that row, and a primitive above
|
||||||
|
the header's bottom must be masked. Fix both with one rule: a row is
|
||||||
|
drawn if any part of it intersects the viewport, and the viewport is
|
||||||
|
the list's own region.
|
||||||
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
|
|||||||
conditions, so the remaining slack was accepted rather than chased
|
conditions, so the remaining slack was accepted rather than chased
|
||||||
further.
|
further.
|
||||||
|
|
||||||
|
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||||
|
|
||||||
|
Iris asked for a third length kind beside `abs` (physical pixels) and
|
||||||
|
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
||||||
|
independent length unit" — after the P0 phone pass found 16px text
|
||||||
|
drawing at roughly a third size on a real phone. The fix that shipped
|
||||||
|
first (RUST.md's P0 box) was a global stopgap: divide the whole window
|
||||||
|
into a "logical" coordinate space (physical ÷ `content_scale`) and let
|
||||||
|
the shader's NDC mapping stretch it back up onto the real framebuffer.
|
||||||
|
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
|
||||||
|
small, pre-stretch size and then stretched onto more physical pixels than
|
||||||
|
it has texels for is blurry, which is exactly what Iris's next report
|
||||||
|
said.
|
||||||
|
|
||||||
|
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
|
||||||
|
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
|
||||||
|
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
|
||||||
|
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
|
||||||
|
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
|
||||||
|
desktop backend has no per-monitor density wired up yet and stays at
|
||||||
|
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||||
|
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
||||||
|
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
|
||||||
|
`UiRenderState::reposition` itself). This also meant the Android
|
||||||
|
boundary's global logical-space stopgap could come out entirely: window
|
||||||
|
size, touch coordinates and insets are physical pixels again, matching
|
||||||
|
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
||||||
|
per-length work the global divide used to do for everything at once.
|
||||||
|
|
||||||
|
**Text is the case that needed more than the `Len` plumbing.** A widget's
|
||||||
|
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
|
||||||
|
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
|
||||||
|
shape` now takes `density` directly and multiplies `font_size`/
|
||||||
|
`line_height` (and any span override) by it before handing them to
|
||||||
|
parley — so the size that reaches both the line-breaker and the
|
||||||
|
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
|
||||||
|
the display's *physical* size, and the glyph atlas holds a bitmap at the
|
||||||
|
resolution it is actually shown at. `GlyphKey.size` already keys on the
|
||||||
|
resolved size, so a cache entry is naturally per-physical-size with no
|
||||||
|
further change. The one caller with no `Painter` to read density from
|
||||||
|
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
|
||||||
|
copy kept directly on `TextData` (`TextData::density`) instead — an
|
||||||
|
accepted duplication rather than threading a `Painter` into every input
|
||||||
|
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
|
||||||
|
already makes for the Diagnostics page.
|
||||||
|
|
||||||
|
**What did not change**: `rel`/`rest` are unaffected (already
|
||||||
|
resolution-independent, a fraction of the parent). `Span::gap` and
|
||||||
|
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
|
||||||
|
on them the same as any other size; a bare number is still `abs`,
|
||||||
|
physical pixels, unchanged.
|
||||||
|
|
||||||
## For IRIS.md
|
## For IRIS.md
|
||||||
|
|
||||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||||
@@ -895,3 +947,95 @@ When this lands, copy this entry into `IRIS.md` (newest first):
|
|||||||
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
> design, the move-offset mechanism this shipped alongside, and the file
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
> list.
|
> list.
|
||||||
|
|
||||||
|
## Masks with a shape (decided 2026-09-07, not yet built)
|
||||||
|
|
||||||
|
Iris, on the code block's scrolling: "the code block scrolling currently
|
||||||
|
masks in an inner rectangle. Ideally masks should have a shape
|
||||||
|
associated with them, rounded rectangle being one of them, and/or
|
||||||
|
another widget you can select, so that the mask becomes the parent
|
||||||
|
container with rounded edges. Make sure alpha works properly with it,
|
||||||
|
eg. on the corners where alpha should be decreased / multiplied."
|
||||||
|
|
||||||
|
**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and
|
||||||
|
a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the
|
||||||
|
rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`)
|
||||||
|
sets the painter's mask to its own region. Separately, `draw_rounded_rect`
|
||||||
|
already produces an anti-aliased rounded edge from
|
||||||
|
`distance_from_rect(pos, center, corner, radius)` with a half-pixel
|
||||||
|
`smoothstep`, and the border variant multiplies a second coverage in.
|
||||||
|
|
||||||
|
**Design** (revised the same day on Iris's two corrections: hit-testing
|
||||||
|
applies the shape too, and a mask should reference a primitive rather
|
||||||
|
than carry a copy of its shape).
|
||||||
|
|
||||||
|
1. **A mask is a reference to a primitive already drawn, plus how to
|
||||||
|
use it.** `Mask { kind, idx, flags, parent }`: the primitive's
|
||||||
|
binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
|
||||||
|
*alpha only* -- take the primitive's coverage and ignore its colour,
|
||||||
|
which is the default and the only mode until a need for another
|
||||||
|
appears), and the enclosing mask's slot for nesting. The fragment
|
||||||
|
stage evaluates the referenced primitive *at the masked pixel* --
|
||||||
|
for a `Rect`, the same `draw_rounded_rect` coverage from the same
|
||||||
|
SDF; for a texture or glyph, the sampled alpha -- and does
|
||||||
|
`color.a *= coverage`. Nothing about the shape is copied: a rounded
|
||||||
|
container's corner and its children's clipped corner are the same
|
||||||
|
primitive's arithmetic, and a texture mask (an alpha image as the
|
||||||
|
clip) works with no new shader path.
|
||||||
|
What this needs from the data layout: evaluating a primitive at an
|
||||||
|
arbitrary pixel means its placement (its spans and `move_idx`, today
|
||||||
|
vertex attributes) has to be readable from a storage buffer in the
|
||||||
|
fragment stage. If it is not already there, put it there once, for
|
||||||
|
every primitive, rather than keeping a second copy for masks -- the
|
||||||
|
vertex stage can read the same buffer. Textures: the shader binds one
|
||||||
|
image at a time (see `masks_layout`'s comment on why an image's own
|
||||||
|
bind group must not name the masks buffer), so a texture mask is
|
||||||
|
limited to what the fragment can sample without a bind-group switch:
|
||||||
|
the atlas, and the primitive's own bound image when the masked
|
||||||
|
primitive is drawn in the same image's batch. Say so at the flag.
|
||||||
|
2. **Nested masks chain and multiply, like moves.** `parent` walks up
|
||||||
|
the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
|
||||||
|
debug-assert on overflow and print the chain); coverages multiply,
|
||||||
|
so a pixel inside two feathered corners is dimmed by both, which is
|
||||||
|
what a compositor does and what "alpha should be multiplied" asks.
|
||||||
|
3. **`.masked()` points the mask at the current widget's own
|
||||||
|
primitives.** `Masked` stops describing a region: it records which
|
||||||
|
primitive(s) the wrapping widget drew this frame (the painter knows
|
||||||
|
-- it just allocated the slots) and sets the mask to reference them.
|
||||||
|
So a rounded `Rect` widget's `.masked()` clips its children to
|
||||||
|
itself by pointing at the rect it already draws; an image widget's
|
||||||
|
`.masked()` clips to its alpha. No radius or shape argument exists to
|
||||||
|
fall out of sync. When a widget draws more than one primitive (a
|
||||||
|
bordered rect is one primitive; a card with a stripe is two), the
|
||||||
|
mask references the *first* and the doc says so; a widget that wants
|
||||||
|
another names it.
|
||||||
|
4. **Hit-testing applies the shape.** A press is inside a masked
|
||||||
|
subtree only if the mask's coverage at that point is above one half.
|
||||||
|
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
|
||||||
|
-- one function in the shared crate, with the WGSL a transliteration
|
||||||
|
of it and a test that compares the two at a grid of points
|
||||||
|
(`headless` renders to a buffer and reads back, or the Rust version
|
||||||
|
is checked against the values the shader produced once and recorded).
|
||||||
|
For a texture, the CPU needs the alpha: keep the alpha channel of an
|
||||||
|
image used as a mask readable on the CPU (it was uploaded from CPU
|
||||||
|
memory; keeping the alpha plane is a quarter of the image), and read
|
||||||
|
it at the point. A masked corner that cannot be tapped and a masked
|
||||||
|
corner that is not drawn are then the same corner.
|
||||||
|
|
||||||
|
**Rejected.** A stencil buffer (a second pass per mask level and no
|
||||||
|
anti-aliasing); the scissor rectangle (rectangles only, no alpha);
|
||||||
|
rendering a masked subtree to an offscreen texture and compositing
|
||||||
|
(a texture allocation per mask, every frame it scrolls, on the phone).
|
||||||
|
|
||||||
|
**Pass conditions.** A headless test draws a rounded container with a
|
||||||
|
masked child that overhangs all four sides and asserts the child's
|
||||||
|
coverage at a corner pixel equals the container's own coverage there
|
||||||
|
(same primitive evaluated, so exactly equal, not approximately); a
|
||||||
|
nested-mask test asserts the product at a pixel inside both feathers; a
|
||||||
|
texture-mask test clips a rect to an alpha image and asserts a
|
||||||
|
transparent texel masks fully; a hit-test asserts a press in a
|
||||||
|
container's clipped corner misses and one just inside the curve hits,
|
||||||
|
and that the CPU SDF and the shader agree at a grid of points; a
|
||||||
|
`run-headless.sh --phone` screenshot of a scrolled code block shows
|
||||||
|
rounded corners with no square pixels poking out at the top and bottom
|
||||||
|
of the scrolled content. Record the commands in RUST.md when it lands.
|
||||||
@@ -147,12 +147,37 @@ turn.
|
|||||||
|
|
||||||
Spawn: `claude -p --verbose --input-format stream-json --output-format
|
Spawn: `claude -p --verbose --input-format stream-json --output-format
|
||||||
stream-json --permission-mode <mode>` in the chosen working directory, plus
|
stream-json --permission-mode <mode>` in the chosen working directory, plus
|
||||||
`--model`. Wire-format notes are pinned against CLI 2.1.237 in
|
`--model` and, where one has been chosen, `--effort`. Wire-format notes are
|
||||||
`session/claude.rs`'s module doc: permissions need the hidden
|
pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions
|
||||||
`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride
|
need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers
|
||||||
`updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
|
ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
|
||||||
are control requests.
|
are control requests.
|
||||||
|
|
||||||
|
**The thinking level is settled at launch** (added 2026-09-04, because it is
|
||||||
|
the largest saving available on a long session: output is about an eighth of
|
||||||
|
what a session costs and thinking is the bulk of output, against the ~1.5% that
|
||||||
|
is prose). The CLI's only two setting control requests are `set_model` and
|
||||||
|
`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no
|
||||||
|
way to ask a running process to think differently. `set_session_effort` is
|
||||||
|
therefore shaped like `set_session_cwd` rather than like `set_session_model`:
|
||||||
|
it records the level and **stops the process**, and the next message or Start
|
||||||
|
launches one that has it. It lives in the session settings dialog beside the
|
||||||
|
working directory for that reason, not on the session bar beside the model and
|
||||||
|
the mode, which do take effect mid-turn. `None` is a level in its own right --
|
||||||
|
the CLI's own default -- so the picker can return to it; a level this app named
|
||||||
|
as the default instead would be this app choosing one.
|
||||||
|
|
||||||
|
**What a new session starts at is `Config::default_effort`**, applied in
|
||||||
|
`spawn_session` rather than filled in by the spawn screen, so it holds for an
|
||||||
|
import and a bare API call as well. It is set by the spawn screen's own
|
||||||
|
picker, whose label says so: one control, where new sessions are made, rather
|
||||||
|
than a settings page for a single value. It is not on a provider, because
|
||||||
|
providers are discovered and the next rediscovery would erase it, and not on
|
||||||
|
the phone, because a second device would then spawn at a level nobody there
|
||||||
|
chose. `GET`/`POST /defaults` carry it, as a struct rather than a bare value
|
||||||
|
so the permission mode -- still hardcoded to `auto` on the spawn screen -- can
|
||||||
|
move there without a second route.
|
||||||
|
|
||||||
**`--resume` only ever runs when nothing else has that session open.** That
|
**`--resume` only ever runs when nothing else has that session open.** That
|
||||||
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
||||||
entry point, and the `Exited` correction below; two CLIs on one session file
|
entry point, and the `Exited` correction below; two CLIs on one session file
|
||||||
@@ -169,10 +194,33 @@ deliberate and easy to undo by accident:
|
|||||||
when the process restarts. That leaves the Claude driver as the odd one
|
when the process restarts. That leaves the Claude driver as the odd one
|
||||||
out rather than this one — the CLI's memory is a cache in front of the same
|
out rather than this one — the CLI's memory is a cache in front of the same
|
||||||
transcript. Resolve any inconsistency in this direction.
|
transcript. Resolve any inconsistency in this direction.
|
||||||
- **A llama session on an ssh host is refused.** The model is reached over
|
- **A llama session runs on whatever machine its setup names** (2026-09-04,
|
||||||
HTTP and forwarding that port is not built, so refusing beats silently
|
the last of phase 5). A transport is "run this" plus "reach this port", and
|
||||||
talking to the wrong machine. A transport is "run this" plus "reach this
|
the second half is `Transport::reserve_port` — the port the server binds
|
||||||
port", and only the first half exists.
|
*there* and the port that reaches it *here*, the same number locally —
|
||||||
|
carried by `Launch::reaching` onto the connection that already runs the
|
||||||
|
command. `llama-server` binds loopback on the far machine, so nothing is
|
||||||
|
served to its network. The far port is a guess from a range below the
|
||||||
|
ephemeral one, because no portable way to ask a machine for a free port
|
||||||
|
avoids racing the bind anyway; a collision is not silent, since the server
|
||||||
|
fails to bind and the readiness poll reports what its log said.
|
||||||
|
- **The model file lives on the machine that serves it** (2026-09-04). Each
|
||||||
|
setup names its own models directory (`SshConfig::models_dir`, default
|
||||||
|
`~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the
|
||||||
|
key on that machine — one round trip answering "at /abs/path" or "missing",
|
||||||
|
so a model that is not there is refused at the spawn rather than becoming a
|
||||||
|
server that never becomes ready. The spawn screen offers
|
||||||
|
`GET /setups/{id}/models`, that machine's list, rather than `GET /models`,
|
||||||
|
which is this backend's downloads. Downloading *to* another machine is
|
||||||
|
deliberately not built: a multi-gigabyte transfer with no progress
|
||||||
|
anywhere, and the file gets there however anything else on that machine
|
||||||
|
did.
|
||||||
|
- **The readiness poll watches the process, not only the port.** A model that
|
||||||
|
will not load, a port already taken, a flag an older build does not know:
|
||||||
|
all exit within a second and none will ever answer `/health`, so waiting
|
||||||
|
out the 300s timeout turned the server's own account of the problem into
|
||||||
|
"gave up". The failure carries the tail of `llama-server.log`, which on a
|
||||||
|
remote session is the only copy anybody reading the phone can see.
|
||||||
|
|
||||||
### Models (2026-08-28)
|
### Models (2026-08-28)
|
||||||
|
|
||||||
@@ -203,6 +251,13 @@ deliberate and easy to undo by accident:
|
|||||||
A driver says what to run; something above it turns that into a process.
|
A driver says what to run; something above it turns that into a process.
|
||||||
Otherwise transport knowledge sits inside a translator whose job is a wire
|
Otherwise transport knowledge sits inside a translator whose job is a wire
|
||||||
format, and every future driver has to remember to do the same.
|
format, and every future driver has to remember to do the same.
|
||||||
|
- **A forwarded launch gets a pty and every other one does not** (measured
|
||||||
|
2026-09-04). Killing the ssh client ends a CLI because it closes the stdin
|
||||||
|
that CLI is reading; `llama-server` never reads its stdin, so the same kill
|
||||||
|
left it running on the far machine with the model loaded — one orphan per
|
||||||
|
stopped session. With `-tt` the far side takes SIGHUP when the connection
|
||||||
|
goes. Its log then arrives through a line discipline, which nothing parses.
|
||||||
|
`-T` stays everywhere else, where a pty would rewrite the JSONL.
|
||||||
- **`command -v` follows ssh's non-login PATH**, which is narrower than an
|
- **`command -v` follows ssh's non-login PATH**, which is narrower than an
|
||||||
interactive shell's, so a binary somewhere unusual is invisible to
|
interactive shell's, so a binary somewhere unusual is invisible to
|
||||||
discovery. Point `command` at an absolute path.
|
discovery. Point `command` at an absolute path.
|
||||||
@@ -499,6 +554,25 @@ rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or
|
|||||||
the usage screen is open, and cache the last answer. It is undocumented, so
|
the usage screen is open, and cache the last answer. It is undocumented, so
|
||||||
`usage.rs` treats every field as optional and degrades rather than erroring.
|
`usage.rs` treats every field as optional and degrades rather than erroring.
|
||||||
|
|
||||||
|
**Per provider, not per machine (2026-09-04).** A machine is not what is
|
||||||
|
metered; the provider a session runs is. One machine offers echo, the Claude
|
||||||
|
CLI and a local model side by side, and only the second spends anything — so
|
||||||
|
pairing a session with a snapshot by machine alone drew the CLI's five-hour
|
||||||
|
window under every echo session on it, a quota that session cannot spend. A
|
||||||
|
session now names its meter (`usageProvider`, from
|
||||||
|
`DriverKind::usage_provider`, which `usage::providers_for` reads too, so the
|
||||||
|
two lists cannot disagree) and `GET /usage` is matched on machine *and*
|
||||||
|
provider. `None` is a session that meters nothing, and the phone draws
|
||||||
|
nothing at all for it — not a zero, and not "unknown".
|
||||||
|
|
||||||
|
`DriverKind::Echo` names a meter of its own that exists only when a test has
|
||||||
|
asked for one: `/usage` in an echo session sets an invented answer
|
||||||
|
(`usage::Fixture`), and with none set there is no snapshot and no bar. That
|
||||||
|
is what makes those screens' states reachable — a number near the top, a
|
||||||
|
window between blocks with no reset time, a machine nobody logged into, one
|
||||||
|
that could not be reached — without spending real quota to arrange them,
|
||||||
|
which is why none of them had ever been looked at.
|
||||||
|
|
||||||
**Per machine, not per backend (2026-08-29).** The credential store that
|
**Per machine, not per backend (2026-08-29).** The credential store that
|
||||||
matters is the one on the machine the session runs on, because that is the
|
matters is the one on the machine the session runs on, because that is the
|
||||||
account being billed — and in the layout this aims at, `ai-server` is on the
|
account being billed — and in the layout this aims at, `ai-server` is on the
|
||||||
@@ -522,6 +596,68 @@ always running. So absent means **not running**, and only a timestamp that
|
|||||||
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
|
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
|
||||||
is the one rule both readers go through.
|
is the one rule both readers go through.
|
||||||
|
|
||||||
|
### Auto-resume (2026-09-05)
|
||||||
|
|
||||||
|
**A session may pick itself back up when the account's usage limit lifts.**
|
||||||
|
Off unless somebody switched that session to it, because it spends quota the
|
||||||
|
moment quota exists and does so with nobody looking — that is not a thing a
|
||||||
|
default may decide. It sends one message, `continue` unless another was
|
||||||
|
typed, and then it is done; there is no retry loop around the conversation
|
||||||
|
itself.
|
||||||
|
|
||||||
|
**Running out of quota is a state, not an error.** `Event::LimitReached`
|
||||||
|
carries the dialect's reset time where it gave one, and recognising it
|
||||||
|
belongs to the driver — the Claude CLI ends the turn with `is_error` and
|
||||||
|
`Claude AI usage limit reached|1788546972`, and nothing above the driver
|
||||||
|
matches on a string. The transcript draws it as a divider, like a clear or a
|
||||||
|
compaction: what a reader scrolling back wants from it is why the
|
||||||
|
conversation stops at that line.
|
||||||
|
|
||||||
|
**The schedule is a plan to ask, never a plan to send.** Every reset time
|
||||||
|
available here is untrustworthy in the direction that matters: the dialect's
|
||||||
|
is written when the turn fails, and the endpoint's moves when the window
|
||||||
|
does. So the wait ends in a question to `usage.rs`, and only `ok` with no
|
||||||
|
window at 100% sends anything. A window still spent reschedules to *its own*
|
||||||
|
reset time — which is what makes a limit that lifts later than promised wait
|
||||||
|
longer, and one that lifts sooner resume sooner. A meter that cannot be
|
||||||
|
asked at all is a longer wait too, never a send: "we could not find out"
|
||||||
|
must not be able to produce the same action as "there is room".
|
||||||
|
|
||||||
|
Bounded, because something has to be: a day after the limit was hit the wait
|
||||||
|
stops and says so in the session's own transcript. A machine that can never
|
||||||
|
be asked would otherwise be retried for ever with nothing on screen saying
|
||||||
|
so.
|
||||||
|
|
||||||
|
The schedule is persisted on the session (`resume: Some(ScheduledResume)`),
|
||||||
|
not held in memory: a five-hour window routinely outlasts a backend restart,
|
||||||
|
and a wait forgotten across one is a session that silently never comes back.
|
||||||
|
`resume.rs` is the top layer — it holds the manager and the monitor and
|
||||||
|
neither holds it — which is what lets the decision be a pure function of a
|
||||||
|
snapshot and a clock. The pump reports limits downward on a broadcast, for
|
||||||
|
the reason `Shared` exists: the pump runs underneath the manager.
|
||||||
|
|
||||||
|
**Exercised with echo, never with a real account.** `/limit [minutes]` in an
|
||||||
|
echo session reports the same event a real driver does, and `/usage` sets
|
||||||
|
what the meter answers — deliberately two commands, because the two
|
||||||
|
disagreeing is the state the whole design is about. The loop was driven end
|
||||||
|
to end that way on 2026-09-05: the wait moved from the dialect's two minutes
|
||||||
|
to the meter's seven when the meter changed its mind, and the message went
|
||||||
|
out on the first check after the meter came back under the limit.
|
||||||
|
|
||||||
|
### Subagents (2026-09-05)
|
||||||
|
|
||||||
|
**A subagent is a second transcript owned by a session, in the same event
|
||||||
|
model, with no process and no controls of its own.** Full design and wire
|
||||||
|
shape in `SUBAGENTS.md`, kept separate because the app half is being built
|
||||||
|
against it in parallel and it is the shared contract between the two. The
|
||||||
|
one-paragraph reason: a session's Task-tool helpers already speak the common
|
||||||
|
event model on the parent's own stdout (each line carrying
|
||||||
|
`parent_tool_use_id`), so giving each one its own small transcript — same
|
||||||
|
file format, same paging routes, same SSE stream, reused by addressing rather
|
||||||
|
than by copying — costs a routing step in the translator and a registry
|
||||||
|
(`session/subagent.rs`) rather than a second session type with a driver, a
|
||||||
|
process and a config entry it does not need.
|
||||||
|
|
||||||
### HTTP surface
|
### HTTP surface
|
||||||
|
|
||||||
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
|
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
|
||||||
@@ -800,8 +936,9 @@ Noticed and deliberately not fixed, so they are not re-found from scratch.
|
|||||||
|
|
||||||
Phases 1–3 (the skeleton pipe, the full Claude driver, the usage screen) done
|
Phases 1–3 (the skeleton pipe, the full Claude driver, the usage screen) done
|
||||||
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
|
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
|
||||||
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28.
|
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28,
|
||||||
The file explorer and the transcript cache followed in September. What is
|
except for the remote `llama-server` and its port forward, which landed
|
||||||
|
2026-09-04. The file explorer and the transcript cache followed in September. What is
|
||||||
left is real-phone/WireGuard bring-up, which is operational rather than code.
|
left is real-phone/WireGuard bring-up, which is operational rather than code.
|
||||||
|
|
||||||
Each phase ended runnable and verified against the real thing. The backend
|
Each phase ended runnable and verified against the real thing. The backend
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Review: iris changes since 0e46293
|
||||||
|
|
||||||
|
Scope: `git diff 0e46293..HEAD -- iris/ client-core/` (58 files, +5224/-226).
|
||||||
|
Read-only review; no source changed. Ordered likely-bug, then invariant
|
||||||
|
guards, then rules, then tests/docs.
|
||||||
|
|
||||||
|
## Likely bugs
|
||||||
|
|
||||||
|
1. **`iris/transcript-ui/src/lib.rs:152-160` (`RowDiff::Rebuild` arm of
|
||||||
|
`TranscriptScreen::apply`) never unregisters the rows it drops from
|
||||||
|
`Selection`, so a stale `WeakWidget<TextEdit>` outlives the widget it
|
||||||
|
points to and the next touch on *any* row panics.**
|
||||||
|
`Selection::rows: BTreeMap<RowKey, WeakWidget<TextEdit>>` documents its
|
||||||
|
own contract at `selection.rs:69-71`: "every addition here needs its
|
||||||
|
removal ... called when `List` evicts the row." The `ReplaceLast` arm
|
||||||
|
above it honours this (`lib.rs:143-145`, `self.selection.borrow_mut()
|
||||||
|
.unregister(old_key)` when the key changes). The `Rebuild` arm calls
|
||||||
|
`(self.list)(rsc).clear()` and rebuilds every row from `new_rows`, but
|
||||||
|
never touches `self.selection` — any key present in `old_rows` and
|
||||||
|
*absent* from `new_rows` (exactly what `group_tool_runs` regrouping two
|
||||||
|
separate tool-call rows into one produces — see `diff_tests::
|
||||||
|
a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback`,
|
||||||
|
which tests the diff decision but not `apply` itself) is left in
|
||||||
|
`self.rows` pointing at a widget `List::clear()` just freed.
|
||||||
|
`TextEditable::edit` (`iris/src/widget/text/edit.rs:582-587`) resolves
|
||||||
|
that handle with `ui.widgets.get_mut(self).unwrap()` — an unconditional
|
||||||
|
panic on the freed slot. `Selection::begin` (`selection.rs:88-101`)
|
||||||
|
iterates *every* registered row (`w.edit(ui).deselect()`) on an
|
||||||
|
ordinary fresh press, so the crash fires on the next tap anywhere in
|
||||||
|
the transcript after a regroup, not only on a tap targeting the
|
||||||
|
orphaned row.
|
||||||
|
Fix: give `Selection` a way to reconcile against the row set that
|
||||||
|
survived a rebuild (e.g. `Selection::retain(&self, keys: &BTreeSet<RowKey>)`
|
||||||
|
removing everything else, called from the `Rebuild` arm before
|
||||||
|
rebuilding), or simplest — call `self.selection.borrow_mut()` cleared
|
||||||
|
the same way `List::clear()` clears the list, then let the rebuild's
|
||||||
|
`push_row` calls re-`register` everything as they already do.
|
||||||
|
|
||||||
|
## Guarded invariants missing
|
||||||
|
|
||||||
|
2. **`iris/src/widget/list.rs:751` (`List::place`) indexes/expects on
|
||||||
|
`slot` with no assertion that it exists.** `slot_widget` (`:563-575`)
|
||||||
|
panics via `.expect(...)` for a sentinel with no widget set, and does
|
||||||
|
an unchecked `&self.items[s as usize]` for a real index — a bare
|
||||||
|
"index out of bounds" with no context if `place` is ever reached with a
|
||||||
|
stale slot. Every current caller happens to derive `slot` from
|
||||||
|
`repair_anchor`/`prev_slot`/`next_slot`, which already check existence,
|
||||||
|
but that invariant is enforced by convention across three call sites,
|
||||||
|
not by the function that depends on it. Add
|
||||||
|
`debug_assert!(self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}");`
|
||||||
|
at the top of `place`.
|
||||||
|
3. **`iris/src/widget/list.rs:426` (`List::fling`) and `sense.rs`'s
|
||||||
|
`FlingCalculator::distance`/`duration`/`position_at` never check that
|
||||||
|
the incoming velocity is finite.** A `NaN`/`inf` velocity (a
|
||||||
|
`VelocityTracker::velocity()` divide-by-near-zero span, or a caller
|
||||||
|
passing a raw device value straight through) propagates through
|
||||||
|
`deceleration_for`'s `.ln()` silently — the fling either never settles
|
||||||
|
(`settled_on_schedule` compares against a `NaN` `duration()`, which is
|
||||||
|
always `false`) or jumps to `NaN` positions with nothing on screen
|
||||||
|
saying why. Add `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||||
|
`List::fling` and `FlingCalculator::new`/`distance`.
|
||||||
|
4. **`iris/src/sense.rs:592-604` (`VelocityTracker::velocity`) has no
|
||||||
|
assertion that samples are chronological.** `add_sample` trusts its
|
||||||
|
caller's `Instant` ordering; a caller that samples out of order (a
|
||||||
|
restored/replayed gesture, a test) would silently produce a negative
|
||||||
|
`span` handled only by the `span <= 0.0 => 0.0` catch-all, masking the
|
||||||
|
bug that produced it rather than surfacing it. Add
|
||||||
|
`debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last))`
|
||||||
|
in `add_sample`.
|
||||||
|
5. **`iris/core/src/render/frame_report.rs:247-252` (`mark_phase`) has no
|
||||||
|
assertion that phases are pushed in non-decreasing `start_index`
|
||||||
|
order.** `phase_stats`'s slicing (`:274`, `idx >= phase.start_index &&
|
||||||
|
idx < end_index`) silently produces an empty or nonsensical slice for
|
||||||
|
an out-of-order phase rather than surfacing the misuse — cheap to add
|
||||||
|
given `self.phases.last()` is already in scope:
|
||||||
|
`debug_assert!(self.phases.last().is_none_or(|p| self.total_frames >= p.start_index));`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
6. **Two mechanisms answer "what row selection points at, still valid?"**
|
||||||
|
`Selection` relies on callers remembering to `unregister` (finding 1);
|
||||||
|
`List` relies on callers deriving slots only from already-checked
|
||||||
|
sources (finding 2). Both are the same class of problem — a derived
|
||||||
|
handle that silently outlives what it points to — solved ad hoc twice
|
||||||
|
rather than once. Not asking for a shared abstraction here, but the two
|
||||||
|
should at minimum cross-reference each other's doc comment so the next
|
||||||
|
caller who adds a third handle-into-`List`-rows type (the code rules'
|
||||||
|
"a rule that governs a set belongs to the set") finds both existing
|
||||||
|
examples.
|
||||||
|
7. **`iris/android-app/src/bench_client.rs:224-225` (`battery_line`)
|
||||||
|
calls `.min().unwrap()`/`.max().unwrap()` on `samples` guarded three
|
||||||
|
lines above by `if samples.is_empty()`, which is fine — but the guard
|
||||||
|
and the two unwraps are two statements apart with a `let mean = ...`
|
||||||
|
in between reading the same slice; a future edit reordering those
|
||||||
|
lines loses the guard's protection silently.** Low severity (this is
|
||||||
|
the bench tool, not the app), but worth a one-line comment tying the
|
||||||
|
unwraps back to the guard, or restructuring as
|
||||||
|
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`
|
||||||
|
pattern so the empty case can't be separated from the check by a future
|
||||||
|
edit.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
8. **No test exercises `TranscriptScreen::apply`'s `Rebuild` arm through
|
||||||
|
`Selection`.** `lib.rs`'s `diff_tests` module (`:284-379`) tests only
|
||||||
|
the pure `diff_rows` decision function, never `apply` itself wired to a
|
||||||
|
real `Selection`; `selection.rs`'s own tests (`a_missed_press_start_
|
||||||
|
recovers_on_the_next_pressing_frame`, `unregister_forgets_the_row_and_
|
||||||
|
clears_a_matching_anchor`) never go through `apply`/`List::clear`
|
||||||
|
either. This is exactly the gap that let finding 1 through: the two
|
||||||
|
pieces (`apply`'s fallback, `Selection`'s registration contract) are
|
||||||
|
each tested in isolation and never together. Add: build a
|
||||||
|
`TranscriptScreen`, force a `RowDiff::Rebuild` (two adjacent tool-call
|
||||||
|
rows regrouping, per the existing `diff_tests` case), then call
|
||||||
|
`selected_text`/simulate a fresh press on a surviving row and assert no
|
||||||
|
panic.
|
||||||
|
9. **`iris/src/widget/list.rs`'s fling tests check total distance and the
|
||||||
|
start/end clamp but not the speed profile in between.**
|
||||||
|
`fling_moves_the_list_and_then_settles`/`fling_distance_is_positive_
|
||||||
|
toward_the_end` only assert the fling started, moved in the right
|
||||||
|
direction, and eventually stopped — none checks that
|
||||||
|
`tick_fling`'s per-tick delta is *monotonically decreasing* once past
|
||||||
|
the fling's peak (the property `fling_calculator_tests::position_at_
|
||||||
|
is_monotonic_and_clamped_past_the_end` already checks one level down,
|
||||||
|
for `FlingCalculator` alone, but never through `List::tick_fling`'s own
|
||||||
|
`scroll`/`anchor.offset` accumulation). A regression that made
|
||||||
|
`tick_fling` apply the *total* distance every tick instead of the
|
||||||
|
incremental one, for instance, would still pass both existing tests
|
||||||
|
(final position and direction are unaffected by how the interior ticks
|
||||||
|
split it up) while being wildly wrong every intermediate frame.
|
||||||
|
10. **`iris/src/widget/list.rs::replacing_the_last_row_stays_pinned_to_
|
||||||
|
the_bottom` and its sibling test `replace_back`'s effect on the
|
||||||
|
displayed row, never that the row it evicted is actually gone from
|
||||||
|
`heights`/`extents`.** Both tests assert the *new* row's position;
|
||||||
|
neither asserts `old.key` is absent from `list_ref.heights`/`extents`
|
||||||
|
after the replace (the "stale primitive" class finding 1 is a
|
||||||
|
production instance of). A cheap addition: assert
|
||||||
|
`!list_ref.heights.contains_key(&old.key)` after `replace_back` in the
|
||||||
|
existing test, since `old.key` is already returned to the test as
|
||||||
|
`evicted`... (`lib.rs` calls it that way; the `list.rs` test would need
|
||||||
|
to capture the key from `old` similarly.)
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
No missing `IRIS.md` entry found for a *public* API change in this diff —
|
||||||
|
`List::fling`/`VelocityTracker`/`FlingCalculator`, `List::
|
||||||
|
anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/
|
||||||
|
`late_at_hz`, `UiRenderNode::new`'s `Result` change, `Len::dp`, and
|
||||||
|
`List::replace_back`/`clear`/`TranscriptScreen::apply` all have entries.
|
||||||
|
The `List::replace_back`/`clear`/`TranscriptScreen::apply` entry
|
||||||
|
(`docs/IRIS.md:526`) predates this review's finding 1 and does not mention
|
||||||
|
`Selection`'s registration contract at all — once finding 1 is fixed,
|
||||||
|
that entry should gain a line noting what the fix requires of a caller
|
||||||
|
that keeps its own row-keyed side table (the same shape `Selection` is),
|
||||||
|
so the next such table doesn't reproduce the same gap.
|
||||||
|
|
||||||
|
## Fixed, 2026-09-06
|
||||||
|
|
||||||
|
All ten findings addressed after the `DragGesture` merge (`selection.rs`
|
||||||
|
was rewritten by that merge, but finding 1's shape and location were
|
||||||
|
unchanged — `TranscriptScreen::apply`'s `Rebuild` arm, `iris/transcript-ui/
|
||||||
|
src/lib.rs`).
|
||||||
|
|
||||||
|
1. **Fixed.** `Selection::clear()` (`selection.rs`) drops `rows` and
|
||||||
|
`anchor`, called from `apply`'s `Rebuild` arm right before
|
||||||
|
`List::clear()` — `push_row` re-`register`s whatever survives as it
|
||||||
|
rebuilds each row, the "simplest" fix option the finding named.
|
||||||
|
2. **Fixed.** `debug_assert!(self.slot_exists(slot), ...)` at the top of
|
||||||
|
`List::place` (`iris/src/widget/list.rs`).
|
||||||
|
3. **Fixed.** `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||||
|
`List::fling`, and `debug_assert!(velocity.is_finite())` in
|
||||||
|
`FlingCalculator::distance`/`duration` (`iris/src/sense.rs`).
|
||||||
|
`position_at` calls both, so it inherits the guard rather than needing
|
||||||
|
its own.
|
||||||
|
4. **Fixed.** `debug_assert!` on chronological sample order in
|
||||||
|
`VelocityTracker::add_sample` (`iris/src/sense.rs`).
|
||||||
|
5. **Fixed.** `debug_assert!` on non-decreasing `start_index` in
|
||||||
|
`FrameReport::mark_phase` (`iris/core/src/render/frame_report.rs`).
|
||||||
|
6. **Fixed (doc cross-reference only, as asked).** `Selection::register`'s
|
||||||
|
doc now points at `List::place`'s `slot_exists` assertion and vice
|
||||||
|
versa isn't needed since finding 2's fix already cites this file in
|
||||||
|
its own comment; both are grep-able on "docs/REVIEW-2026-09-06.md" and
|
||||||
|
on each other's type names.
|
||||||
|
7. **Fixed.** `bench_client.rs::battery_line` restructured to
|
||||||
|
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`,
|
||||||
|
so the empty-guard and the two lookups can no longer be separated by a
|
||||||
|
future edit.
|
||||||
|
8. **Fixed.** `transcript-ui`'s new `apply_tests::
|
||||||
|
a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection`
|
||||||
|
(`lib.rs`) builds a real `TranscriptScreen`, forces the same regroup
|
||||||
|
shape `diff_tests` already covers at the pure-diff level, calls `apply`,
|
||||||
|
and then `Selection::begin` on a surviving row — which panicked before
|
||||||
|
fix 1, resolving a `WeakWidget` `List::clear()` had just freed.
|
||||||
|
9. **Fixed.** `list.rs`'s new `tick_fling_applies_shrinking_incremental_
|
||||||
|
deltas` flings toward the end from `jump_to_start` and asserts each
|
||||||
|
tick's `extents[&0]` delta is no larger than the previous one — would
|
||||||
|
fail against a `tick_fling` that applied the total spline distance
|
||||||
|
every tick instead of the incremental slice, which the two pre-existing
|
||||||
|
fling tests cannot catch.
|
||||||
|
10. **Fixed.** `list.rs`'s new `replace_back_forgets_the_evicted_keys_own_
|
||||||
|
height` replaces row 4 with a row keyed `100` (the two existing
|
||||||
|
`replace_back` tests always reuse the same key, so neither actually
|
||||||
|
exercises the removal) and asserts `heights` no longer contains the
|
||||||
|
evicted key.
|
||||||
|
|
||||||
|
Docs: `docs/IRIS.md`'s 2026-09-05 `List::replace_back`/`clear`/
|
||||||
|
`TranscriptScreen::apply` entry now has a line on what the fix requires of
|
||||||
|
a caller with its own row-keyed side table, naming `Selection` as the
|
||||||
|
example and dating the fix.
|
||||||
|
|
||||||
|
Verification run alongside the rest of this pass's checks: `cargo fmt
|
||||||
|
--all`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`
|
||||||
|
from `iris/` — see docs/RUST.md's plan box for the pass/fail and any
|
||||||
|
caveats from this same session.
|
||||||
@@ -33,3 +33,14 @@ one in place when it turns out to need a decision.
|
|||||||
that would work today, for Claude sessions, and it is the option that was
|
that would work today, for Claude sessions, and it is the option that was
|
||||||
not chosen.
|
not chosen.
|
||||||
|
|
||||||
|
|
||||||
|
## From Iris's phone log export, 2026-09-07 (Compose app)
|
||||||
|
|
||||||
|
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
|
||||||
|
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
|
||||||
|
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
|
||||||
|
range was built with end before start while highlighting a tool
|
||||||
|
input. Found in the per-package system log she exported; the tool
|
||||||
|
input that triggered it is not in the log. Reproduce by fuzzing
|
||||||
|
`highlighted` with inputs whose token boundaries collapse, and guard
|
||||||
|
the range construction.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Compose bench report from Iris's phone, 2026-09-06
|
||||||
|
|
||||||
|
The Compose half of P0 (RUST.md), run by Iris on her own phone and pasted
|
||||||
|
back verbatim. The iris half's report goes beside it in this directory
|
||||||
|
when it exists. Her caveat, worth keeping with the numbers: "I don't think
|
||||||
|
this is entirely fair because the UI for iris is more minimal" -- the
|
||||||
|
Compose screen also draws the usage bar, the status row and tool cards,
|
||||||
|
which the iris bench screen does not yet. Her impression of the iris build
|
||||||
|
before its first-touch bug: "it already feels very smooth so far".
|
||||||
|
|
||||||
|
What to read first: the phone runs at 120 Hz, so the budget is 8.3 ms;
|
||||||
|
`late` is measured against that. Compose's tail is the streaming phase --
|
||||||
|
`markdown reparsed while streaming: 396, 8.5ms mean, 25.8ms worst` and
|
||||||
|
`record: one block: 398, 6.3ms mean, 19.9ms worst` -- which is exactly the
|
||||||
|
path iris's `TranscriptScreen::apply` (replace the last row only) is meant
|
||||||
|
to beat. Process CPU over the run is 20.9 s of a 38.5 s run; peak RSS
|
||||||
|
587 MB; battery current mean 419 mA.
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-app render report
|
||||||
|
device: Pixel 9 Pro XL (Google), Android 17
|
||||||
|
build: release
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
43 events, 41 rows, 93 units loaded
|
||||||
|
viewport 1333px, 2 units visible
|
||||||
|
on screen: the list's own 0px, AssistantMsg 24520px
|
||||||
|
0 tool calls and 0 groups open
|
||||||
|
|
||||||
|
frames:
|
||||||
|
1613 frames over 38.5s at 120Hz (8.3ms budget)
|
||||||
|
late: 742 (46.0%)
|
||||||
|
total p50 7.7ms p90 29.2ms p99 41.1ms
|
||||||
|
waited p50 0.5ms p90 12.9ms p99 27.2ms
|
||||||
|
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||||
|
anim p50 1.1ms p90 5.8ms p99 9.5ms
|
||||||
|
layout p50 0.0ms p90 0.1ms p99 0.2ms
|
||||||
|
draw p50 0.4ms p90 15.9ms p99 27.9ms
|
||||||
|
sync p50 0.1ms p90 0.5ms p99 1.0ms
|
||||||
|
issue p50 1.1ms p90 1.7ms p99 3.0ms
|
||||||
|
swap p50 0.4ms p90 0.5ms p99 0.7ms
|
||||||
|
gpu p50 1.8ms p90 2.1ms p99 6.6ms
|
||||||
|
|
||||||
|
where the draw phase went:
|
||||||
|
draw phase 3.83ms per frame, of which:
|
||||||
|
the transcript: 0.25ms (measure 0.15, place 0.10, record 0.00)
|
||||||
|
everything else: 3.58ms (93%)
|
||||||
|
|
||||||
|
work since this was last copied:
|
||||||
|
draw: the whole transcript: 12, 0.2ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
grouped tool runs: 398, 10.3ms total, 0.0ms mean, 0.1ms worst
|
||||||
|
markdown cut into pieces: 1, 0.0ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
markdown parsed while composing: 7, 3.0ms total, 0.4ms mean, 0.5ms worst
|
||||||
|
markdown ready: 46
|
||||||
|
markdown reparsed while streaming: 396, 3384.6ms total, 8.5ms mean, 25.8ms worst
|
||||||
|
markdown warmed: 1, 1.4ms total, 1.4ms mean, 1.4ms worst
|
||||||
|
measure: the whole transcript: 957, 248.7ms total, 0.3ms mean, 15.7ms worst
|
||||||
|
message composed: 403
|
||||||
|
message cut into parts: 1, 0.1ms total, 0.1ms mean, 0.1ms worst
|
||||||
|
place: the whole transcript: 1319, 162.4ms total, 0.1ms mean, 1.3ms worst
|
||||||
|
record: one block: 398, 2498.7ms total, 6.3ms mean, 19.9ms worst
|
||||||
|
session screen recomposed: 413
|
||||||
|
status row recomposed: 1
|
||||||
|
unit composed: 538
|
||||||
|
units flattened: 399, 44.3ms total, 0.1ms mean, 0.5ms worst
|
||||||
|
usage bar recomposed: 413
|
||||||
|
|
||||||
|
bench:
|
||||||
|
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||||
|
process CPU time over this run: 20907ms
|
||||||
|
peak RSS: 587356kB
|
||||||
|
battery current: mean -418509µA over 39 samples (min -1988281, max -107812)
|
||||||
|
```
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Compose bench v2 report from Iris's phone, 2026-09-06
|
||||||
|
|
||||||
|
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
|
||||||
|
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
|
||||||
|
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
|
||||||
|
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
|
||||||
|
`late` is judged against whichever it was, so compare a run with a run at
|
||||||
|
the same rate. The iris v2 report goes beside this when it exists.
|
||||||
|
|
||||||
|
What it says: fling, type and keyboard are all essentially clean on
|
||||||
|
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
|
||||||
|
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
|
||||||
|
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
|
||||||
|
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
|
||||||
|
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
|
||||||
|
571 mA over 126 samples.
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-app render report
|
||||||
|
device: Pixel 9 Pro XL (Google), Android 17
|
||||||
|
build: release
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
108 events, 26 rows, 58 units loaded
|
||||||
|
viewport 1531px, 2 units visible
|
||||||
|
on screen: the list's own 0px, AssistantMsg 24520px
|
||||||
|
0 tool calls and 0 groups open
|
||||||
|
|
||||||
|
per phase:
|
||||||
|
fling: 3278 frames over 32.7s
|
||||||
|
late: 4 (0.1%)
|
||||||
|
total p50 5.5ms p90 8.7ms p99 11.6ms
|
||||||
|
worst 49.0ms
|
||||||
|
stream: 1041 frames over 21.3s
|
||||||
|
late: 436 (41.9%)
|
||||||
|
total p50 13.4ms p90 31.7ms p99 42.5ms
|
||||||
|
worst 52.5ms
|
||||||
|
type: 2446 frames over 61.5s
|
||||||
|
late: 23 (0.9%)
|
||||||
|
total p50 7.3ms p90 13.2ms p99 16.5ms
|
||||||
|
worst 38.9ms
|
||||||
|
keyboard: 358 frames over 10.0s
|
||||||
|
late: 0 (0.0%)
|
||||||
|
total p50 6.3ms p90 8.6ms p99 11.1ms
|
||||||
|
worst 12.0ms
|
||||||
|
|
||||||
|
frames:
|
||||||
|
7122 frames over 125.5s at 60Hz (16.7ms budget)
|
||||||
|
late: 463 (6.5%)
|
||||||
|
total p50 6.0ms p90 13.8ms p99 34.0ms
|
||||||
|
waited p50 0.5ms p90 1.1ms p99 19.9ms
|
||||||
|
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||||
|
anim p50 0.7ms p90 4.5ms p99 7.6ms
|
||||||
|
layout p50 0.1ms p90 0.1ms p99 0.2ms
|
||||||
|
draw p50 0.7ms p90 2.9ms p99 21.9ms
|
||||||
|
sync p50 0.1ms p90 0.2ms p99 0.6ms
|
||||||
|
issue p50 1.4ms p90 2.4ms p99 3.2ms
|
||||||
|
swap p50 0.4ms p90 0.8ms p99 1.2ms
|
||||||
|
gpu p50 1.5ms p90 2.1ms p99 6.6ms
|
||||||
|
|
||||||
|
where the draw phase went:
|
||||||
|
draw phase 1.74ms per frame, of which:
|
||||||
|
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
|
||||||
|
everything else: 1.51ms (86%)
|
||||||
|
|
||||||
|
work since this was last copied:
|
||||||
|
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
|
||||||
|
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
|
||||||
|
markdown ready: 323
|
||||||
|
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
|
||||||
|
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
|
||||||
|
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
|
||||||
|
message composed: 397
|
||||||
|
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
|
||||||
|
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
|
||||||
|
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
|
||||||
|
session screen recomposed: 1630
|
||||||
|
status row recomposed: 1
|
||||||
|
transcript page from server: 10
|
||||||
|
unit composed: 927
|
||||||
|
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
|
||||||
|
|
||||||
|
bench:
|
||||||
|
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
|
||||||
|
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||||
|
type: 600 characters inserted then deleted, one per 50ms
|
||||||
|
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||||||
|
process CPU time over this run: 69564ms
|
||||||
|
peak RSS: 577452kB
|
||||||
|
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# iris bench report from Iris's phone, 2026-09-06, before the phone fixes
|
||||||
|
|
||||||
|
Build 46246ea (Vulkan, bench v1: 24-swipe scroll loop then 400 streamed
|
||||||
|
events), run by Iris on her Pixel 9 Pro XL before the first-touch wipe,
|
||||||
|
the missing bold faces, the density scale and the status-bar inset were
|
||||||
|
fixed -- so the rows were drawn at roughly a third of their intended size
|
||||||
|
and the run may have included frames after the wipe. Preliminary, kept
|
||||||
|
because it is the first iris number from real hardware. Compare with
|
||||||
|
`compose-phone-2026-09-06.md`, taken on the same phone with the same
|
||||||
|
fixture and gesture loop.
|
||||||
|
|
||||||
|
Reading it: the phone is 120 Hz (8.3 ms budget). `janky%` here counts
|
||||||
|
frames over 16.7 ms, so it is not Compose's `late` (over 8.3 ms). Like for
|
||||||
|
like: iris p50 6.2 ms vs Compose 7.7 ms; p90 32.0 vs 29.2; p99 42.1 vs
|
||||||
|
41.1. `cpu_p50=4.7ms` is iris's own per-frame CPU work on the phone,
|
||||||
|
against 0.2-0.4 ms on the emulator's x86 cores. Process CPU 15.6 s vs
|
||||||
|
20.9 s, but over a shorter run (692 frames vs 1613 -- iris only renders on
|
||||||
|
change and had no fling settle time), so per-second CPU is not directly
|
||||||
|
comparable; peak RSS 365 MB vs 587 MB. Battery current mean 563 mA vs
|
||||||
|
419 mA is the one figure that reads worse, and it is the least
|
||||||
|
comparable: 22 samples vs 39, over runs of different length and different
|
||||||
|
idle share. Bench v2's per-phase accounting is what makes these comparable.
|
||||||
|
|
||||||
|
```
|
||||||
|
iris bench report
|
||||||
|
frames=692 janky%=32.37 p50=6.2ms p90=32.0ms p99=42.1ms worst=52.6ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=4.7ms gpu_wait_p50=1.3ms (redraw-start-to-submit vs. submit-to-after-present)
|
||||||
|
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||||
|
process CPU time over this run: 15554ms
|
||||||
|
peak RSS: 365328kB
|
||||||
|
battery current: mean -563493µA over 22 samples (min -1807812, max -132812)
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# iris bench v2 report from Iris's phone, 2026-09-06
|
||||||
|
|
||||||
|
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
|
||||||
|
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
|
||||||
|
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
|
||||||
|
compare the millisecond percentiles, not `late`.
|
||||||
|
|
||||||
|
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
|
||||||
|
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
|
||||||
|
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
|
||||||
|
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
|
||||||
|
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
|
||||||
|
|
||||||
|
Iris's observations on the same run: "the scrolling is not similar at
|
||||||
|
all. It does not fling for me yet [with a finger], and the test also seems
|
||||||
|
to give it a constant velocity and abruptly stop it at some point. Also
|
||||||
|
unsure what's going on in that image with the compaction" -- her
|
||||||
|
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
|
||||||
|
overlapping, and once more below the composer bar: primitives of a
|
||||||
|
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||||
|
header drawn twice after a keyboard resize.
|
||||||
|
|
||||||
|
**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in
|
||||||
|
that sentence was right and the location was not -- `UiRenderState::
|
||||||
|
draw_inner` read the `needs_redraw` mark without consuming it and skipped
|
||||||
|
the branch that frees a redrawn widget's old primitives. docs/RUST.md's
|
||||||
|
"Stale primitives, the phone's half" box has the full account, the guard
|
||||||
|
(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run
|
||||||
|
that exercises it.
|
||||||
|
|
||||||
|
```
|
||||||
|
iris bench report
|
||||||
|
per phase:
|
||||||
|
fling: 1783 frames over 53.2s
|
||||||
|
late: 104 (5.8%)
|
||||||
|
total p50 3.8ms p90 6.9ms p99 12.6ms
|
||||||
|
worst 29.1ms
|
||||||
|
stream: 401 frames over 21.3s
|
||||||
|
late: 306 (76.3%)
|
||||||
|
total p50 18.2ms p90 35.8ms p99 43.1ms
|
||||||
|
worst 43.8ms
|
||||||
|
type: 1202 frames over 65.7s
|
||||||
|
late: 309 (25.7%)
|
||||||
|
total p50 7.2ms p90 9.2ms p99 11.2ms
|
||||||
|
worst 15.3ms
|
||||||
|
keyboard: 9 frames over 9.7s
|
||||||
|
late: 9 (100.0%)
|
||||||
|
total p50 12.0ms p90 12.9ms p99 12.9ms
|
||||||
|
worst 12.9ms
|
||||||
|
|
||||||
|
frames:
|
||||||
|
3395 frames over 149.9s at 120Hz (8.3ms budget)
|
||||||
|
late: 728 (21.4%)
|
||||||
|
total p50 5.0ms p90 10.9ms p99 36.6ms
|
||||||
|
worst 43.8ms
|
||||||
|
cpu_p50 2.0ms gpu_wait_p50 2.6ms
|
||||||
|
|
||||||
|
bench:
|
||||||
|
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
|
||||||
|
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||||
|
type: 600 characters inserted then deleted, one per 50ms
|
||||||
|
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||||||
|
process CPU time over this run: 40603ms
|
||||||
|
peak RSS: 379156kB
|
||||||
|
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -150,6 +150,19 @@ pub enum Event {
|
|||||||
ToolEnd {
|
ToolEnd {
|
||||||
id: String,
|
id: String,
|
||||||
output: String,
|
output: String,
|
||||||
|
/// Whether the tool reported that the call *failed*, from the
|
||||||
|
/// CLI's own `is_error` on the `tool_result`.
|
||||||
|
///
|
||||||
|
/// Added 2026-09-06 with the tool-call cards (RUST.md's P1b),
|
||||||
|
/// because without it a result is the only thing a card has and a
|
||||||
|
/// failed call is drawn as confidently as a successful one -- the
|
||||||
|
/// missing state, not a wrong one. `#[serde(default)]` so a
|
||||||
|
/// transcript written before this field, or a peer on an older
|
||||||
|
/// build, reads back as "not reported to have failed" rather than
|
||||||
|
/// failing to parse; that is the same claim the field's absence
|
||||||
|
/// used to make implicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
is_error: bool,
|
||||||
},
|
},
|
||||||
/// An image the session produced or was sent, saved under the session
|
/// An image the session produced or was sent, saved under the session
|
||||||
/// dir and referenced by id; the phone fetches it by URL.
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
@@ -305,6 +318,25 @@ pub enum Event {
|
|||||||
/// it, which is why this is written down rather than left to be inferred
|
/// it, which is why this is written down rather than left to be inferred
|
||||||
/// from a second example that does not exist.
|
/// from a second example that does not exist.
|
||||||
Cleared,
|
Cleared,
|
||||||
|
/// The account behind this session has no quota left, so the turn stopped
|
||||||
|
/// without finishing.
|
||||||
|
///
|
||||||
|
/// Its own event rather than an [`Event::Error`] carrying the dialect's
|
||||||
|
/// sentence, because two things act on it that cannot read English: the
|
||||||
|
/// transcript draws it as a state the session is in rather than as a
|
||||||
|
/// failure of something it did, and `crate::resume` schedules the message
|
||||||
|
/// that picks the work back up. Recognising it belongs to the driver, which
|
||||||
|
/// is the only layer that knows its dialect's wording -- above here nothing
|
||||||
|
/// matches on strings.
|
||||||
|
///
|
||||||
|
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
|
||||||
|
/// said the limit was hit without saying when it lifts. Nothing here
|
||||||
|
/// invents one -- what the wait is actually decided against is the usage
|
||||||
|
/// endpoint, and this is the hint that starts the waiting.
|
||||||
|
LimitReached {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
resets_at: Option<f64>,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -964,9 +965,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlib"
|
name = "dlib"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
@@ -1757,6 +1758,7 @@ dependencies = [
|
|||||||
"fxhash",
|
"fxhash",
|
||||||
"image",
|
"image",
|
||||||
"parley",
|
"parley",
|
||||||
|
"pollster",
|
||||||
"swash",
|
"swash",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
@@ -2873,9 +2875,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3056,6 +3058,15 @@ version = "0.8.52"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-input"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"iris",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3644,6 +3655,18 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3892,9 +3915,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-backend"
|
name = "wayland-backend"
|
||||||
version = "0.3.12"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
|
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
@@ -3906,9 +3929,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-client"
|
name = "wayland-client"
|
||||||
version = "0.31.12"
|
version = "0.31.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
|
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"rustix 1.1.3",
|
"rustix 1.1.3",
|
||||||
@@ -3940,9 +3963,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols"
|
name = "wayland-protocols"
|
||||||
version = "0.32.10"
|
version = "0.32.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
|
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -3965,9 +3988,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols-wlr"
|
name = "wayland-protocols-wlr"
|
||||||
version = "0.3.10"
|
version = "0.3.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
|
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -3978,9 +4001,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
@@ -3989,9 +4012,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-sys"
|
name = "wayland-sys"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
|
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dlib",
|
"dlib",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ wgpu = { workspace = true }
|
|||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
accesskit = { workspace = true }
|
accesskit = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||||
|
# For diagnostics visible through android_logger (or whatever logger the
|
||||||
|
# app crate installs) -- this crate never installs one itself. Not in the
|
||||||
|
# android-only block below any more: the lines that matter most are in
|
||||||
|
# shared widget code, which the host backend compiles too.
|
||||||
|
log = "0.4.28"
|
||||||
|
|
||||||
# winit everywhere except Android; android-view (below) is what stands in
|
# winit everywhere except Android; android-view (below) is what stands in
|
||||||
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||||
@@ -53,9 +58,6 @@ accesskit_android = "0.8.0"
|
|||||||
# for `android/insets.rs`'s own id -> state map -- the same reason
|
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||||
# android-view's own `PEER_MAP` carries one.
|
# android-view's own `PEER_MAP` carries one.
|
||||||
send_wrapper = "0.6.0"
|
send_wrapper = "0.6.0"
|
||||||
# For diagnostics visible through android_logger, wherever the app crate
|
|
||||||
# installs it -- this crate never installs a logger itself.
|
|
||||||
log = "0.4.28"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
||||||
@@ -64,7 +66,11 @@ log = "0.4.28"
|
|||||||
# default) or virgl's GLES path, without a second env-var plumbing path that
|
# default) or virgl's GLES path, without a second env-var plumbing path that
|
||||||
# nothing on this machine can hand to an already-launched Android process
|
# nothing on this machine can hand to an already-launched Android process
|
||||||
# (there is no `am start` environment and no system-property reader here to
|
# (there is no `am start` environment and no system-property reader here to
|
||||||
# add one). Android-only; `android/render.rs` is the only reader.
|
# add one). Read by `android/render.rs` and, so the GLES path can be
|
||||||
|
# reproduced on a machine with a real GPU rather than only in the emulator,
|
||||||
|
# by `default/render.rs`:
|
||||||
|
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||||
|
# --features iris/force-gles
|
||||||
force-gles = []
|
force-gles = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -83,13 +89,21 @@ name = "message_list"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
|
members = [
|
||||||
|
"core",
|
||||||
|
"macro",
|
||||||
|
"tabs-ui",
|
||||||
|
"transcript-ui",
|
||||||
|
"transcript-fixture",
|
||||||
|
"rig-input",
|
||||||
|
"desktop-app",
|
||||||
|
]
|
||||||
# android-app pulls in android-view, which needs the NDK sysroot to link
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
# workspace, since it has no `[workspace]` table of its own and this
|
# workspace, since it has no `[workspace]` table of its own and this
|
||||||
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||||
# -t x86_64 -P 26 build`.
|
# -t x86_64 -P 29 build`.
|
||||||
exclude = ["android-app"]
|
exclude = ["android-app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -745,6 +745,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -1768,9 +1769,12 @@ dependencies = [
|
|||||||
"client-core",
|
"client-core",
|
||||||
"event-model",
|
"event-model",
|
||||||
"iris",
|
"iris",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tabs-ui",
|
"tabs-ui",
|
||||||
|
"tokio",
|
||||||
|
"transcript-fixture",
|
||||||
"transcript-ui",
|
"transcript-ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1783,6 +1787,7 @@ dependencies = [
|
|||||||
"fxhash",
|
"fxhash",
|
||||||
"image",
|
"image",
|
||||||
"parley",
|
"parley",
|
||||||
|
"pollster",
|
||||||
"swash",
|
"swash",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
@@ -3860,6 +3865,17 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -29,9 +29,30 @@ log = "0.4.28"
|
|||||||
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
||||||
tabs-ui = { path = "../tabs-ui", optional = true }
|
tabs-ui = { path = "../tabs-ui", optional = true }
|
||||||
transcript-ui = { path = "../transcript-ui", optional = true }
|
transcript-ui = { path = "../transcript-ui", optional = true }
|
||||||
|
# P0's bench build only: the fixture and the folded screen both bench
|
||||||
|
# clients open, shared with the headless harness and the desktop window
|
||||||
|
# (docs/RUST.md's "Three test layers").
|
||||||
|
transcript-fixture = { path = "../transcript-fixture", optional = true }
|
||||||
client-core = { path = "../../client-core", optional = true }
|
client-core = { path = "../../client-core", optional = true }
|
||||||
event-model = { path = "../../event-model", optional = true }
|
event-model = { path = "../../event-model", optional = true }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
||||||
|
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
|
||||||
|
# process CPU time, matching `libc::getrusage`'s mention in that box over
|
||||||
|
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
|
||||||
|
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
||||||
|
# at 0.2.179) -- this makes it a direct dependency at the same version
|
||||||
|
# rather than a second, possibly-drifting resolution.
|
||||||
|
libc = { version = "0.2.179", optional = true }
|
||||||
|
# P0's bench build only: the scroll animation and the streaming phase are
|
||||||
|
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
|
||||||
|
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
|
||||||
|
# the battery sampler is a second, concurrent task on that same runtime
|
||||||
|
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
|
||||||
|
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
|
||||||
|
# doesn't call. Version matches the one `iris`'s own dependency tree already
|
||||||
|
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
|
||||||
|
# two.
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["tabs-screen"]
|
default = ["tabs-screen"]
|
||||||
@@ -41,6 +62,14 @@ transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model",
|
|||||||
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
||||||
# on the feature this forwards to.
|
# on the feature this forwards to.
|
||||||
force-gles = ["iris/force-gles"]
|
force-gles = ["iris/force-gles"]
|
||||||
|
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
|
||||||
|
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
# build type drives, run here against `transcript-ui`'s real screen with no
|
||||||
|
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
|
||||||
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
|
# listed, which is how this crate's build command names both explicitly.
|
||||||
|
bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|||||||
@@ -13,15 +13,74 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.android.demo"
|
applicationId = "dev.iris.android.demo"
|
||||||
minSdk = 26
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
targetSdk = 34
|
// 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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
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 {
|
buildTypes {
|
||||||
debug {
|
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 {
|
compileOptions {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
package dev.iris.android.demo;
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ClipData;
|
||||||
|
import android.content.ClipboardManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
import org.linebender.android.rustview.RustView;
|
import org.linebender.android.rustview.RustView;
|
||||||
|
|
||||||
@@ -16,7 +27,7 @@ public final class IrisView extends RustView {
|
|||||||
protected native long newViewPeer(Context context);
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
native void applyWindowInsetsNative(
|
native void applyWindowInsetsNative(
|
||||||
long peer, int left, int top, int right, int bottom, int imeBottom);
|
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||||
|
|
||||||
native void unregisterInsetsNative(long peer);
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
@@ -24,8 +35,9 @@ public final class IrisView extends RustView {
|
|||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
|
void applyWindowInsets(
|
||||||
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
|
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -33,4 +45,112 @@ public final class IrisView extends RustView {
|
|||||||
unregisterInsetsNative(mViewPeer);
|
unregisterInsetsNative(mViewPeer);
|
||||||
super.onDetachedFromWindow();
|
super.onDetachedFromWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the Rust side (iris/src/android/view.rs's
|
||||||
|
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
|
||||||
|
* drawing -- an ordinary instance method rather than a `native` one,
|
||||||
|
* since this call is Rust reaching into Java rather than the other
|
||||||
|
* direction. Replaces the whole activity content with plain,
|
||||||
|
* selectable, scrollable text rather than leaving the last frame (or a
|
||||||
|
* blank surface) on screen with no way to report what happened:
|
||||||
|
* UI_RULES.md's "a failure is reported where it happened, and says
|
||||||
|
* what to do next." No dialog and no styling beyond what is needed to
|
||||||
|
* read and copy the text -- this path exists for exactly the crash it
|
||||||
|
* replaces, so it must not depend on anything that could itself fail
|
||||||
|
* to render.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bench build's keyboard diagnostics capture
|
||||||
|
* (`bench_client.rs`'s `on_insets_changed` /
|
||||||
|
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
|
||||||
|
* `PlatformHandle::show_diagnostics_overlay`): unlike
|
||||||
|
* `showRendererError` above, this adds a panel *over* this view
|
||||||
|
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
|
||||||
|
* running) rather than replacing the activity's content, and gives it
|
||||||
|
* a Copy button and a Close that removes the panel -- so it draws
|
||||||
|
* (and can be read) whether or not iris itself is still putting
|
||||||
|
* anything on screen, without abandoning the session that produced
|
||||||
|
* it. Runs on the UI thread regardless of which thread calls it,
|
||||||
|
* since the call comes from a background task (a delayed capture
|
||||||
|
* after the keyboard opens), and touching the view tree off the UI
|
||||||
|
* thread is undefined.
|
||||||
|
*/
|
||||||
|
void showDiagnosticsOverlay(String report) {
|
||||||
|
Context context = getContext();
|
||||||
|
if (!(context instanceof Activity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Activity activity = (Activity) context;
|
||||||
|
activity.runOnUiThread(() -> {
|
||||||
|
ViewGroup parent = (ViewGroup) getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||||
|
if (existing != null) {
|
||||||
|
parent.removeView(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
float density = activity.getResources().getDisplayMetrics().density;
|
||||||
|
int pad = (int) (16 * density);
|
||||||
|
|
||||||
|
LinearLayout overlay = new LinearLayout(activity);
|
||||||
|
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||||
|
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
overlay.setBackgroundColor(0xEE000000);
|
||||||
|
overlay.setPadding(pad, pad, pad, pad);
|
||||||
|
|
||||||
|
TextView text = new TextView(activity);
|
||||||
|
text.setText(report);
|
||||||
|
text.setTextIsSelectable(true);
|
||||||
|
text.setTextColor(0xFFFFFFFF);
|
||||||
|
ScrollView scroll = new ScrollView(activity);
|
||||||
|
scroll.addView(text);
|
||||||
|
overlay.addView(scroll, new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||||
|
|
||||||
|
LinearLayout buttonRow = new LinearLayout(activity);
|
||||||
|
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
buttonRow.setPadding(0, pad, 0, 0);
|
||||||
|
|
||||||
|
Button copy = new Button(activity);
|
||||||
|
copy.setText("Copy");
|
||||||
|
copy.setOnClickListener(v -> {
|
||||||
|
ClipboardManager clipboard =
|
||||||
|
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
|
if (clipboard != null) {
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Button close = new Button(activity);
|
||||||
|
close.setText("Close");
|
||||||
|
close.setOnClickListener(v -> parent.removeView(overlay));
|
||||||
|
buttonRow.addView(copy);
|
||||||
|
buttonRow.addView(close);
|
||||||
|
overlay.addView(buttonRow);
|
||||||
|
|
||||||
|
parent.addView(overlay, new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,9 @@ import android.app.Activity;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||||
@@ -31,17 +33,107 @@ public final class MainActivity extends Activity {
|
|||||||
setContentView(layout);
|
setContentView(layout);
|
||||||
view.requestFocus();
|
view.requestFocus();
|
||||||
|
|
||||||
|
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
|
||||||
|
// `logcat` showed the platform's own IME open/resize happening
|
||||||
|
// while `setOnApplyWindowInsetsListener` fired only once, at
|
||||||
|
// attach, and never again for a pure keyboard toggle -- a plain
|
||||||
|
// (non-edge-to-edge) window is only guaranteed that one initial
|
||||||
|
// dispatch; `adjustResize` handling the IME entirely by resizing
|
||||||
|
// the window is not itself a trigger for a fresh one. Opting into
|
||||||
|
// edge-to-edge (a platform call, API 30+, no new dependency) is
|
||||||
|
// what makes the system redeliver insets on every change,
|
||||||
|
// including the ones this activity actually cares about --
|
||||||
|
// `getSystemWindowInset*` below is unaffected by this (it has
|
||||||
|
// always reported the raw system-bar/IME overlap regardless of
|
||||||
|
// who consumes it), so the on-screen bars and the padding Rust
|
||||||
|
// already derives from those four numbers are unchanged; only the
|
||||||
|
// callback's firing became reliable.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The keyboard's height arrives twice, over two different
|
||||||
|
// paths, and the phone needs the second one** (Iris, 2026-09-07:
|
||||||
|
// the emulator pushed the composer up and her Pixel did not).
|
||||||
|
// `setOnApplyWindowInsetsListener` is the platform's *settled*
|
||||||
|
// answer; `WindowInsetsAnimation.Callback` is the running one, and
|
||||||
|
// an IME that animates in delivers every intermediate height
|
||||||
|
// through the callback with the static dispatch arriving only at
|
||||||
|
// the ends -- on some devices only at `onEnd`. Registering both
|
||||||
|
// means neither device depends on the other's timing, and it is
|
||||||
|
// also what makes the push-up *animate* with the keyboard rather
|
||||||
|
// than jump when it lands.
|
||||||
|
//
|
||||||
|
// The two do not disagree, because they are the same call with the
|
||||||
|
// same numbers read out of whichever `WindowInsets` is current.
|
||||||
|
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
|
||||||
|
// nothing keeps the ordinary dispatch running underneath.
|
||||||
|
// `onEnd` re-reads the root's insets rather than trusting the last
|
||||||
|
// `onProgress`: an animation interrupted mid-flight never delivers
|
||||||
|
// its final frame, which is exactly the fault the Compose app hit
|
||||||
|
// (AGENTS.md, "the composer can get stuck floating above the
|
||||||
|
// bottom of the screen").
|
||||||
|
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) -> {
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
sendInsets((IrisView) v, insets);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||||
|
* place that reads these fields, so the static dispatch and the
|
||||||
|
* animation callback above cannot come to report different things. */
|
||||||
|
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||||
int left = insets.getSystemWindowInsetLeft();
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
int top = insets.getSystemWindowInsetTop();
|
int top = insets.getSystemWindowInsetTop();
|
||||||
int right = insets.getSystemWindowInsetRight();
|
int right = insets.getSystemWindowInsetRight();
|
||||||
int bottom = insets.getSystemWindowInsetBottom();
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
// **Two separate answers, because they are separate questions**
|
||||||
|
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||||
|
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||||
|
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||||
|
// pass sent the boolean *as* the height (0 or 1) because under
|
||||||
|
// plain `adjustResize` the window shrinks to make room and the ime
|
||||||
|
// inset therefore measures a zero overlap by construction -- true
|
||||||
|
// then, and no longer true now that this is an edge-to-edge window
|
||||||
|
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
|
||||||
|
// above for the devices below that), which is exactly the case
|
||||||
|
// where the system stops resizing and hands the app the real
|
||||||
|
// overlap instead. Sending 1 for it left the Rust side padding the
|
||||||
|
// composer by one physical pixel, so the keyboard covered the bar
|
||||||
|
// and the transcript alike.
|
||||||
|
//
|
||||||
|
// The visibility is still sent in its own right rather than
|
||||||
|
// inferred from `height > 0`: the two disagree during the
|
||||||
|
// keyboard's slide-in and -out (visible, height still climbing),
|
||||||
|
// and "is the IME up" drives the bench's own state machine
|
||||||
|
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||||
|
// reading as "closed" is a miscount.
|
||||||
int imeBottom = 0;
|
int imeBottom = 0;
|
||||||
|
int imeVisible = 0;
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
}
|
}
|
||||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
return insets;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Builds iris-android-app end to end: the cdylib (cargo ndk, straight into
|
||||||
|
# app/src/main/jniLibs/) then the APK (Gradle). 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`, unlike
|
||||||
|
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
|
||||||
|
# own doc) exists only to force the emulator off its default software
|
||||||
|
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
|
||||||
|
# iris's frame time goes") -- 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.
|
||||||
|
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
|
||||||
|
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
|
||||||
|
# run, and GLES is the backend the same box's own SwiftShader finding
|
||||||
|
# already flagged as the fragile one for this shader's storage buffers.
|
||||||
|
# Pass `--features "transcript-screen force-gles bench"` explicitly for
|
||||||
|
# an emulator backend-isolation run; never 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 app/src/main/jniLibs
|
||||||
|
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 app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||||
|
else
|
||||||
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GRADLE_TASK="assembleDebug"
|
||||||
|
APK_DIR="app/build/outputs/apk/debug"
|
||||||
|
APK_NAME="app-debug.apk"
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
GRADLE_TASK="assembleRelease"
|
||||||
|
APK_DIR="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
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -22,6 +22,14 @@ fn main() {
|
|||||||
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
|
||||||
|
// server at all -- `bench_client.rs` never references the `pinned`
|
||||||
|
// module this generates, so requiring a live server's host/port/token/
|
||||||
|
// CA to build it (as plain `transcript-screen` does, below) would be a
|
||||||
|
// pointless requirement for a build that talks to nothing.
|
||||||
|
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/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
|
||||||
|
# (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 app/build/outputs/apk/release/app-release.apk ]; then
|
||||||
|
APK=app/build/outputs/apk/release/app-release.apk
|
||||||
|
else
|
||||||
|
APK=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
|
||||||
|
|
||||||
|
# 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,999 @@
|
|||||||
|
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
|
||||||
|
//! the same fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
|
||||||
|
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||||
|
//! that measures the renderer rather than the data or the network.
|
||||||
|
//!
|
||||||
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||||
|
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||||
|
//! network half replaced by the checked-in fixture. Reading that fixture
|
||||||
|
//! and folding it into a screen is **`transcript-fixture`'s** job, not
|
||||||
|
//! this file's -- the same crate the headless harness and the
|
||||||
|
//! phone-shaped desktop window open, so all three measure one screen
|
||||||
|
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
|
||||||
|
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||||
|
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||||
|
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||||
|
//! this file exists to measure -- see docs/RUST.md's P0 box for the
|
||||||
|
//! before/after report.
|
||||||
|
|
||||||
|
use crate::bench_jni::PlatformHandle;
|
||||||
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, fold_event};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||||
|
/// implement the identical four phases -- see that box before changing any
|
||||||
|
/// constant here, since a mismatch would make the two reports stop
|
||||||
|
/// measuring the same thing while still looking like they do.
|
||||||
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||||
|
const STREAM_SECONDS: u64 = 20;
|
||||||
|
|
||||||
|
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
|
||||||
|
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
|
||||||
|
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
|
||||||
|
/// exact line even though the swipe loop it names no longer runs there
|
||||||
|
/// either (the fling phase replaced it); nothing here drives an actual
|
||||||
|
/// swipe with these any more.
|
||||||
|
const LEGACY_CYCLES: usize = 6;
|
||||||
|
|
||||||
|
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
|
||||||
|
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
|
||||||
|
/// tween can never exceed the distance/time it is given while a real
|
||||||
|
/// fling decays from an initial velocity the way a finger flick does.
|
||||||
|
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Type phase (v2): long, multisyllabic words so the composer actually
|
||||||
|
/// wraps and the transcript above it is pushed upward, typed and deleted
|
||||||
|
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
|
||||||
|
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
|
||||||
|
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
|
||||||
|
/// `KEYBOARD_HIDE_WAIT_MS`.
|
||||||
|
const KEYBOARD_CYCLES: usize = 5;
|
||||||
|
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||||
|
|
||||||
|
/// One animation step's target cadence -- close enough to 60Hz that a
|
||||||
|
/// fling/scroll is many small moves rather than one jump, so frames are
|
||||||
|
/// actually rendered along the way, and close enough that a `ctx.update`
|
||||||
|
/// closure's effect (only applied once the next frame callback drains the
|
||||||
|
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
|
||||||
|
/// when a later step in the same phase needs to read state back.
|
||||||
|
const ANIM_STEP_MS: u64 = 16;
|
||||||
|
|
||||||
|
/// How much of the screen a *filled* benchmark report may take before it
|
||||||
|
/// scrolls instead of growing -- roughly a third of a phone screen, the
|
||||||
|
/// share the pane used to reserve unconditionally. An empty report takes
|
||||||
|
/// nothing at all; see `new`'s comment at the tree it is used in.
|
||||||
|
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||||
|
|
||||||
|
pub struct BenchClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
report_display: WeakWidget<TextEdit>,
|
||||||
|
/// The top button row, in a `WidgetPtr` slot rather than added
|
||||||
|
/// directly (like `content`) so `on_insets_changed` can swap in a
|
||||||
|
/// version padded for the status bar once insets are known -- RUST.md's
|
||||||
|
/// P0 box, "the status-bar inset is not applied," found the row sitting
|
||||||
|
/// directly under it because nothing here read `insets().top` at all.
|
||||||
|
top_bar: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||||
|
/// clone, kept here only as the source a second run would need (the
|
||||||
|
/// button can be pressed more than once; `running` just stops overlap,
|
||||||
|
/// not repeat).
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
|
last_report: Option<String>,
|
||||||
|
running: bool,
|
||||||
|
/// The keyboard phase's own confirmation channel -- updated from
|
||||||
|
/// `on_insets_changed` (the platform's own answer for whether the IME
|
||||||
|
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
|
||||||
|
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
|
||||||
|
/// `ctx.update`, since neither side needs the widget tree for this.
|
||||||
|
ime_state: Arc<Mutex<ImeState>>,
|
||||||
|
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
||||||
|
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
||||||
|
/// first where it is not, so opening the keyboard fires this once
|
||||||
|
/// rather than on every insets update while it stays open (a rotation
|
||||||
|
/// or a status-bar change with the keyboard already up would otherwise
|
||||||
|
/// re-fire it).
|
||||||
|
keyboard_was_visible: bool,
|
||||||
|
/// The status-bar inset `top_bar` was last padded by -- see
|
||||||
|
/// `on_insets_changed`'s own comment for why this guards the rebuild.
|
||||||
|
last_top_pad: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
|
||||||
|
/// count real 0->visible / visible->0 transitions `on_insets_changed`
|
||||||
|
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
|
||||||
|
/// present an inferred value as a measured one. `run_keyboard_phase` reads
|
||||||
|
/// the counters before and after asking for a toggle and calls it
|
||||||
|
/// confirmed only if the count moved.
|
||||||
|
#[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(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
|
||||||
|
/// the syscall itself fails, which UI_RULES.md's "never present an
|
||||||
|
/// inferred value as a measured one" says to keep apart from a real (and
|
||||||
|
/// here, impossible) zero.
|
||||||
|
fn process_cpu_ms() -> Option<u64> {
|
||||||
|
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||||
|
// initialises on success; on failure it is never read.
|
||||||
|
unsafe {
|
||||||
|
let mut usage: libc::rusage = std::mem::zeroed();
|
||||||
|
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
||||||
|
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
||||||
|
Some(user_ms + sys_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
|
||||||
|
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
|
||||||
|
/// two reports' numbers mean the same thing.
|
||||||
|
fn peak_rss_kb() -> Option<u64> {
|
||||||
|
std::fs::read_to_string("/proc/self/status")
|
||||||
|
.ok()?
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmHWM:"))
|
||||||
|
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
||||||
|
.and_then(|n| n.trim().parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn battery_line(samples: &[i32]) -> String {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return " battery current: unavailable on this device".to_string();
|
||||||
|
}
|
||||||
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
|
// `min`/`max` are guarded by the `is_empty` check above, three lines
|
||||||
|
// up -- pairing the `Option` unwraps with the emptiness check right
|
||||||
|
// here (rather than two statements apart, with `mean` in between
|
||||||
|
// reading the same slice) is what keeps a future reorder from
|
||||||
|
// separating the guard from what it protects (docs/
|
||||||
|
// REVIEW-2026-09-06.md finding 7).
|
||||||
|
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(Color::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);
|
||||||
|
// The report pane is sized to whatever report it is holding, not
|
||||||
|
// to a share of the window: `rest(1)` here reserved a third of
|
||||||
|
// the screen for an *empty* `TextEdit` at every launch, which is
|
||||||
|
// what Iris's 2026-09-06 11:39 phone report described as "the app
|
||||||
|
// does not start with keyboard spacing correct" -- the composer
|
||||||
|
// two thirds down with black below it, nothing to do with the IME
|
||||||
|
// inset (measured: `iris insets:` reports bottom=63 ime_bottom=0
|
||||||
|
// at launch, while the `Message` field's own box sat 789px above
|
||||||
|
// the bottom of a 2282px surface -- exactly this pane's third).
|
||||||
|
// Capped and scrollable so a long report cannot take the screen
|
||||||
|
// back over, the same idiom `composer.rs` uses for the field.
|
||||||
|
// Above the transcript, not below it: the report is what the
|
||||||
|
// header's own "Run benchmark" button produces (UI_RULES.md --
|
||||||
|
// results appear where the action was started), and a pane under
|
||||||
|
// the composer would eat the navigation-bar clearance
|
||||||
|
// `set_bottom_inset` gives it.
|
||||||
|
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(tree);
|
||||||
|
|
||||||
|
// Startup log line (RUST.md's P0 box, "log once at startup ... the
|
||||||
|
// number of font families found, the default family resolved"):
|
||||||
|
// what font discovery actually found on this device, before
|
||||||
|
// anything is drawn.
|
||||||
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
|
log::info!(
|
||||||
|
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||||
|
bold={:?} italic={:?} mono={:?}",
|
||||||
|
font.families_found,
|
||||||
|
font.default_family,
|
||||||
|
font.default_mono_family,
|
||||||
|
font.regular_resolved,
|
||||||
|
font.bold_resolved,
|
||||||
|
font.italic_resolved,
|
||||||
|
font.mono_resolved,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 transcript_fixture::build_screen(rsc) {
|
||||||
|
Ok((opened, tree)) => {
|
||||||
|
client.items = opened.items;
|
||||||
|
client.stream_tail = opened.stream_tail;
|
||||||
|
(client.content)(rsc).set(tree);
|
||||||
|
client.screen = Some(opened.screen);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
|
||||||
|
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
||||||
|
/// field comment. Rebuilds the row rather than mutating a stored
|
||||||
|
/// `Padding` in place, since nothing here holds a handle to one --
|
||||||
|
/// but **only when `insets.top` actually changed**: this callback
|
||||||
|
/// also fires on every `ime_bottom` change (the keyboard sliding
|
||||||
|
/// in/out fires several intermediate insets updates), which has
|
||||||
|
/// nothing to do with the status bar, and rebuilding on every one of
|
||||||
|
/// those was the root cause of a real bug (found on Iris's phone,
|
||||||
|
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
|
||||||
|
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
|
||||||
|
/// `needs_redraw.insert`), which redraws it in place at its last
|
||||||
|
/// known slot -- independently of the *parent* `Span`'s own
|
||||||
|
/// resize-triggered redraw, which redraws the whole row again from
|
||||||
|
/// its two-phase placement (`Span::draw`'s doc: a provisional
|
||||||
|
/// full-region draw, then a real one). A `.set()` landing between
|
||||||
|
/// those two phases left one dirty-widget redraw's primitives
|
||||||
|
/// un-freed while the `Span`-driven redraw drew its own copy,
|
||||||
|
/// producing two live copies of the same three buttons in one frame
|
||||||
|
/// -- one at the header's real slot, one wherever `Span`'s
|
||||||
|
/// provisional phase happened to leave it (visibly inside the
|
||||||
|
/// transcript area), each still holding its own working `on(click)`
|
||||||
|
/// handlers, so a tap meant for whatever was under the stray copy
|
||||||
|
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
|
||||||
|
/// depends on changed removes the repeated `.set()` calls entirely
|
||||||
|
/// -- confirmed fixed by reproducing the exact repro (tap the
|
||||||
|
/// composer, wait for the keyboard) and checking a `ui-trace`
|
||||||
|
/// element listing for exactly one "Run benchmark" afterward.
|
||||||
|
///
|
||||||
|
/// Also two things downstream of the same `ime_bottom` transition:
|
||||||
|
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
||||||
|
/// doc -- the platform's own answer for whether the IME actually
|
||||||
|
/// opened or closed, rather than assumed from having called
|
||||||
|
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
|
||||||
|
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
|
||||||
|
/// surface is exactly the case a previous commit found wiped text,
|
||||||
|
/// and Iris needs a way to get a report off the phone even if that
|
||||||
|
/// (or some other keyboard-triggered regression) is still happening
|
||||||
|
/// on the build she is holding -- `capture_keyboard_diagnostics`
|
||||||
|
/// below fires ~500ms after the keyboard becomes visible, once per
|
||||||
|
/// keyboard opening, and shows its report in a plain overlay view
|
||||||
|
/// that draws independently of whatever iris itself is doing.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The composer bar sits directly on whichever of the IME or the
|
||||||
|
// navigation bar is currently the bottom of usable space -- see
|
||||||
|
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
|
||||||
|
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
|
||||||
|
// keyboard covers it, so the larger of the two is always the right
|
||||||
|
// answer without needing to know which is currently showing.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait after the keyboard becomes visible before capturing
|
||||||
|
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
||||||
|
/// still happening) and a couple of frames have all had time to land, per
|
||||||
|
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
||||||
|
/// on the way that nothing can observe" reasoning applied the other way:
|
||||||
|
/// this wants to observe the state *after* the transition settles, not
|
||||||
|
/// mid-flight.
|
||||||
|
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
|
type Rsc = AndroidRsc<BenchClient>;
|
||||||
|
|
||||||
|
/// 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: UiColor = UiColor::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.
|
||||||
|
///
|
||||||
|
/// **Backed by an opaque rect the full size of the row, not just the three
|
||||||
|
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
||||||
|
/// build a9232ac): "the header buttons have nothing behind them and
|
||||||
|
/// overlap the transcript text" -- before this, only each button's own
|
||||||
|
/// `rect(...)` painted anything, so the gaps between and around them (and
|
||||||
|
/// the status-bar strip above them) showed whatever was one layer back
|
||||||
|
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
||||||
|
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
||||||
|
/// density-correct size the transcript below was already using post-P0 --
|
||||||
|
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
||||||
|
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
||||||
|
/// function), and every size below moved from a bare number (physical
|
||||||
|
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||||
|
/// so the row's reserved height in the outer `Span::DOWN`
|
||||||
|
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||||
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||||
|
let run_rect = rect(Color::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(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
|
ctx.state.copy_report();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Copy report");
|
||||||
|
let copy = (
|
||||||
|
copy_rect,
|
||||||
|
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let diag_rect = rect(Color::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(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
||||||
|
|
||||||
|
(rect(HEADER_SURFACE), buttons)
|
||||||
|
.stack()
|
||||||
|
.height(dp(56))
|
||||||
|
.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) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
|
||||||
|
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
|
||||||
|
/// benchmark report uses) rather than a separate widget, so the
|
||||||
|
/// existing "Copy report" button and clipboard path work on whichever
|
||||||
|
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||||
|
/// so it's set here too rather than adding a second copy path.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
};
|
||||||
|
// The insets line goes in the pane, not just the log: Iris has no
|
||||||
|
// logcat on her phone, and "the keyboard does not push the
|
||||||
|
// composer up" cannot be told from "the listener never fired"
|
||||||
|
// without it (`AndroidUiState::insets_report`).
|
||||||
|
format!("{renderer}\n{}", self.android_state().insets_report())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
|
/// doc comment. **Logged only.** It used to also copy the report to
|
||||||
|
/// the clipboard unprompted and put it in the shell's overlay view,
|
||||||
|
/// from when the keyboard-inset callback was not firing at all and a
|
||||||
|
/// report could not be got off the phone any other way. Both are gone
|
||||||
|
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
|
||||||
|
/// `MainActivity.java`), and the overlay covered the whole screen on
|
||||||
|
/// *every* keyboard open with its own Copy/Close buttons underneath
|
||||||
|
/// the keyboard, so it could not be dismissed -- an interruption for
|
||||||
|
/// something nobody asked for, over an app you are trying to type
|
||||||
|
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
|
||||||
|
/// same text on demand, and `iris surface:`/`iris insets:` (view.rs)
|
||||||
|
/// carry the lifecycle a `logcat` pull actually needs.
|
||||||
|
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) {
|
||||||
|
let Some(report) = &self.last_report else {
|
||||||
|
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(platform) = &self.platform else {
|
||||||
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
|
||||||
|
/// then type, then keyboard, then the report -- run in-process for the
|
||||||
|
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
|
||||||
|
/// on a real phone, no agent that can drive one).
|
||||||
|
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 refresh_hz = platform
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.refresh_rate_hz())
|
||||||
|
.unwrap_or(60.0);
|
||||||
|
let cpu_start = process_cpu_ms();
|
||||||
|
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 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 ({:.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 cpu_p50 {:.1}ms gpu_wait_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.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 report = format!(
|
||||||
|
"iris bench report\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 `ANIM_STEP_MS` sleep is enough, since a
|
||||||
|
/// slow device's frame callback can lag further than that.
|
||||||
|
async fn read_from_state<T, F>(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
f: F,
|
||||||
|
) -> T
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
let _ = tx.send(f(state, rsc));
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
loop {
|
||||||
|
if let Ok(value) = rx.try_recv() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||||
|
/// from it (toward older messages) through `List::fling`, then
|
||||||
|
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
||||||
|
/// convention (`List::scroll`'s own doc: positive moves *later* content
|
||||||
|
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
||||||
|
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
||||||
|
/// the other way around; the two apps' *travel* is still directly
|
||||||
|
/// comparable because both report it as a row index + pixel offset, not a
|
||||||
|
/// signed distance.
|
||||||
|
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(ANIM_STEP_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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticks the fling forward in ~60Hz steps (the same shape
|
||||||
|
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
|
||||||
|
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
|
||||||
|
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
|
||||||
|
/// own spline-decided `duration()` already caps how long it can run.
|
||||||
|
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).tick_fling(Instant::now()),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if !still_scrolling {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
|
||||||
|
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
||||||
|
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
|
||||||
|
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
|
||||||
|
/// `(sent, total)`.
|
||||||
|
async fn run_stream_phase(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||||
|
state.android_state_mut().frame_report.mark_phase("stream");
|
||||||
|
});
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).jump_to_end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
|
||||||
|
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||||
|
let mut sent = 0usize;
|
||||||
|
for event in stream_tail.into_iter().take(total) {
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
let old_items = state.items.clone();
|
||||||
|
state.items = fold_event(&state.items, &event);
|
||||||
|
match &state.screen {
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||||
|
None => state.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
sent += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||||
|
}
|
||||||
|
// Lets the last few deltas land and draw before the next phase starts
|
||||||
|
// -- `BenchRun.kt`'s own closing delay.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
(sent, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 3: focuses the real composer, shows the keyboard, then types
|
||||||
|
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
|
||||||
|
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
|
||||||
|
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
|
||||||
|
/// way.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
// 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 -- `BenchRun.kt`'s own delay.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
|
||||||
|
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
|
||||||
|
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
|
||||||
|
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
|
||||||
|
/// "keyboard: could not be shown" if the platform never confirms it even
|
||||||
|
/// once, per UI_RULES.md ("design the unknown/failed state before the
|
||||||
|
/// answer's").
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
|
||||||
|
/// is the same string, so it has to match exactly or the two apps'
|
||||||
|
/// type phases stop typing the same content -- RUST.md's "Benchmark
|
||||||
|
/// v2" spec is one shared string for both.
|
||||||
|
#[test]
|
||||||
|
fn type_text_is_exactly_600_characters() {
|
||||||
|
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||||
|
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||||
|
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||||
|
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
|
||||||
|
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
|
||||||
|
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
|
||||||
|
//! report's real late-frame budget and `InputMethodManager.
|
||||||
|
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
|
||||||
|
//! of these are part of `android_view::context`'s own `Context`/
|
||||||
|
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
|
||||||
|
//! this calls them directly rather than growing that crate's wrapper for
|
||||||
|
//! calls this crate alone needs.
|
||||||
|
//!
|
||||||
|
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||||
|
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
|
||||||
|
//! whichever thread calls it -- the battery sampler runs on a background
|
||||||
|
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
|
||||||
|
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
|
||||||
|
//! already attached (the `jni` crate detects it and does not double
|
||||||
|
//! attach), so no caller here needs to know or care which thread it is.
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
|
||||||
|
/// on any JNI failure, on a device with no `BatteryManager` service,
|
||||||
|
/// or when the platform itself answers "not supported" -- `0` or
|
||||||
|
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
|
||||||
|
/// both would read as a real (and wrong) measurement if folded into an
|
||||||
|
/// average rather than named apart. UI_RULES.md: never present an
|
||||||
|
/// inferred value as a measured one.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
|
||||||
|
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
|
||||||
|
/// frames are judged against *this* device's real budget, not an
|
||||||
|
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
|
||||||
|
/// "late" that met their own faster deadline. `None` if the view is
|
||||||
|
/// not yet attached to a window (`getDisplay` returns `null`) or the
|
||||||
|
/// platform reports a non-positive rate, which is not a real answer
|
||||||
|
/// either.
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
|
||||||
|
/// own show, called directly rather than through the focus-driven
|
||||||
|
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
|
||||||
|
/// since RUST.md's "Benchmark v2" spec asks for this "through the
|
||||||
|
/// shell's InputMethodManager" independent of focus state. `true` only
|
||||||
|
/// if the platform itself reports the request succeeded -- whether the
|
||||||
|
/// IME actually became visible is confirmed separately, from
|
||||||
|
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
|
||||||
|
/// value as a measured one").
|
||||||
|
pub fn show_ime(&self) -> bool {
|
||||||
|
self.try_toggle_ime(true).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows `report` in the shell's plain-view diagnostics overlay
|
||||||
|
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
|
||||||
|
/// and Close controls, added over whatever iris itself is drawing
|
||||||
|
/// rather than replacing it (unlike `android::view::show_renderer_error`,
|
||||||
|
/// which exists for the case the renderer can never recover from and
|
||||||
|
/// intentionally never returns). Called from a background task after
|
||||||
|
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
|
||||||
|
/// so the Java side hops onto the UI thread itself before touching the
|
||||||
|
/// view tree -- see that method's own comment.
|
||||||
|
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
|
||||||
|
self.try_show_diagnostics_overlay(report).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let jreport = env.new_string(report).ok()?;
|
||||||
|
env.call_method(
|
||||||
|
self.view.as_obj(),
|
||||||
|
"showDiagnosticsOverlay",
|
||||||
|
"(Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(jreport.as_ref())],
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,18 @@
|
|||||||
//! A build picks one screen or the other, never both, so `Client` and
|
//! A build picks one screen or the other, never both, so `Client` and
|
||||||
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
||||||
//! there is no in-app navigation to switch *to* on either side yet.
|
//! there is no in-app navigation to switch *to* on either side yet.
|
||||||
|
//!
|
||||||
|
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
|
||||||
|
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
|
||||||
|
//! `transcript_ui::build_tree` again, this time against the checked-in
|
||||||
|
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
|
||||||
|
//! server, with a "Run benchmark" control that drives the same scroll loop
|
||||||
|
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
|
||||||
|
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
|
||||||
|
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
|
||||||
|
//! enabled together -- `ActiveClient` below gives `bench` priority in that
|
||||||
|
//! case, the same way `transcript-screen` already takes priority over the
|
||||||
|
//! default `tabs-screen`.
|
||||||
|
|
||||||
use android_view::{
|
use android_view::{
|
||||||
Context, View,
|
Context, View,
|
||||||
@@ -39,7 +51,11 @@ use iris::prelude::*;
|
|||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
|
|
||||||
#[cfg(feature = "transcript-screen")]
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_client;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_jni;
|
||||||
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
mod transcript_client;
|
mod transcript_client;
|
||||||
|
|
||||||
/// The app's `View` subclass, matching the Java side's package --
|
/// The app's `View` subclass, matching the Java side's package --
|
||||||
@@ -85,8 +101,10 @@ impl AndroidAppState for Client {
|
|||||||
|
|
||||||
#[cfg(not(feature = "transcript-screen"))]
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
type ActiveClient = Client;
|
type ActiveClient = Client;
|
||||||
#[cfg(feature = "transcript-screen")]
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
type ActiveClient = transcript_client::TranscriptClient;
|
type ActiveClient = transcript_client::TranscriptClient;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
type ActiveClient = bench_client::BenchClient;
|
||||||
|
|
||||||
extern "system" fn new_view_peer<'local>(
|
extern "system" fn new_view_peer<'local>(
|
||||||
env: JNIEnv<'local>,
|
env: JNIEnv<'local>,
|
||||||
|
|||||||
@@ -20,14 +20,22 @@
|
|||||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||||
//! a stale background response, and a full rebuild of the widget tree on
|
//! a stale background response. What differs is only the redraw
|
||||||
//! every event (same tradeoff, same reason: `push_row` cannot update a row
|
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||||
//! already on screen, and this rig's conversations are small). What
|
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||||
//! differs is only the redraw mechanism: android-view has no
|
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||||
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
|
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||||
//! (new, added alongside this box) to request a frame after each
|
//! doc for why.
|
||||||
//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single
|
//!
|
||||||
//! end-of-future redraw -- see that method's own doc for why.
|
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
|
||||||
|
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
|
||||||
|
//! a ~3,200-row transcript otherwise): `apply_event` calls
|
||||||
|
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
|
||||||
|
//! after `fold_event`, which updates only the row(s) that actually
|
||||||
|
//! changed (almost always just the one open assistant message) instead of
|
||||||
|
//! refolding and rebuilding every row. `rebuild_transcript` still runs
|
||||||
|
//! the whole widget tree once, for the opening page and for `apply`'s own
|
||||||
|
//! rare regroup fallback.
|
||||||
|
|
||||||
use client_core::api::{ApiClient, UreqTransport};
|
use client_core::api::{ApiClient, UreqTransport};
|
||||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
use client_core::event_stream::{StreamItem, follow_session_events};
|
||||||
@@ -360,8 +368,17 @@ impl TranscriptClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||||
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, event);
|
self.items = fold_event(&self.items, event);
|
||||||
self.rebuild_transcript(rsc);
|
match &self.screen {
|
||||||
|
// The common path: update only the row(s) that actually
|
||||||
|
// changed instead of refolding and rebuilding all ~3,200 of
|
||||||
|
// them per event (RUST.md's P0 streaming-phase fix).
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||||
|
// No screen yet (the opening page hasn't landed) -- build one
|
||||||
|
// the ordinary way once it has.
|
||||||
|
None => self.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_message(&mut self, session_id: String, text: String) {
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AOSP's fling spline, transcribed independently of the Rust port.
|
||||||
|
|
||||||
|
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
||||||
|
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
||||||
|
grading its own homework. Every test iris's fling had before 2026-09-07
|
||||||
|
compared the curve with itself -- monotonic, signed, integrates to the closed
|
||||||
|
form -- and all of them passed while `distance_fraction(t)` was returning
|
||||||
|
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
||||||
|
test have to come from somewhere else, and this is the somewhere else.
|
||||||
|
|
||||||
|
Transcribed by hand from, and only from:
|
||||||
|
|
||||||
|
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
||||||
|
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
||||||
|
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
||||||
|
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
||||||
|
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
||||||
|
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
||||||
|
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
||||||
|
line, which is why iris ports one curve rather than two.
|
||||||
|
|
||||||
|
Run it with no arguments; it prints the table entries and the (velocity,
|
||||||
|
density, t) points the Rust tests assert on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NB_SAMPLES = 100
|
||||||
|
INFLEXION = 0.35
|
||||||
|
START_TENSION = 0.5
|
||||||
|
END_TENSION = 1.0
|
||||||
|
P1 = START_TENSION * INFLEXION
|
||||||
|
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
||||||
|
|
||||||
|
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
|
||||||
|
# "look and feel tuning" constant -- a different number in a different place
|
||||||
|
# of the same formula, which is the pair iris got the wrong way round once.
|
||||||
|
SCROLL_FRICTION = 0.015
|
||||||
|
TUNING = 0.84
|
||||||
|
GRAVITY_EARTH = 9.80665
|
||||||
|
INCHES_PER_METER = 39.37
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def spline_positions():
|
||||||
|
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
||||||
|
position = [0.0] * (NB_SAMPLES + 1)
|
||||||
|
x_min = 0.0
|
||||||
|
for i in range(NB_SAMPLES):
|
||||||
|
alpha = i / NB_SAMPLES
|
||||||
|
x_max = 1.0
|
||||||
|
while True:
|
||||||
|
x = x_min + (x_max - x_min) / 2.0
|
||||||
|
coef = 3.0 * x * (1.0 - x)
|
||||||
|
# Solved on the P1/P2 curve...
|
||||||
|
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
||||||
|
if abs(tx - alpha) < 1e-5:
|
||||||
|
break
|
||||||
|
if tx > alpha:
|
||||||
|
x_max = x
|
||||||
|
else:
|
||||||
|
x_min = x
|
||||||
|
# ...and sampled on the tension curve.
|
||||||
|
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
||||||
|
position[NB_SAMPLES] = 1.0
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
POSITION = spline_positions()
|
||||||
|
|
||||||
|
|
||||||
|
def fling_sample(t):
|
||||||
|
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
||||||
|
t = min(max(t, 0.0), 1.0)
|
||||||
|
index = int(t * NB_SAMPLES)
|
||||||
|
if index >= NB_SAMPLES:
|
||||||
|
return 1.0, 0.0
|
||||||
|
t_inf = index / NB_SAMPLES
|
||||||
|
t_sup = (index + 1) / NB_SAMPLES
|
||||||
|
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
||||||
|
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
||||||
|
|
||||||
|
|
||||||
|
def physical_coefficient(density):
|
||||||
|
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
||||||
|
|
||||||
|
|
||||||
|
def deceleration(velocity, density):
|
||||||
|
return math.log(
|
||||||
|
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_distance(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return (
|
||||||
|
SCROLL_FRICTION
|
||||||
|
* physical_coefficient(density)
|
||||||
|
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_duration_s(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return math.exp(l / (DECELERATION_RATE - 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def position_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def velocity_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("SPLINE_POSITION at a few indices (index: value)")
|
||||||
|
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
||||||
|
print(f" {i:3}: {POSITION[i]:.6f}")
|
||||||
|
print()
|
||||||
|
print("distance/velocity fraction at time fractions")
|
||||||
|
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
||||||
|
d, v = fling_sample(t)
|
||||||
|
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
||||||
|
print()
|
||||||
|
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||||
|
# 2.75 is this checkout's emulator.
|
||||||
|
for density in (2.55, 2.75):
|
||||||
|
for velocity in (5000.0, 11064.0):
|
||||||
|
dur = fling_duration_s(velocity, density)
|
||||||
|
print(
|
||||||
|
f"density={density} v={velocity}: "
|
||||||
|
f"distance={fling_distance(velocity, density):.3f}px "
|
||||||
|
f"duration={dur:.4f}s"
|
||||||
|
)
|
||||||
|
# Deliberately not round fractions. The velocity coefficient is
|
||||||
|
# piecewise *constant* across each of the 100 samples, so it
|
||||||
|
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
||||||
|
# on which side of a discontinuity the last float landed --
|
||||||
|
# which is genuinely different between Python and Rust and says
|
||||||
|
# nothing about the curve.
|
||||||
|
for frac in (0.125, 0.335, 0.505, 0.755):
|
||||||
|
t = frac * dur
|
||||||
|
print(
|
||||||
|
f" t={frac:>4} of duration ({t:.4f}s): "
|
||||||
|
f"pos={position_at(velocity, density, t):.3f}px "
|
||||||
|
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
||||||
|
)
|
||||||
@@ -142,7 +142,7 @@ fn bench_first_frame(n: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
report(
|
report(
|
||||||
&format!("(a) first frame, N={n}"),
|
&format!("(a) first frame, N={n}"),
|
||||||
elapsed,
|
elapsed,
|
||||||
@@ -177,7 +177,7 @@ fn bench_scroll(n: usize, ticks: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -245,7 +245,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -302,7 +302,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -384,7 +384,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
|
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
|
||||||
|
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
|
||||||
|
# `block_on` turns that one async pop into the same synchronous call shape
|
||||||
|
# `device_limits()`'s two callers already use for `request_adapter`/
|
||||||
|
# `request_device`, rather than making this crate's one entry point async.
|
||||||
|
pollster = { workspace = true }
|
||||||
bytemuck ={ workspace = true }
|
bytemuck ={ workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
parley = { workspace = true }
|
parley = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -9,7 +9,31 @@ pub struct Size {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Len {
|
pub struct Len {
|
||||||
|
/// Physical pixels -- a raw device pixel, unaffected by the display's
|
||||||
|
/// density. Rare to want directly (a hairline border is the usual
|
||||||
|
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
|
||||||
|
/// the two are kept separate rather than one field a caller has to
|
||||||
|
/// remember to pre-multiply.
|
||||||
pub abs: f32,
|
pub abs: f32,
|
||||||
|
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
|
||||||
|
/// (1 unit = 1/160in), resolved against the display's density at
|
||||||
|
/// layout time (`apply_rest`'s `density` parameter) rather than at the
|
||||||
|
/// point a widget is built, since density is a property of the device
|
||||||
|
/// this ends up running on, not of the widget tree. This is the unit
|
||||||
|
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
|
||||||
|
/// 2026-09-06: before it existed, every size in the tree was `abs`
|
||||||
|
/// (physical pixels), and the only way to make a 16px design draw at
|
||||||
|
/// the right *size* on a denser display was a single global multiply
|
||||||
|
/// applied to the whole rendered scene after layout -- which is also
|
||||||
|
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
|
||||||
|
/// at logical size and stretched by the scale"): a glyph rasterised at
|
||||||
|
/// 16 physical px and then stretched 3x by that global multiply is a
|
||||||
|
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
|
||||||
|
/// layout time instead means the font size handed to the text shaper
|
||||||
|
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
|
||||||
|
/// atlas rasterises at the display's real resolution and nothing
|
||||||
|
/// downstream needs to stretch anything.
|
||||||
|
pub dp: f32,
|
||||||
pub rel: f32,
|
pub rel: f32,
|
||||||
pub rest: f32,
|
pub rest: f32,
|
||||||
}
|
}
|
||||||
@@ -67,10 +91,10 @@ impl Size {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_uivec2(self) -> UiVec2 {
|
pub fn to_uivec2(self, density: f32) -> UiVec2 {
|
||||||
UiVec2 {
|
UiVec2 {
|
||||||
x: self.x.apply_rest(),
|
x: self.x.apply_rest(density),
|
||||||
y: self.y.apply_rest(),
|
y: self.y.apply_rest(density),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,26 +122,66 @@ impl Size {
|
|||||||
impl Len {
|
impl Len {
|
||||||
pub const ZERO: Self = Self {
|
pub const ZERO: Self = Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const REST: Self = Self {
|
pub const REST: Self = Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 1.0,
|
rest: 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn apply_rest(&self) -> UiScalar {
|
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
|
||||||
|
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
|
||||||
|
/// unscaled display, `content_scale` on Android; see `dp`'s field
|
||||||
|
/// doc). Every other component of `Len` is already resolution-
|
||||||
|
/// independent (`rel` is a fraction of the parent; `rest` becomes a
|
||||||
|
/// fraction too, below), so `density` only ever touches this one term.
|
||||||
|
pub fn apply_rest(&self, density: f32) -> UiScalar {
|
||||||
UiScalar {
|
UiScalar {
|
||||||
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||||
abs: self.abs,
|
abs: self.abs + self.dp * density,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
|
||||||
|
/// `rest` survives: `dp` becomes physical pixels and every other
|
||||||
|
/// component is left alone.
|
||||||
|
///
|
||||||
|
/// **A `Len` a widget *reports* must have been through this.** `dp` is
|
||||||
|
/// an input unit -- a number the widget author wrote -- and the
|
||||||
|
/// containers that consume a reported length read `abs`/`rel`/`rest`
|
||||||
|
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
|
||||||
|
/// so a reported `dp` is silently worth zero. That is what made the
|
||||||
|
/// composer's bar collapse to nothing the moment its content grew past
|
||||||
|
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
|
||||||
|
/// so the bar was given a slot of 0 and the field inside it was panned
|
||||||
|
/// out of a container measured at -63px. `UiRenderState::draw_inner`
|
||||||
|
/// debug-asserts the invariant after every `Widget::draw`.
|
||||||
|
pub fn fold_dp(&self, density: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: self.abs + self.dp * density,
|
||||||
|
dp: 0.0,
|
||||||
|
rel: self.rel,
|
||||||
|
rest: self.rest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn abs(abs: impl UiNum) -> Self {
|
pub fn abs(abs: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: abs.to_f32(),
|
abs: abs.to_f32(),
|
||||||
|
dp: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn dp(dp: impl UiNum) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: 0.0,
|
||||||
|
dp: dp.to_f32(),
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -125,6 +189,7 @@ impl Len {
|
|||||||
pub fn rel(rel: impl UiNum) -> Self {
|
pub fn rel(rel: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: rel.to_f32(),
|
rel: rel.to_f32(),
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -132,6 +197,7 @@ impl Len {
|
|||||||
pub fn rest(ratio: impl UiNum) -> Self {
|
pub fn rest(ratio: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: ratio.to_f32(),
|
rest: ratio.to_f32(),
|
||||||
}
|
}
|
||||||
@@ -144,6 +210,15 @@ pub mod len_fns {
|
|||||||
pub fn abs(abs: impl UiNum) -> Len {
|
pub fn abs(abs: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: abs.to_f32(),
|
abs: abs.to_f32(),
|
||||||
|
dp: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn dp(dp: impl UiNum) -> Len {
|
||||||
|
Len {
|
||||||
|
abs: 0.0,
|
||||||
|
dp: dp.to_f32(),
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -151,6 +226,7 @@ pub mod len_fns {
|
|||||||
pub fn rel(rel: impl UiNum) -> Len {
|
pub fn rel(rel: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: rel.to_f32(),
|
rel: rel.to_f32(),
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -158,14 +234,15 @@ pub mod len_fns {
|
|||||||
pub fn rest(ratio: impl UiNum) -> Len {
|
pub fn rest(ratio: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: ratio.to_f32(),
|
rest: ratio.to_f32(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_op!(Len Add add; abs rel rest);
|
impl_op!(Len Add add; abs dp rel rest);
|
||||||
impl_op!(Len Sub sub; abs rel rest);
|
impl_op!(Len Sub sub; abs dp rel rest);
|
||||||
|
|
||||||
impl_op!(Size Add add; x y);
|
impl_op!(Size Add add; x y);
|
||||||
impl_op!(Size Sub sub; x y);
|
impl_op!(Size Sub sub; x y);
|
||||||
@@ -187,6 +264,9 @@ impl std::fmt::Display for Len {
|
|||||||
if self.abs != 0.0 {
|
if self.abs != 0.0 {
|
||||||
write!(f, "{} abs;", self.abs)?;
|
write!(f, "{} abs;", self.abs)?;
|
||||||
}
|
}
|
||||||
|
if self.dp != 0.0 {
|
||||||
|
write!(f, "{} dp;", self.dp)?;
|
||||||
|
}
|
||||||
if self.rel != 0.0 {
|
if self.rel != 0.0 {
|
||||||
write!(f, "{} rel;", self.rel)?;
|
write!(f, "{} rel;", self.rel)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,63 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
|
|||||||
use parley::{
|
use parley::{
|
||||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||||
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||||
|
fontique::{Blob, FamilyId},
|
||||||
};
|
};
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::sync::Arc;
|
||||||
use swash::{
|
use swash::{
|
||||||
FontRef,
|
FontRef,
|
||||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||||
zeno::{Format, Vector},
|
zeno::{Format, Vector},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Bundled fonts, registered over the system collection rather than relied
|
||||||
|
/// on alone -- see `TextData::register_bundled_fonts`'s doc comment for
|
||||||
|
/// why. Static weight/style cuts, not a variable font: parley/fontique
|
||||||
|
/// resolve a variable font's weight axis by picking normalized coordinates
|
||||||
|
/// on whatever single face registers for the family, and a phone whose
|
||||||
|
/// system "Roboto" is actually the variable "Roboto Flex" is exactly the
|
||||||
|
/// device class this sidesteps, rather than depends on working correctly.
|
||||||
|
/// Noto Sans, OFL-licensed (`assets/fonts/OFL.txt`), chosen for coverage
|
||||||
|
/// breadth (a transcript's content is not known in advance) over a
|
||||||
|
/// smaller-footprint alternative -- see the doc comment for the size this
|
||||||
|
/// added.
|
||||||
|
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
|
||||||
|
const NOTO_SANS_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Bold.ttf");
|
||||||
|
const NOTO_SANS_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Italic.ttf");
|
||||||
|
const NOTO_SANS_BOLD_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
|
||||||
|
const NOTO_SANS_MONO_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
|
||||||
|
const NOTO_SANS_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
|
||||||
|
|
||||||
|
/// What starting up found about text rendering, for the on-screen
|
||||||
|
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
|
||||||
|
/// once at startup ... the number of font families found, the default
|
||||||
|
/// family resolved"). Built once by `TextData::font_diagnostics` --
|
||||||
|
/// `Default::default` still exists for callers (tests, examples) that
|
||||||
|
/// don't need the report.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FontDiagnostics {
|
||||||
|
/// `Collection::family_names().count()` after registering the bundled
|
||||||
|
/// fonts -- system families plus the two bundled ones.
|
||||||
|
pub families_found: usize,
|
||||||
|
/// The family `GenericFamily::SansSerif` resolves to first -- the
|
||||||
|
/// bundled "Noto Sans" unless registration itself failed.
|
||||||
|
pub default_family: Option<String>,
|
||||||
|
/// The family `GenericFamily::Monospace` resolves to first.
|
||||||
|
pub default_mono_family: Option<String>,
|
||||||
|
/// One resolved family name per style axis this crate actually uses
|
||||||
|
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
|
||||||
|
/// bold/italic request is landing on a real face rather than being
|
||||||
|
/// silently absorbed by whatever the sans-serif default resolves to
|
||||||
|
/// for every weight (RUST.md's P0 box, "bold words render as blank
|
||||||
|
/// gaps" -- a family that resolves but has no distinct bold face is
|
||||||
|
/// exactly what produced that).
|
||||||
|
pub regular_resolved: Option<String>,
|
||||||
|
pub bold_resolved: Option<String>,
|
||||||
|
pub italic_resolved: Option<String>,
|
||||||
|
pub mono_resolved: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything text needs that outlives one string: the font collection, the
|
/// Everything text needs that outlives one string: the font collection, the
|
||||||
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
||||||
pub struct TextData {
|
pub struct TextData {
|
||||||
@@ -17,15 +66,182 @@ pub struct TextData {
|
|||||||
pub layout_cx: LayoutContext<UiColor>,
|
pub layout_cx: LayoutContext<UiColor>,
|
||||||
scale_cx: ScaleContext,
|
scale_cx: ScaleContext,
|
||||||
pub atlas: GlyphAtlas,
|
pub atlas: GlyphAtlas,
|
||||||
|
/// Physical pixels per dp -- a second copy of
|
||||||
|
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
|
||||||
|
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
|
||||||
|
/// from an event callback that has a `TextData` but no `Painter`, so it
|
||||||
|
/// has nowhere else to read the display's density from. Both copies are
|
||||||
|
/// set together, from the one place either backend learns the real
|
||||||
|
/// value (`android::view::new_peer`); this is the same accepted
|
||||||
|
/// duplication as `AndroidRenderer::content_scale`; a single source of
|
||||||
|
/// truth would mean carrying a `Painter` (or output size) into every
|
||||||
|
/// input handler for the sake of one field.
|
||||||
|
pub density: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TextData {
|
impl Default for TextData {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
let mut data = Self {
|
||||||
font_cx: FontContext::new(),
|
font_cx: FontContext::new(),
|
||||||
layout_cx: LayoutContext::new(),
|
layout_cx: LayoutContext::new(),
|
||||||
scale_cx: ScaleContext::new(),
|
scale_cx: ScaleContext::new(),
|
||||||
atlas: GlyphAtlas::default(),
|
atlas: GlyphAtlas::default(),
|
||||||
|
density: 1.0,
|
||||||
|
};
|
||||||
|
data.register_bundled_fonts();
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextData {
|
||||||
|
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
|
||||||
|
/// Mono (regular/bold) as static faces, and puts them **first** in the
|
||||||
|
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
|
||||||
|
/// not instead of, whatever the platform already found, so a script
|
||||||
|
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
|
||||||
|
/// font the same as before this existed.
|
||||||
|
///
|
||||||
|
/// Exists because text rendering must not depend on the platform's own
|
||||||
|
/// font enumeration succeeding or resolving weight/style the way this
|
||||||
|
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
|
||||||
|
/// rendering as blank gaps of the correct advance width (the glyph
|
||||||
|
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
|
||||||
|
/// the emulator's system fonts happened to resolve every style. A
|
||||||
|
/// bundled, static-per-style family removes fontique's Android font
|
||||||
|
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
|
||||||
|
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
|
||||||
|
/// has to survive to reach the screen at all.
|
||||||
|
///
|
||||||
|
/// Cost: six static `.ttf`s, ~3.6 MB uncompressed
|
||||||
|
/// (`iris/core/assets/fonts/`), landing in the APK compressed --
|
||||||
|
/// `build-apk.sh`'s own output is what says the delivered number, not
|
||||||
|
/// this comment.
|
||||||
|
fn register_bundled_fonts(&mut self) {
|
||||||
|
fn register(cx: &mut FontContext, bytes: &'static [u8]) -> Option<FamilyId> {
|
||||||
|
let blob = Blob::new(Arc::new(bytes));
|
||||||
|
cx.collection
|
||||||
|
.register_fonts(blob, None)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
let sans_id = register(&mut self.font_cx, NOTO_SANS_REGULAR);
|
||||||
|
register(&mut self.font_cx, NOTO_SANS_BOLD);
|
||||||
|
register(&mut self.font_cx, NOTO_SANS_ITALIC);
|
||||||
|
register(&mut self.font_cx, NOTO_SANS_BOLD_ITALIC);
|
||||||
|
let mono_id = register(&mut self.font_cx, NOTO_SANS_MONO_REGULAR);
|
||||||
|
register(&mut self.font_cx, NOTO_SANS_MONO_BOLD);
|
||||||
|
|
||||||
|
if let Some(sans_id) = sans_id {
|
||||||
|
let existing: Vec<_> = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::SansSerif)
|
||||||
|
.collect();
|
||||||
|
self.font_cx.collection.set_generic_families(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
std::iter::once(sans_id).chain(existing),
|
||||||
|
);
|
||||||
|
let existing: Vec<_> = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::SystemUi)
|
||||||
|
.collect();
|
||||||
|
self.font_cx.collection.set_generic_families(
|
||||||
|
GenericFamily::SystemUi,
|
||||||
|
std::iter::once(sans_id).chain(existing),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(mono_id) = mono_id {
|
||||||
|
let existing: Vec<_> = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::Monospace)
|
||||||
|
.collect();
|
||||||
|
self.font_cx.collection.set_generic_families(
|
||||||
|
GenericFamily::Monospace,
|
||||||
|
std::iter::once(mono_id).chain(existing),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the startup report -- see `FontDiagnostics`. Queries the
|
||||||
|
/// collection directly (`fontique::Query`) rather than shaping a real
|
||||||
|
/// string, since all that's needed is which family each axis lands on.
|
||||||
|
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
|
||||||
|
use parley::fontique::{Attributes, FontWidth, QueryStatus};
|
||||||
|
let families_found = self.font_cx.collection.family_names().count();
|
||||||
|
let default_family_id = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::SansSerif)
|
||||||
|
.next();
|
||||||
|
let default_family = default_family_id
|
||||||
|
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||||
|
let default_mono_family_id = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::Monospace)
|
||||||
|
.next();
|
||||||
|
let default_mono_family = default_mono_family_id
|
||||||
|
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||||
|
|
||||||
|
// Resolves the family a (generic family, weight, style) query lands
|
||||||
|
// on, without holding the `Query`'s borrow of `collection` across
|
||||||
|
// the `family_name` lookup that needs it back -- the `FamilyId` is
|
||||||
|
// captured out of the closure first, then looked up once `query`
|
||||||
|
// (and its borrow) has been dropped.
|
||||||
|
let mut resolve_family =
|
||||||
|
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
|
||||||
|
let mut family_id = None;
|
||||||
|
{
|
||||||
|
let mut query = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.query(&mut self.font_cx.source_cache);
|
||||||
|
query.set_families([generic]);
|
||||||
|
query.set_attributes(Attributes {
|
||||||
|
width: FontWidth::NORMAL,
|
||||||
|
style,
|
||||||
|
weight,
|
||||||
|
});
|
||||||
|
query.matches_with(|font| {
|
||||||
|
family_id = Some(font.family.0);
|
||||||
|
QueryStatus::Stop
|
||||||
|
});
|
||||||
|
}
|
||||||
|
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
|
||||||
|
};
|
||||||
|
|
||||||
|
let regular_resolved = resolve_family(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
FontWeight::NORMAL,
|
||||||
|
FontStyle::Normal,
|
||||||
|
);
|
||||||
|
let bold_resolved = resolve_family(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
FontWeight::BOLD,
|
||||||
|
FontStyle::Normal,
|
||||||
|
);
|
||||||
|
let italic_resolved = resolve_family(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
FontWeight::NORMAL,
|
||||||
|
FontStyle::Italic,
|
||||||
|
);
|
||||||
|
let mono_resolved = resolve_family(
|
||||||
|
GenericFamily::Monospace,
|
||||||
|
FontWeight::NORMAL,
|
||||||
|
FontStyle::Normal,
|
||||||
|
);
|
||||||
|
|
||||||
|
FontDiagnostics {
|
||||||
|
families_found,
|
||||||
|
default_family,
|
||||||
|
default_mono_family,
|
||||||
|
regular_resolved,
|
||||||
|
bold_resolved,
|
||||||
|
italic_resolved,
|
||||||
|
mono_resolved,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +375,7 @@ pub struct TextBuffer {
|
|||||||
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||||
/// does, since spans change far less often than a naive equality check
|
/// does, since spans change far less often than a naive equality check
|
||||||
/// on the whole `Vec` would cost to compute every frame.
|
/// on the whole `Vec` would cost to compute every frame.
|
||||||
shaped: Option<(TextAttrs, Option<f32>)>,
|
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextBuffer {
|
impl TextBuffer {
|
||||||
@@ -215,19 +431,42 @@ impl TextBuffer {
|
|||||||
Vec2::new(self.layout.width(), self.layout.height())
|
Vec2::new(self.layout.width(), self.layout.height())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lay the text out, unless it is already laid out for these attributes and
|
/// Lay the text out, unless it is already laid out for these
|
||||||
/// this width.
|
/// attributes, this width and this density.
|
||||||
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
///
|
||||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
|
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
|
||||||
|
/// are density-independent (dp) units, multiplied by `density` here --
|
||||||
|
/// the one place text crosses from the widget tree's dp sizes into the
|
||||||
|
/// physical pixels the shaper and rasteriser (`TextData::place`) both
|
||||||
|
/// then work in.** This is what makes glyphs sharp on a dense display:
|
||||||
|
/// before this existed, `font_size` was already a physical-pixel value
|
||||||
|
/// (RUST.md's P0 box's global-scale stopgap resolved density by
|
||||||
|
/// stretching the whole rendered frame afterward instead), so a glyph
|
||||||
|
/// was rasterised small and then upscaled by whatever the display's
|
||||||
|
/// scale factor was -- exactly the blur Iris's report described.
|
||||||
|
/// Multiplying here instead means the font size hitting `ScaleContext`
|
||||||
|
/// in `place` below is already the display's real physical size, so
|
||||||
|
/// the atlas holds a bitmap at the resolution it is actually shown at.
|
||||||
|
/// `GlyphKey.size` already keys on that resolved `font_size`
|
||||||
|
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
|
||||||
|
/// physical size with no change needed there.
|
||||||
|
pub fn shape(
|
||||||
|
&mut self,
|
||||||
|
data: &mut TextData,
|
||||||
|
attrs: &TextAttrs,
|
||||||
|
width: Option<f32>,
|
||||||
|
density: f32,
|
||||||
|
) {
|
||||||
|
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut builder = data
|
let mut builder = data
|
||||||
.layout_cx
|
.layout_cx
|
||||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||||
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
||||||
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||||
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||||
attrs.line_height,
|
attrs.line_height * density,
|
||||||
)));
|
)));
|
||||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||||
for span in &self.spans {
|
for span in &self.spans {
|
||||||
@@ -239,7 +478,7 @@ impl TextBuffer {
|
|||||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||||
}
|
}
|
||||||
if let Some(size) = span.font_size {
|
if let Some(size) = span.font_size {
|
||||||
builder.push(StyleProperty::FontSize(size), range.clone());
|
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||||
}
|
}
|
||||||
if span.bold {
|
if span.bold {
|
||||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||||
@@ -255,7 +494,7 @@ impl TextBuffer {
|
|||||||
self.layout.break_all_lines(width);
|
self.layout.break_all_lines(width);
|
||||||
self.layout
|
self.layout
|
||||||
.align(Alignment::Start, AlignmentOptions::default());
|
.align(Alignment::Start, AlignmentOptions::default());
|
||||||
self.shaped = Some((attrs.clone(), width));
|
self.shaped = Some((attrs.clone(), width, density));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +601,11 @@ pub struct RenderedText {
|
|||||||
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||||
pub size: Vec2,
|
pub size: Vec2,
|
||||||
pub color: UiColor,
|
pub color: UiColor,
|
||||||
|
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
|
||||||
|
/// A holder must re-render rather than re-emit these quads once the
|
||||||
|
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
|
||||||
|
/// otherwise); `Painter::glyphs` debug-asserts it.
|
||||||
|
pub generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextData {
|
impl TextData {
|
||||||
@@ -372,13 +616,15 @@ impl TextData {
|
|||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
textures: &mut Textures,
|
textures: &mut Textures,
|
||||||
|
density: f32,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
buffer.shape(self, attrs, width);
|
buffer.shape(self, attrs, width, density);
|
||||||
let glyphs = self.place(buffer, textures);
|
let glyphs = self.place(buffer, textures);
|
||||||
RenderedText {
|
RenderedText {
|
||||||
glyphs: std::sync::Arc::new(glyphs),
|
glyphs: std::sync::Arc::new(glyphs),
|
||||||
size: buffer.size(),
|
size: buffer.size(),
|
||||||
color: attrs.color,
|
color: attrs.color,
|
||||||
|
generation: self.atlas.generation(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,27 @@ impl Textures {
|
|||||||
self.updates.push(Update::Patch(handle.slot, rect));
|
self.updates.push(Update::Patch(handle.slot, rect));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget every image, page and pending update -- what a genuinely new
|
||||||
|
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
|
||||||
|
/// clear`], which this module's own doc references: every slot number
|
||||||
|
/// and every queued [`Update`] here describes the *old* device's
|
||||||
|
/// textures (an `Update::Push`/`Update::Patch` already drained into a
|
||||||
|
/// renderer that no longer exists is gone for good, and a fresh
|
||||||
|
/// `UiRenderNode`'s own texture manager starts with none of them
|
||||||
|
/// applied), so nothing is lost by starting this bookkeeping over too.
|
||||||
|
/// Any `TextureHandle` a caller still holds across the reset (none in
|
||||||
|
/// the transcript screen this reset is wired up for today -- confirmed
|
||||||
|
/// by grep, the only standalone (non-atlas) image anywhere in this
|
||||||
|
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
|
||||||
|
/// `tabs-ui` example) is left pointing at a slot this instance no
|
||||||
|
/// longer recognises and needs reinserting via `add`/`add_page` again
|
||||||
|
/// -- the same pre-existing gap a renderer restart already left for
|
||||||
|
/// such a handle before this method existed, just named rather than
|
||||||
|
/// silent now.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
*self = Self::new();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn free(&mut self) {
|
pub fn free(&mut self) {
|
||||||
for (kind, idx) in self.recv.try_iter() {
|
for (kind, idx) in self.recv.try_iter() {
|
||||||
self.images[idx as usize] = None;
|
self.images[idx as usize] = None;
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ struct Page {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GlyphAtlas {
|
pub struct GlyphAtlas {
|
||||||
pages: Vec<Page>,
|
pages: Vec<Page>,
|
||||||
|
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
|
||||||
|
/// from an earlier atlas can tell that its coordinates are stale --
|
||||||
|
/// see that method's doc for what goes wrong without it.
|
||||||
|
generation: u64,
|
||||||
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
||||||
/// too, so it is not re-rasterised on every layout.
|
/// too, so it is not re-rasterised on every layout.
|
||||||
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
||||||
@@ -166,6 +170,13 @@ impl GlyphAtlas {
|
|||||||
self.entries.insert(key, None);
|
self.entries.insert(key, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which atlas the entries handed out right now belong to. A
|
||||||
|
/// [`crate::RenderedText`] records this when it is built and is only
|
||||||
|
/// reusable while it still matches.
|
||||||
|
pub fn generation(&self) -> u64 {
|
||||||
|
self.generation
|
||||||
|
}
|
||||||
|
|
||||||
pub fn page_count(&self) -> usize {
|
pub fn page_count(&self) -> usize {
|
||||||
self.pages.len()
|
self.pages.len()
|
||||||
}
|
}
|
||||||
@@ -173,6 +184,36 @@ impl GlyphAtlas {
|
|||||||
pub fn glyph_count(&self) -> usize {
|
pub fn glyph_count(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget every page and every rasterised entry -- what a genuinely new
|
||||||
|
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
|
||||||
|
/// "not already live" branch, e.g. after backgrounding): the pages this
|
||||||
|
/// atlas remembers are `TextureHandle`s into the *old* device's
|
||||||
|
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
|
||||||
|
/// `uv_max`/`layer` point into them. Without this, a glyph already
|
||||||
|
/// cached here is treated as "already placed" and never re-inserted
|
||||||
|
/// into the fresh (empty) atlas the new renderer actually has --
|
||||||
|
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
|
||||||
|
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
|
||||||
|
/// this is its counterpart for the case where the renderer really is
|
||||||
|
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||||
|
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||||
|
/// doc for why that is harmless here.
|
||||||
|
/// Bumping `generation` here is the other half of the same
|
||||||
|
/// invalidation: emptying this atlas does nothing about the
|
||||||
|
/// `RenderedText`s widgets are *already holding*
|
||||||
|
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
|
||||||
|
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
|
||||||
|
/// away. Those redraw perfectly happily and sample whatever now sits at
|
||||||
|
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
|
||||||
|
/// after resuming the app on 2026-09-06. One counter, checked where the
|
||||||
|
/// cache is read, is what makes a cached render un-reusable across a
|
||||||
|
/// renderer rebuild.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.pages.clear();
|
||||||
|
self.entries.clear();
|
||||||
|
self.generation += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
|
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
|
||||||
|
|||||||
@@ -1,15 +1,87 @@
|
|||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
|
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
|
||||||
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
|
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
|
||||||
/// report and a percentage from `gfxinfo` mean the same thing.
|
/// report and a percentage from `gfxinfo` mean the same thing. Only a
|
||||||
|
/// fallback now that a caller can read the display's real refresh rate
|
||||||
|
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
|
||||||
|
/// 90Hz or 120Hz phone judged against this constant would call every frame
|
||||||
|
/// "late" that merely met its own, faster budget.
|
||||||
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
|
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
|
||||||
|
|
||||||
/// Enough frames for several minutes of scrolling before the oldest ones
|
/// Enough frames for several minutes of scrolling before the oldest ones
|
||||||
/// start being overwritten -- the same "diagnostic, not a log" sizing
|
/// start being overwritten -- the same "diagnostic, not a log" sizing
|
||||||
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
|
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
|
||||||
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
|
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
|
||||||
const RING_CAPACITY: usize = 4096;
|
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
|
||||||
|
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
|
||||||
|
/// this so `phase_stats` never has to report a phase as partially evicted.
|
||||||
|
const RING_CAPACITY: usize = 16384;
|
||||||
|
|
||||||
|
/// One `mark_phase` call: the wall-clock instant and the (0-based,
|
||||||
|
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
|
||||||
|
/// which a phase began -- `phase_stats` slices `index_ring` against this to
|
||||||
|
/// find which recorded samples belong to which phase, since the ring
|
||||||
|
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
|
||||||
|
/// not which phase they were in.
|
||||||
|
struct PhaseMark {
|
||||||
|
name: String,
|
||||||
|
start_index: u64,
|
||||||
|
start_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
|
||||||
|
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
|
||||||
|
/// p50/p90/p99, worst, duration". `Display` matches the shape
|
||||||
|
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
|
||||||
|
/// the two apps' reports read the same way side by side.
|
||||||
|
pub struct PhaseStats {
|
||||||
|
pub name: String,
|
||||||
|
/// How many frames were recorded during this phase in total -- may
|
||||||
|
/// exceed `late + (samples counted)` if some of this phase's frames
|
||||||
|
/// have since been evicted from the ring by a very long run; that
|
||||||
|
/// case is named in the `Display` rather than silently under-counted.
|
||||||
|
pub frames: u64,
|
||||||
|
pub duration: Duration,
|
||||||
|
pub late: u64,
|
||||||
|
pub late_percent: f64,
|
||||||
|
pub p50: Duration,
|
||||||
|
pub p90: Duration,
|
||||||
|
pub p99: Duration,
|
||||||
|
pub worst: Duration,
|
||||||
|
/// `false` if this phase's frame count exceeds how many samples of it
|
||||||
|
/// are still in the ring -- the percentiles above are then computed
|
||||||
|
/// over whatever survived, not the whole phase. UI_RULES.md: this is
|
||||||
|
/// the "we don't fully know" state, named rather than folded silently
|
||||||
|
/// into a number that looks exact.
|
||||||
|
pub complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PhaseStats {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
" {}: {} frames over {:.1}s{}",
|
||||||
|
self.name,
|
||||||
|
self.frames,
|
||||||
|
self.duration.as_secs_f64(),
|
||||||
|
if self.complete {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (ring evicted some of this phase)"
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
|
||||||
|
self.p50.as_secs_f64() * 1000.0,
|
||||||
|
self.p90.as_secs_f64() * 1000.0,
|
||||||
|
self.p99.as_secs_f64() * 1000.0,
|
||||||
|
)?;
|
||||||
|
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
|
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
|
||||||
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
|
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
|
||||||
@@ -41,6 +113,11 @@ pub struct FrameReport {
|
|||||||
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
|
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
|
||||||
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
|
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
|
||||||
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
||||||
|
/// The absolute (0-based, since the last `reset`) frame index each
|
||||||
|
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
|
||||||
|
/// slices against `PhaseMark::start_index` to tell which recorded
|
||||||
|
/// frames fall in which phase.
|
||||||
|
index_ring: Box<[u64; RING_CAPACITY]>,
|
||||||
/// How many of `ring`'s slots hold a real sample -- saturates at
|
/// How many of `ring`'s slots hold a real sample -- saturates at
|
||||||
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
|
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
|
||||||
len: usize,
|
len: usize,
|
||||||
@@ -50,6 +127,12 @@ pub struct FrameReport {
|
|||||||
/// correct even once the ring itself only holds the most recent frames.
|
/// correct even once the ring itself only holds the most recent frames.
|
||||||
total_frames: u64,
|
total_frames: u64,
|
||||||
janky_frames: u64,
|
janky_frames: u64,
|
||||||
|
/// `mark_phase` calls since the last `reset`, oldest first -- see
|
||||||
|
/// `phase_stats`. Empty on an ordinary run that never calls
|
||||||
|
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
|
||||||
|
/// prints no "per phase:" section at all, matching RUST.md's "empty/
|
||||||
|
/// absent on an ordinary 'Copy' press, which never marks a phase."
|
||||||
|
phases: Vec<PhaseMark>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One resolved reading. `Display` is the log line both the "Frame report"
|
/// One resolved reading. `Display` is the log line both the "Frame report"
|
||||||
@@ -107,10 +190,12 @@ impl FrameReport {
|
|||||||
Self {
|
Self {
|
||||||
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||||
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||||
|
index_ring: Box::new([0; RING_CAPACITY]),
|
||||||
len: 0,
|
len: 0,
|
||||||
pos: 0,
|
pos: 0,
|
||||||
total_frames: 0,
|
total_frames: 0,
|
||||||
janky_frames: 0,
|
janky_frames: 0,
|
||||||
|
phases: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +216,7 @@ impl FrameReport {
|
|||||||
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
|
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
|
||||||
self.ring[self.pos] = total;
|
self.ring[self.pos] = total;
|
||||||
self.submit_ring[self.pos] = submit_to_present;
|
self.submit_ring[self.pos] = submit_to_present;
|
||||||
|
self.index_ring[self.pos] = self.total_frames;
|
||||||
self.pos = (self.pos + 1) % RING_CAPACITY;
|
self.pos = (self.pos + 1) % RING_CAPACITY;
|
||||||
self.len = (self.len + 1).min(RING_CAPACITY);
|
self.len = (self.len + 1).min(RING_CAPACITY);
|
||||||
self.total_frames += 1;
|
self.total_frames += 1;
|
||||||
@@ -142,12 +228,98 @@ impl FrameReport {
|
|||||||
/// Clears every counter and every sample -- what the "Reset frame
|
/// Clears every counter and every sample -- what the "Reset frame
|
||||||
/// report" control calls, so a report covers only what was scrolled
|
/// report" control calls, so a report covers only what was scrolled
|
||||||
/// after the button was pressed (the same reason `FrameStats.kt`'s
|
/// after the button was pressed (the same reason `FrameStats.kt`'s
|
||||||
/// `reset()` exists on the Compose side).
|
/// `reset()` exists on the Compose side). Also clears every phase
|
||||||
|
/// mark, so a fresh run starts with no "per phase:" section until it
|
||||||
|
/// marks one of its own.
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.len = 0;
|
self.len = 0;
|
||||||
self.pos = 0;
|
self.pos = 0;
|
||||||
self.total_frames = 0;
|
self.total_frames = 0;
|
||||||
self.janky_frames = 0;
|
self.janky_frames = 0;
|
||||||
|
self.phases.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the start of a named phase at the current moment -- every
|
||||||
|
/// frame recorded from here until the next `mark_phase` (or `reset`)
|
||||||
|
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
|
||||||
|
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||||
|
/// can slice one whole run's frames by what was happening during each.
|
||||||
|
pub fn mark_phase(&mut self, name: &str) {
|
||||||
|
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
|
||||||
|
// end_index`) silently produces an empty or nonsensical slice for
|
||||||
|
// a phase pushed out of order rather than surfacing the misuse
|
||||||
|
// (docs/REVIEW-2026-09-06.md finding 5).
|
||||||
|
debug_assert!(
|
||||||
|
self.phases
|
||||||
|
.last()
|
||||||
|
.is_none_or(|p| self.total_frames >= p.start_index)
|
||||||
|
);
|
||||||
|
self.phases.push(PhaseMark {
|
||||||
|
name: name.to_string(),
|
||||||
|
start_index: self.total_frames,
|
||||||
|
start_at: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
|
||||||
|
/// oldest first. `now` closes the last phase's wall-clock span (there
|
||||||
|
/// is no "next phase" instant to use for it); `refresh_hz` is what
|
||||||
|
/// each phase's own `late`/`late_percent` is judged against, read from
|
||||||
|
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
|
||||||
|
/// count/% against the display's refresh rate."
|
||||||
|
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
|
||||||
|
if self.phases.is_empty() || refresh_hz <= 0.0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||||
|
self.phases
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, phase)| {
|
||||||
|
let (end_index, end_at) = match self.phases.get(i + 1) {
|
||||||
|
Some(next) => (next.start_index, next.start_at),
|
||||||
|
None => (self.total_frames, now),
|
||||||
|
};
|
||||||
|
let frames = end_index.saturating_sub(phase.start_index);
|
||||||
|
let mut samples: Vec<Duration> = (0..self.len)
|
||||||
|
.filter(|&j| {
|
||||||
|
let idx = self.index_ring[j];
|
||||||
|
idx >= phase.start_index && idx < end_index
|
||||||
|
})
|
||||||
|
.map(|j| self.ring[j])
|
||||||
|
.collect();
|
||||||
|
let complete = samples.len() as u64 >= frames;
|
||||||
|
if samples.is_empty() {
|
||||||
|
return PhaseStats {
|
||||||
|
name: phase.name.clone(),
|
||||||
|
frames,
|
||||||
|
duration: end_at.saturating_duration_since(phase.start_at),
|
||||||
|
late: 0,
|
||||||
|
late_percent: 0.0,
|
||||||
|
p50: Duration::ZERO,
|
||||||
|
p90: Duration::ZERO,
|
||||||
|
p99: Duration::ZERO,
|
||||||
|
worst: Duration::ZERO,
|
||||||
|
complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
samples.sort_unstable();
|
||||||
|
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
|
||||||
|
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
|
||||||
|
PhaseStats {
|
||||||
|
name: phase.name.clone(),
|
||||||
|
frames,
|
||||||
|
duration: end_at.saturating_duration_since(phase.start_at),
|
||||||
|
late,
|
||||||
|
late_percent: 100.0 * late as f64 / samples.len() as f64,
|
||||||
|
p50: pct(50),
|
||||||
|
p90: pct(90),
|
||||||
|
p99: pct(99),
|
||||||
|
worst: *samples.last().expect("checked not empty above"),
|
||||||
|
complete,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `None` if nothing has been recorded since the last reset -- the
|
/// `None` if nothing has been recorded since the last reset -- the
|
||||||
@@ -186,6 +358,28 @@ impl FrameReport {
|
|||||||
gpu_wait_p50: median(submit_samples),
|
gpu_wait_p50: median(submit_samples),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `(late count, late percent)` over every sample still in the ring,
|
||||||
|
/// judged against `refresh_hz`'s own frame budget rather than the
|
||||||
|
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
|
||||||
|
/// count/% against the display's refresh rate... print 'at N Hz (X ms
|
||||||
|
/// budget)' like Compose does." A separate method from `report()`
|
||||||
|
/// rather than a parameter on it, so `report()`'s own `janky_percent`
|
||||||
|
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
|
||||||
|
/// unaffected for every existing caller that never measured a real
|
||||||
|
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
|
||||||
|
/// `refresh_hz`.
|
||||||
|
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
|
||||||
|
if self.len == 0 || refresh_hz <= 0.0 {
|
||||||
|
return (0, 0.0);
|
||||||
|
}
|
||||||
|
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||||
|
let late = self.ring[..self.len]
|
||||||
|
.iter()
|
||||||
|
.filter(|&&d| d > budget)
|
||||||
|
.count() as u64;
|
||||||
|
(late, 100.0 * late as f64 / self.len as f64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FrameReport {
|
impl Default for FrameReport {
|
||||||
@@ -296,4 +490,68 @@ mod tests {
|
|||||||
// same pattern here.
|
// same pattern here.
|
||||||
assert!(stats.worst <= Duration::from_millis(5));
|
assert!(stats.worst <= Duration::from_millis(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_marks_means_no_phases() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.record(Duration::from_millis(5));
|
||||||
|
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phases_slice_frames_by_when_they_were_marked() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.mark_phase("a");
|
||||||
|
for _ in 0..5 {
|
||||||
|
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
|
||||||
|
}
|
||||||
|
r.mark_phase("b");
|
||||||
|
for _ in 0..3 {
|
||||||
|
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let phases = r.phase_stats(now, 60.0);
|
||||||
|
assert_eq!(phases.len(), 2);
|
||||||
|
assert_eq!(phases[0].name, "a");
|
||||||
|
assert_eq!(phases[0].frames, 5);
|
||||||
|
assert_eq!(phases[0].late, 0);
|
||||||
|
assert_eq!(phases[0].worst, Duration::from_millis(10));
|
||||||
|
assert_eq!(phases[1].name, "b");
|
||||||
|
assert_eq!(phases[1].frames, 3);
|
||||||
|
assert_eq!(phases[1].late, 3);
|
||||||
|
assert_eq!(phases[1].late_percent, 100.0);
|
||||||
|
assert_eq!(phases[1].worst, Duration::from_millis(20));
|
||||||
|
assert!(phases[0].complete);
|
||||||
|
assert!(phases[1].complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_phase_runs_until_now() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.mark_phase("only");
|
||||||
|
r.record(Duration::from_millis(1));
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
let now = Instant::now();
|
||||||
|
let phases = r.phase_stats(now, 60.0);
|
||||||
|
assert_eq!(phases.len(), 1);
|
||||||
|
assert!(phases[0].duration >= Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_phase_marks() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.mark_phase("a");
|
||||||
|
r.record(Duration::from_millis(1));
|
||||||
|
r.reset();
|
||||||
|
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
|
||||||
|
r.record(Duration::from_millis(10));
|
||||||
|
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
|
||||||
|
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ use crate::{
|
|||||||
util::{HashMap, Vec2},
|
util::{HashMap, Vec2},
|
||||||
};
|
};
|
||||||
use data::WindowUniform;
|
use data::WindowUniform;
|
||||||
|
use pollster::FutureExt;
|
||||||
use wgpu::{
|
use wgpu::{
|
||||||
util::{BufferInitDescriptor, DeviceExt},
|
util::{BufferInitDescriptor, DeviceExt},
|
||||||
*,
|
*,
|
||||||
@@ -65,6 +66,57 @@ pub fn device_limits() -> Limits {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
|
||||||
|
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
|
||||||
|
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
|
||||||
|
/// creation: a validation failure during an ordinary frame's `update`/
|
||||||
|
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
|
||||||
|
/// no caller able to intervene -- exactly what aborted the P0 bench APK
|
||||||
|
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
|
||||||
|
/// platform backends install a handler here instead of leaving the default
|
||||||
|
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
|
||||||
|
/// must never panic in release").
|
||||||
|
///
|
||||||
|
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
|
||||||
|
/// process-wide static, so a caller builds one alongside its `Device`,
|
||||||
|
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
|
||||||
|
/// for the Diagnostics page to read -- context passed explicitly, per
|
||||||
|
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
|
||||||
|
/// `OnceLock`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WgpuErrorLog {
|
||||||
|
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many uncaptured errors the log keeps -- old ones drop off the front
|
||||||
|
/// rather than being trimmed on read, so a build spraying errors every
|
||||||
|
/// frame doesn't grow this without bound.
|
||||||
|
const WGPU_ERROR_LOG_CAP: usize = 20;
|
||||||
|
|
||||||
|
impl Default for WgpuErrorLog {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WgpuErrorLog {
|
||||||
|
pub fn record(&self, error: impl std::fmt::Display) {
|
||||||
|
let mut errors = self.errors.lock().unwrap();
|
||||||
|
if errors.len() >= WGPU_ERROR_LOG_CAP {
|
||||||
|
errors.pop_front();
|
||||||
|
}
|
||||||
|
errors.push_back(error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot for the Diagnostics page -- cloned rather than held,
|
||||||
|
/// since the lock must not outlive one call.
|
||||||
|
pub fn snapshot(&self) -> Vec<String> {
|
||||||
|
self.errors.lock().unwrap().iter().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UiRenderNode {
|
pub struct UiRenderNode {
|
||||||
uniform_group: BindGroup,
|
uniform_group: BindGroup,
|
||||||
primitive_layout: BindGroupLayout,
|
primitive_layout: BindGroupLayout,
|
||||||
@@ -152,7 +204,7 @@ impl UiRenderNode {
|
|||||||
queue: &Queue,
|
queue: &Queue,
|
||||||
ui: &mut UiData,
|
ui: &mut UiData,
|
||||||
ui_render: &mut UiRenderState,
|
ui_render: &mut UiRenderState,
|
||||||
) {
|
) -> FrameUpdateStats {
|
||||||
self.active.clear();
|
self.active.clear();
|
||||||
for (i, primitives) in ui_render.layers.iter_mut() {
|
for (i, primitives) in ui_render.layers.iter_mut() {
|
||||||
self.active.push(i);
|
self.active.push(i);
|
||||||
@@ -236,6 +288,10 @@ impl UiRenderNode {
|
|||||||
if rebuild_main {
|
if rebuild_main {
|
||||||
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
|
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
|
||||||
}
|
}
|
||||||
|
FrameUpdateStats {
|
||||||
|
masks_resized,
|
||||||
|
moves_resized,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Takes a size rather than a window type: this is the only thing the
|
/// Takes a size rather than a window type: this is the only thing the
|
||||||
@@ -251,26 +307,69 @@ impl UiRenderNode {
|
|||||||
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
|
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(device: &Device, queue: &Queue, config: &SurfaceConfiguration) -> Self {
|
/// Builds every bind group layout, the pipeline, and the two storage
|
||||||
|
/// buffers this needs -- fallibly, since this is exactly the call that
|
||||||
|
/// aborted the process on Iris's phone in a release build with no
|
||||||
|
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
|
||||||
|
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
|
||||||
|
/// behaviour for an uncaptured error is `panic!` with no caller able to
|
||||||
|
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
|
||||||
|
/// call below runs inside three nested error scopes (one per
|
||||||
|
/// `ErrorFilter`) instead: whichever scope catches something, its
|
||||||
|
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
|
||||||
|
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
|
||||||
|
/// would have printed before Android's crash reporter truncated it) and
|
||||||
|
/// becomes this function's `Err`. Both callers
|
||||||
|
/// (`android::render::AndroidRenderer::new`, `default::render::
|
||||||
|
/// UiRenderer::new`) already call `Device`-creation with
|
||||||
|
/// `pollster::block_on`, so returning a plain `Result` here rather than
|
||||||
|
/// making this `async fn` keeps that same synchronous shape.
|
||||||
|
pub fn new(
|
||||||
|
device: &Device,
|
||||||
|
queue: &Queue,
|
||||||
|
config: &SurfaceConfiguration,
|
||||||
|
window_size: impl Into<Vec2>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
// Popped in reverse of this order, once every creation call below
|
||||||
|
// has run -- `Device::push_error_scope`'s own contract.
|
||||||
|
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
|
||||||
|
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
|
||||||
|
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
|
||||||
|
|
||||||
let shader = device.create_shader_module(ShaderModuleDescriptor {
|
let shader = device.create_shader_module(ShaderModuleDescriptor {
|
||||||
label: Some("UI Shape Shader"),
|
label: Some("UI Shape Shader"),
|
||||||
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeded from the surface's own size, not `WindowUniform::default()`
|
// Seeded from the caller's own reported size, not
|
||||||
// (0, 0): the vertex shader divides by `window.dim` to reach clip
|
// `WindowUniform::default()` (0, 0): the vertex shader divides by
|
||||||
// space, so a window this buffer disagrees with means every
|
// `window.dim` to reach clip space, so a window this buffer
|
||||||
// primitive's position is NaN/Inf and is dropped before
|
// disagrees with means every primitive's position is NaN/Inf and is
|
||||||
// rasterization -- the clear colour still reaches the screen (the
|
// dropped before rasterization -- the clear colour still reaches
|
||||||
// pass runs regardless) while nothing drawn on top of it ever does.
|
// the screen (the pass runs regardless) while nothing drawn on top
|
||||||
// winit's backend gets away with the old default because winit
|
// of it ever does. winit's backend gets away with the old default
|
||||||
// fires an initial `WindowEvent::Resized` that calls `resize()`
|
// because winit fires an initial `WindowEvent::Resized` that calls
|
||||||
// before the first frame; android-view has no such automatic
|
// `resize()` before the first frame; android-view has no such
|
||||||
// event, so `AndroidRenderer::new` built a node whose window buffer
|
// automatic event, so `AndroidRenderer::new` built a node whose
|
||||||
// was never corrected -- this is I2's "nothing draws" bug (RUST.md).
|
// window buffer was never corrected -- this is I2's "nothing draws"
|
||||||
let window_uniform = WindowUniform {
|
// bug (RUST.md).
|
||||||
width: config.width as f32,
|
//
|
||||||
height: config.height as f32,
|
// **Deliberately not `config.width`/`config.height`**: those are
|
||||||
|
// the surface's *physical* pixel size, which the swapchain needs,
|
||||||
|
// but everything downstream of this uniform (layout, hit-testing,
|
||||||
|
// glyph/rect positions) works in the caller's own units -- on
|
||||||
|
// Android that's *logical* (physical / density) since RUST.md's P0
|
||||||
|
// box ("text is far too small"), on desktop it's whatever
|
||||||
|
// `default::render::UiRenderer::new` already divides by
|
||||||
|
// `window.scale_factor()`. Passing it in explicitly, rather than
|
||||||
|
// deriving it from `config` here, is what keeps this crate from
|
||||||
|
// needing to know either platform's notion of density at all.
|
||||||
|
let window_uniform = {
|
||||||
|
let size = window_size.into();
|
||||||
|
WindowUniform {
|
||||||
|
width: size.x,
|
||||||
|
height: size.y,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
|
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
|
||||||
label: Some("window"),
|
label: Some("window"),
|
||||||
@@ -373,7 +472,18 @@ impl UiRenderNode {
|
|||||||
cache: None,
|
cache: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
// Reverse of the push order above. Only one of these should ever be
|
||||||
|
// `Some` in practice -- three separate scopes exist to name *which*
|
||||||
|
// kind of error it was, not because more than one is expected at
|
||||||
|
// once.
|
||||||
|
let internal_err = internal_scope.pop().block_on();
|
||||||
|
let validation_err = validation_scope.pop().block_on();
|
||||||
|
let oom_err = oom_scope.pop().block_on();
|
||||||
|
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
|
||||||
|
return Err(err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
uniform_group,
|
uniform_group,
|
||||||
primitive_layout,
|
primitive_layout,
|
||||||
rsc_layout,
|
rsc_layout,
|
||||||
@@ -387,7 +497,7 @@ impl UiRenderNode {
|
|||||||
move_offsets,
|
move_offsets,
|
||||||
masks_layout,
|
masks_layout,
|
||||||
masks_group,
|
masks_group,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bind_group_0(
|
fn bind_group_0(
|
||||||
@@ -554,4 +664,26 @@ impl UiRenderNode {
|
|||||||
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
||||||
self.textures.take_bind_group_creates()
|
self.textures.take_bind_group_creates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atlas-array `grow_array` calls since the last call -- same calling
|
||||||
|
/// convention as `take_image_bind_group_creates` (call once per frame,
|
||||||
|
/// before `update()`, to read exactly the previous frame's tally). Part
|
||||||
|
/// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
|
||||||
|
/// first input frame" investigation): if a report ever shows a grow
|
||||||
|
/// landing on the same frame the glyphs vanished, that is the
|
||||||
|
/// coincidence to chase first.
|
||||||
|
pub fn take_atlas_pages_grown(&mut self) -> u64 {
|
||||||
|
self.textures.take_pages_grown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `UiRenderNode::update` changed this frame that a caller building a
|
||||||
|
/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/
|
||||||
|
/// `take_atlas_pages_grown` for the two counters this doesn't carry (they
|
||||||
|
/// use the existing "call before update()" convention instead, so as not
|
||||||
|
/// to disturb `bench_images`' documented counts).
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct FrameUpdateStats {
|
||||||
|
pub masks_resized: bool,
|
||||||
|
pub moves_resized: bool,
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
ArrBuf,
|
ArrBuf,
|
||||||
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
||||||
},
|
},
|
||||||
|
util::HashSet,
|
||||||
};
|
};
|
||||||
use bytemuck::Pod;
|
use bytemuck::Pod;
|
||||||
use wgpu::*;
|
use wgpu::*;
|
||||||
@@ -277,6 +278,31 @@ impl Primitives {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many instances are still bound for the GPU -- the O(1) half of
|
||||||
|
/// the orphan check, so the O(primitives) walk below only runs on a
|
||||||
|
/// frame that already looks wrong. See
|
||||||
|
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||||
|
pub fn live_count(&self) -> usize {
|
||||||
|
(self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every instance that is still bound for the GPU, as `(inst_idx,
|
||||||
|
/// owner, is_image)` -- everything except the slots already handed to
|
||||||
|
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
||||||
|
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
|
||||||
|
/// to check that every drawn primitive still belongs to a live widget.
|
||||||
|
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
|
||||||
|
let free: HashSet<usize> = self.free.iter().copied().collect();
|
||||||
|
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
|
||||||
|
let rects = (0..self.instances.len())
|
||||||
|
.filter(move |i| !free.contains(i))
|
||||||
|
.map(|i| (i, self.assoc[i], false));
|
||||||
|
let images = (0..self.images.len())
|
||||||
|
.filter(move |i| !image_free.contains(i))
|
||||||
|
.map(|i| (i, self.image_assoc[i], true));
|
||||||
|
rects.chain(images)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn data(&self) -> &PrimitiveData {
|
pub fn data(&self) -> &PrimitiveData {
|
||||||
&self.data
|
&self.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,11 +80,17 @@ var<storage> masks: array<Mask>;
|
|||||||
@group(3) @binding(1)
|
@group(3) @binding(1)
|
||||||
var<storage> move_offsets: array<MoveOffset>;
|
var<storage> move_offsets: array<MoveOffset>;
|
||||||
|
|
||||||
// A move chain more than this deep means something else is wrong (an
|
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
|
||||||
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in
|
// render_state.rs, which walks the identical chain on the CPU side for
|
||||||
// render_state.rs, which walks the identical bound on the CPU side for
|
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||||
// hit-testing. Bounded so a malformed chain cannot hang the GPU.
|
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||||
const MOVE_CHAIN_LIMIT: u32 = 16u;
|
// and that was too small: the transcript screen's composer field sits 17
|
||||||
|
// slots below the root, measured 2026-09-07 on this checkout's emulator
|
||||||
|
// by tapping it (the CPU walk's own debug assert names the chain now).
|
||||||
|
// Past the bound both walks simply stop summing, so the widget draws and
|
||||||
|
// hit-tests short by whatever the outer slots held, with nothing on
|
||||||
|
// screen to say so.
|
||||||
|
const MOVE_CHAIN_LIMIT: u32 = 64u;
|
||||||
|
|
||||||
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||||
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
|
|||||||
|
|
||||||
use super::atlas::PAGE;
|
use super::atlas::PAGE;
|
||||||
|
|
||||||
|
/// The fewest layers the glyph atlas array is ever created with. Two, not
|
||||||
|
/// one, for the GLES reason written on `create_array_texture`.
|
||||||
|
const MIN_ARRAY_LAYERS: u32 = 2;
|
||||||
|
|
||||||
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
|
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
|
||||||
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
|
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
|
||||||
/// same thing on both sides without a second map to keep in sync.
|
/// same thing on both sides without a second map to keep in sync.
|
||||||
@@ -67,6 +71,12 @@ pub struct GpuTextures {
|
|||||||
/// unchanging image list is zero, the same way `UiRenderState`'s
|
/// unchanging image list is zero, the same way `UiRenderState`'s
|
||||||
/// `draw_count`/`region_mut_count` prove the layout side.
|
/// `draw_count`/`region_mut_count` prove the layout side.
|
||||||
bind_group_creates: u64,
|
bind_group_creates: u64,
|
||||||
|
/// `grow_array` calls since the last `take_pages_grown` -- the
|
||||||
|
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
|
||||||
|
/// input frame" investigation) reads this alongside `bind_group_creates`
|
||||||
|
/// to say whether *this* frame's glyph disappearance, if any, coincided
|
||||||
|
/// with the atlas array being recreated.
|
||||||
|
pages_grown: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuTextures {
|
impl GpuTextures {
|
||||||
@@ -226,6 +236,7 @@ impl GpuTextures {
|
|||||||
/// array's view, which invalidates every bind group that referenced it,
|
/// array's view, which invalidates every bind group that referenced it,
|
||||||
/// so this also rebuilds all of them before returning.
|
/// so this also rebuilds all of them before returning.
|
||||||
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
||||||
|
self.pages_grown += 1;
|
||||||
let new_capacity = self.array_capacity * 2;
|
let new_capacity = self.array_capacity * 2;
|
||||||
let new_texture = Self::create_array_texture(&self.device, new_capacity);
|
let new_texture = Self::create_array_texture(&self.device, new_capacity);
|
||||||
if self.page_count > 0 {
|
if self.page_count > 0 {
|
||||||
@@ -353,7 +364,26 @@ impl GpuTextures {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
|
||||||
|
/// array is not one on the GLES backend**: wgpu-hal picks the GL
|
||||||
|
/// texture target from the descriptor alone
|
||||||
|
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
|
||||||
|
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
|
||||||
|
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
|
||||||
|
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
|
||||||
|
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
|
||||||
|
/// filled box. That was iris's appearance on the emulator's GLES for
|
||||||
|
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
|
||||||
|
/// it is a real defect on any device whose adapter is GL rather than
|
||||||
|
/// Vulkan, not an emulator artifact. So the array never has fewer than
|
||||||
|
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
|
||||||
|
/// texture memory and is used by the next atlas page anyway.
|
||||||
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
|
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
|
||||||
|
debug_assert!(
|
||||||
|
capacity >= MIN_ARRAY_LAYERS,
|
||||||
|
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
|
||||||
|
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
|
||||||
|
);
|
||||||
device.create_texture(&TextureDescriptor {
|
device.create_texture(&TextureDescriptor {
|
||||||
label: Some("glyph atlas array"),
|
label: Some("glyph atlas array"),
|
||||||
size: Extent3d {
|
size: Extent3d {
|
||||||
@@ -375,7 +405,7 @@ impl GpuTextures {
|
|||||||
pub fn new(device: &Device, queue: &Queue) -> Self {
|
pub fn new(device: &Device, queue: &Queue) -> Self {
|
||||||
let sampler = default_sampler(device);
|
let sampler = default_sampler(device);
|
||||||
let null_view = null_texture_view(device);
|
let null_view = null_texture_view(device);
|
||||||
let array_capacity = 1;
|
let array_capacity = MIN_ARRAY_LAYERS;
|
||||||
let array_texture = Self::create_array_texture(device, array_capacity);
|
let array_texture = Self::create_array_texture(device, array_capacity);
|
||||||
let array_view = array_texture.create_view(&TextureViewDescriptor {
|
let array_view = array_texture.create_view(&TextureViewDescriptor {
|
||||||
dimension: Some(TextureViewDimension::D2Array),
|
dimension: Some(TextureViewDimension::D2Array),
|
||||||
@@ -392,6 +422,7 @@ impl GpuTextures {
|
|||||||
sampler,
|
sampler,
|
||||||
null_view,
|
null_view,
|
||||||
bind_group_creates: 0,
|
bind_group_creates: 0,
|
||||||
|
pages_grown: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +433,12 @@ impl GpuTextures {
|
|||||||
std::mem::take(&mut self.bind_group_creates)
|
std::mem::take(&mut self.bind_group_creates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
|
||||||
|
/// field comment.
|
||||||
|
pub fn take_pages_grown(&mut self) -> u64 {
|
||||||
|
std::mem::take(&mut self.pages_grown)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn array_view(&self) -> &TextureView {
|
pub fn array_view(&self) -> &TextureView {
|
||||||
&self.array_view
|
&self.array_view
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
|
use crate::{
|
||||||
|
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
|
||||||
|
};
|
||||||
|
|
||||||
/// important non rendering data for retained drawing
|
/// important non rendering data for retained drawing
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -9,7 +11,22 @@ pub struct ActiveData {
|
|||||||
pub textures: Vec<TextureHandle>,
|
pub textures: Vec<TextureHandle>,
|
||||||
pub primitives: Vec<PrimitiveHandle>,
|
pub primitives: Vec<PrimitiveHandle>,
|
||||||
pub children: Vec<WidgetId>,
|
pub children: Vec<WidgetId>,
|
||||||
|
/// The mask this widget was drawn **under** (its parent's), not the
|
||||||
|
/// one it set for itself -- see `own_mask` for that.
|
||||||
pub mask: MaskIdx,
|
pub mask: MaskIdx,
|
||||||
|
/// The mask slot this widget allocated for *itself* with
|
||||||
|
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
|
||||||
|
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
|
||||||
|
/// a fresh slot each draw left every already-drawn descendant --
|
||||||
|
/// which `draw_inner`'s unchanged-region fast path does not revisit --
|
||||||
|
/// clipping to the *old* slot's region, so a composer whose bar had
|
||||||
|
/// since been placed at the bottom of the screen was still being
|
||||||
|
/// clipped to a box at the top of it and drew nothing (measured
|
||||||
|
/// 2026-09-06: four mask entries live, none of them the widget's
|
||||||
|
/// current region). Its path out is the `undraw` branch of
|
||||||
|
/// `UiRenderState::remove`, which drops the self-ownership ref taken
|
||||||
|
/// when the slot was allocated.
|
||||||
|
pub own_mask: MaskIdx,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
/// What `Widget::draw` returned the last time this widget was actually
|
/// What `Widget::draw` returned the last time this widget was actually
|
||||||
/// drawn -- read by a parent placing this widget again without
|
/// drawn -- read by a parent placing this widget again without
|
||||||
@@ -21,4 +38,32 @@ pub struct ActiveData {
|
|||||||
/// so a retained child's `parent` link never goes stale). See
|
/// so a retained child's `parent` link never goes stale). See
|
||||||
/// LAYOUT.md section 2.
|
/// LAYOUT.md section 2.
|
||||||
pub move_slot: MoveIdx,
|
pub move_slot: MoveIdx,
|
||||||
|
/// How much of this widget's own `move_slot` delta is already folded
|
||||||
|
/// into `region` above, in window pixels. The two mechanisms that
|
||||||
|
/// write that slot disagree about this and cannot be told apart from
|
||||||
|
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
|
||||||
|
/// together (the *offered* region genuinely moved), while
|
||||||
|
/// `Painter::reposition` writes only the delta (`region` stays the
|
||||||
|
/// offered box and the delta says where inside it the content was
|
||||||
|
/// placed). So anything that wants the widget's real position --
|
||||||
|
/// `resolved_region`, and through it every hit test -- must subtract
|
||||||
|
/// this from the chain sum. Without it a panned widget's own hit box
|
||||||
|
/// sits at twice the pan while its descendants' are correct, which is
|
||||||
|
/// how it went unnoticed: the composer's field became untappable
|
||||||
|
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
|
||||||
|
/// is really redrawn, since `draw_inner` zeroes the slot then too.
|
||||||
|
pub move_applied: Vec2,
|
||||||
|
/// The offset the last `Painter::reposition` placed this widget's
|
||||||
|
/// content at *within* `region`, in window pixels. The move slot has
|
||||||
|
/// exactly one owner and one meaning:
|
||||||
|
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
|
||||||
|
/// adds to the first, `reposition` overwrites the second (it
|
||||||
|
/// recomputes `from` afresh every call, so repeating it must land on
|
||||||
|
/// the same answer rather than drifting), and both then rewrite the
|
||||||
|
/// slot from the sum -- which is what lets a parent both move a child
|
||||||
|
/// with its own layout and place it inside that moved region in one
|
||||||
|
/// frame. `List::place`'s Bottom-known branch does exactly that once a
|
||||||
|
/// row's blocks wrap. Reset to zero on a real redraw, with
|
||||||
|
/// `move_applied` and the slot itself.
|
||||||
|
pub repositioned: Vec2,
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,46 @@ pub struct UiData {
|
|||||||
/// id (never reallocated), so a retained descendant's `parent` index
|
/// id (never reallocated), so a retained descendant's `parent` index
|
||||||
/// never goes stale -- see LAYOUT.md section 2.
|
/// never goes stale -- see LAYOUT.md section 2.
|
||||||
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||||
|
/// Every widget whose [`crate::Widget::tick`] should run before the
|
||||||
|
/// next frame -- today, a `List` coasting through a fling. Added by
|
||||||
|
/// [`Self::animate`] when the animation starts and removed by
|
||||||
|
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
||||||
|
/// a stopped animation costs nothing and a dropped widget cannot be
|
||||||
|
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
|
||||||
|
/// way).
|
||||||
|
animating: Vec<WidgetId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiData {
|
||||||
|
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
|
||||||
|
/// says it is done. Idempotent -- registering an already-animating
|
||||||
|
/// widget is the ordinary case (a second fling before the first
|
||||||
|
/// settled) and must not tick it twice per frame.
|
||||||
|
pub fn animate(&mut self, id: WidgetId) {
|
||||||
|
if !self.animating.contains(&id) {
|
||||||
|
self.animating.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick every registered widget to `now`, drop the ones that finished,
|
||||||
|
/// and say whether any is still going -- which is a backend's cue to
|
||||||
|
/// ask for another frame. Called once per frame *before* the draw, so
|
||||||
|
/// what the frame draws is this instant's position rather than the
|
||||||
|
/// previous one's.
|
||||||
|
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
// Taken out and put back rather than iterated in place: `tick`
|
||||||
|
// needs `&mut` on the widget arena this list lives beside, and a
|
||||||
|
// widget is free to register another one while ticking.
|
||||||
|
let mut registered = std::mem::take(&mut self.animating);
|
||||||
|
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
|
||||||
|
Some(widget) => widget.tick(now),
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
for id in registered {
|
||||||
|
self.animate(id);
|
||||||
|
}
|
||||||
|
!self.animating.is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UiRsc {
|
pub trait UiRsc {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct Painter<'a> {
|
|||||||
pub(super) region: UiRegion,
|
pub(super) region: UiRegion,
|
||||||
pub(super) mask: MaskIdx,
|
pub(super) mask: MaskIdx,
|
||||||
pub(super) move_slot: MoveIdx,
|
pub(super) move_slot: MoveIdx,
|
||||||
|
/// This widget's own mask slot, reused across redraws -- see
|
||||||
|
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
|
||||||
|
/// for the first time in this widget's life.
|
||||||
|
pub(super) own_mask: MaskIdx,
|
||||||
pub(super) textures: Vec<TextureHandle>,
|
pub(super) textures: Vec<TextureHandle>,
|
||||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||||
pub(super) children: Vec<WidgetId>,
|
pub(super) children: Vec<WidgetId>,
|
||||||
@@ -48,12 +52,32 @@ impl<'a> Painter<'a> {
|
|||||||
self.primitive_at(primitive, region.within(&self.region));
|
self.primitive_at(primitive, region.within(&self.region));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clip everything this widget draws, itself and its descendants, to
|
||||||
|
/// `region`. One per widget: a second call would need the two to be
|
||||||
|
/// intersected, which nothing here does.
|
||||||
|
///
|
||||||
|
/// The slot is allocated once and **rewritten in place** on every
|
||||||
|
/// later draw rather than pushed again, because a descendant whose own
|
||||||
|
/// region did not change is not redrawn (`draw_inner`'s fast path) and
|
||||||
|
/// so keeps pointing at whichever slot it was drawn under. See
|
||||||
|
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||||
pub fn set_mask(&mut self, region: UiRegion) {
|
pub fn set_mask(&mut self, region: UiRegion) {
|
||||||
assert!(self.mask == MaskIdx::NONE);
|
assert!(self.mask == MaskIdx::NONE);
|
||||||
self.mask = self.rsc.ui_mut().masks.push(Mask {
|
let mask = Mask {
|
||||||
region,
|
region,
|
||||||
move_idx: self.move_slot,
|
move_idx: self.move_slot,
|
||||||
});
|
};
|
||||||
|
if self.own_mask == MaskIdx::NONE {
|
||||||
|
let slot = self.rsc.ui_mut().masks.push(mask);
|
||||||
|
// The one ref this widget holds on its own slot, so the slot
|
||||||
|
// outlives any single frame's primitives; released in
|
||||||
|
// `UiRenderState::remove`'s `undraw` branch.
|
||||||
|
self.rsc.ui_mut().masks.push_ref(slot);
|
||||||
|
self.own_mask = slot;
|
||||||
|
} else {
|
||||||
|
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||||
|
}
|
||||||
|
self.mask = self.own_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a widget within this widget's region, returning the size it
|
/// Draws a widget within this widget's region, returning the size it
|
||||||
@@ -86,6 +110,7 @@ impl<'a> Painter<'a> {
|
|||||||
self.mask,
|
self.mask,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
crate::render::MaskIdx::NONE,
|
||||||
self.rsc,
|
self.rsc,
|
||||||
);
|
);
|
||||||
self.state
|
self.state
|
||||||
@@ -165,8 +190,21 @@ impl<'a> Painter<'a> {
|
|||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
|
let density = self.state.density;
|
||||||
|
// Counted here rather than in `TextView::render`, which returns
|
||||||
|
// its memoized layout without reaching this -- so this counts
|
||||||
|
// shapes, not requests. `UiRenderState::take_counters`.
|
||||||
|
self.state.shape_count += 1;
|
||||||
let ui = self.rsc.ui_mut();
|
let ui = self.rsc.ui_mut();
|
||||||
ui.text.render(buffer, attrs, width, &mut ui.textures)
|
ui.text
|
||||||
|
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which glyph atlas the glyphs handed out right now belong to --
|
||||||
|
/// what a widget caching a [`RenderedText`] across frames has to
|
||||||
|
/// compare against before re-emitting it (`GlyphAtlas::clear`).
|
||||||
|
pub fn atlas_generation(&mut self) -> u64 {
|
||||||
|
self.rsc.ui_mut().text.atlas.generation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||||
@@ -175,6 +213,18 @@ impl<'a> Painter<'a> {
|
|||||||
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
||||||
/// and nothing else.
|
/// and nothing else.
|
||||||
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
||||||
|
// A caller re-emitting quads placed against an atlas that has since
|
||||||
|
// been cleared draws every glyph from coordinates now holding
|
||||||
|
// something else. Caught at the submission rather than on screen,
|
||||||
|
// where it reads as fragments of unrelated letters.
|
||||||
|
debug_assert_eq!(
|
||||||
|
text.generation,
|
||||||
|
self.atlas_generation(),
|
||||||
|
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
||||||
|
re-render after the atlas was cleared",
|
||||||
|
text.generation,
|
||||||
|
self.atlas_generation(),
|
||||||
|
);
|
||||||
let flags_for = |is_color| {
|
let flags_for = |is_color| {
|
||||||
if is_color {
|
if is_color {
|
||||||
GlyphPrimitive::IS_COLOR
|
GlyphPrimitive::IS_COLOR
|
||||||
@@ -210,6 +260,12 @@ impl<'a> Painter<'a> {
|
|||||||
self.state.output_size
|
self.state.output_size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
|
||||||
|
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
|
||||||
|
pub fn density(&self) -> f32 {
|
||||||
|
self.state.density
|
||||||
|
}
|
||||||
|
|
||||||
pub fn px_size(&mut self) -> Vec2 {
|
pub fn px_size(&mut self) -> Vec2 {
|
||||||
self.region.size().to_abs(self.state.output_size)
|
self.region.size().to_abs(self.state.output_size)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
||||||
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
||||||
render::MoveOffset,
|
render::{IMAGE_BINDING, MoveOffset},
|
||||||
util::{HashMap, HashSet, Id, Vec2},
|
util::{HashMap, HashSet, Id, Vec2},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -9,11 +9,44 @@ pub struct UiRenderState {
|
|||||||
pub active: HashMap<WidgetId, ActiveData>,
|
pub active: HashMap<WidgetId, ActiveData>,
|
||||||
pub layers: PrimitiveLayers,
|
pub layers: PrimitiveLayers,
|
||||||
pub(super) output_size: Vec2,
|
pub(super) output_size: Vec2,
|
||||||
|
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||||
|
/// unscaled display) until a backend that knows its own density calls
|
||||||
|
/// `set_density` (Android's `content_scale`, read at `surface_changed`
|
||||||
|
/// time); the winit backend has no analogous per-monitor value wired up
|
||||||
|
/// yet and stays at the default.
|
||||||
|
pub(super) density: f32,
|
||||||
|
|
||||||
old_root: Option<WidgetId>,
|
old_root: Option<WidgetId>,
|
||||||
resized: bool,
|
resized: bool,
|
||||||
|
/// The widgets whose `Widget::draw` is on the stack right now -- so
|
||||||
|
/// [`Self::redraw`] can tell "this widget needs drawing again" from
|
||||||
|
/// "an ancestor is drawing it at this very moment", where a second
|
||||||
|
/// draw would leave the first one's primitives behind with nothing
|
||||||
|
/// owning them. An id is inserted immediately before `draw` is called
|
||||||
|
/// and removed the moment it returns (both in `draw_inner`), so this
|
||||||
|
/// is empty between frames -- asserted at the end of `update`.
|
||||||
|
///
|
||||||
|
/// It used to only ever be inserted into, and `redraw` removed the id
|
||||||
|
/// *before* testing for it, which made the test constant `false`: the
|
||||||
|
/// guard could never fire and the set grew by one entry per widget
|
||||||
|
/// ever drawn and was never emptied.
|
||||||
draw_started: HashSet<WidgetId>,
|
draw_started: HashSet<WidgetId>,
|
||||||
|
|
||||||
|
/// The widget currently holding exclusive pointer input, if any --
|
||||||
|
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
|
||||||
|
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
|
||||||
|
/// `CursorData` reaching this through an async `task_on` handler needs
|
||||||
|
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
|
||||||
|
/// dispatched to, not owned, at that layer) and this render state is
|
||||||
|
/// the one structure both backends (winit, android-view) already hold
|
||||||
|
/// across frames, the same way `old_root`/`resized` are -- see
|
||||||
|
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
|
||||||
|
/// a gesture has committed to panning or selecting, every later sample
|
||||||
|
/// of it must reach the same widget even if the finger has moved off
|
||||||
|
/// whatever hit region first noticed the press. Never held across an
|
||||||
|
/// await or another lock -- every access here is a single get/set.
|
||||||
|
captured: std::sync::Mutex<Option<WidgetId>>,
|
||||||
|
|
||||||
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
||||||
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
||||||
/// stated in terms of these two: an unchanged frame must cost 0 of
|
/// stated in terms of these two: an unchanged frame must cost 0 of
|
||||||
@@ -22,12 +55,22 @@ pub struct UiRenderState {
|
|||||||
draw_count: u64,
|
draw_count: u64,
|
||||||
region_mut_count: u64,
|
region_mut_count: u64,
|
||||||
mov_count: u64,
|
mov_count: u64,
|
||||||
|
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
||||||
|
/// which `TextView::render` only reaches on a cache miss.
|
||||||
|
pub(super) shape_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A move chain more than this deep would mean something else is wrong
|
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||||
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
|
/// which walks the identical chain and must be kept in step with this
|
||||||
/// the identical bound and must be kept in step with this constant.
|
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
|
||||||
pub const MOVE_CHAIN_LIMIT: usize = 16;
|
/// not as a statement about how deep a real tree gets: it was 16, and the
|
||||||
|
/// transcript screen's composer field turned out to sit **17** slots below
|
||||||
|
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
|
||||||
|
/// the composer in a debug build -- the assert in `resolve_move_chain`
|
||||||
|
/// prints the chain). A chain past the bound is not reported anywhere at
|
||||||
|
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||||
|
/// tested short by whatever the outer slots held.
|
||||||
|
pub const MOVE_CHAIN_LIMIT: usize = 64;
|
||||||
|
|
||||||
impl UiRenderState {
|
impl UiRenderState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -35,23 +78,33 @@ impl UiRenderState {
|
|||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
layers: Default::default(),
|
layers: Default::default(),
|
||||||
output_size: Vec2::ZERO,
|
output_size: Vec2::ZERO,
|
||||||
|
density: 1.0,
|
||||||
old_root: None,
|
old_root: None,
|
||||||
resized: false,
|
resized: false,
|
||||||
draw_started: Default::default(),
|
draw_started: Default::default(),
|
||||||
|
captured: Default::default(),
|
||||||
draw_count: 0,
|
draw_count: 0,
|
||||||
region_mut_count: 0,
|
region_mut_count: 0,
|
||||||
mov_count: 0,
|
mov_count: 0,
|
||||||
|
shape_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
||||||
/// writes) counters -- call once per frame before `update()` to
|
/// writes, text shapes) counters -- call once per frame before
|
||||||
/// measure exactly that frame, per LAYOUT.md section 8.
|
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
||||||
pub fn take_counters(&mut self) -> (u64, u64, u64) {
|
///
|
||||||
|
/// The fourth is the one a draw count cannot stand in for: a widget
|
||||||
|
/// can be redrawn without re-shaping (`TextView::render` memoizes by
|
||||||
|
/// width) and re-shaped without any extra draw, and it is re-shaping
|
||||||
|
/// that the per-block transcript row exists to avoid -- see
|
||||||
|
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
|
||||||
|
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
|
||||||
(
|
(
|
||||||
std::mem::take(&mut self.draw_count),
|
std::mem::take(&mut self.draw_count),
|
||||||
std::mem::take(&mut self.region_mut_count),
|
std::mem::take(&mut self.region_mut_count),
|
||||||
std::mem::take(&mut self.mov_count),
|
std::mem::take(&mut self.mov_count),
|
||||||
|
std::mem::take(&mut self.shape_count),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +113,20 @@ impl UiRenderState {
|
|||||||
self.resized = true;
|
self.resized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
|
||||||
|
/// resolves against from the next layout pass on -- see `density`'s
|
||||||
|
/// field doc. Not folded into `resize` because the two change on
|
||||||
|
/// different triggers (a surface resize on every rotation or keyboard
|
||||||
|
/// open; a density change only if the app follows the display to a
|
||||||
|
/// different screen, which Android surfaces separately).
|
||||||
|
pub fn set_density(&mut self, density: f32) {
|
||||||
|
self.density = density;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn density(&self) -> f32 {
|
||||||
|
self.density
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
||||||
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||||
// decide whether to panic or not
|
// decide whether to panic or not
|
||||||
@@ -78,6 +145,11 @@ impl UiRenderState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
|
debug_assert!(
|
||||||
|
self.draw_started.is_empty(),
|
||||||
|
"a previous frame left {} widget(s) marked as mid-draw",
|
||||||
|
self.draw_started.len(),
|
||||||
|
);
|
||||||
if self.needs_redraw_all(root) {
|
if self.needs_redraw_all(root) {
|
||||||
self.redraw_all(root, rsc);
|
self.redraw_all(root, rsc);
|
||||||
self.old_root = root.map(|r| r.id());
|
self.old_root = root.map(|r| r.id());
|
||||||
@@ -85,6 +157,8 @@ impl UiRenderState {
|
|||||||
} else if rsc.widgets().has_updates() {
|
} else if rsc.widgets().has_updates() {
|
||||||
self.redraw_updates(rsc);
|
self.redraw_updates(rsc);
|
||||||
}
|
}
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||||
@@ -100,6 +174,7 @@ impl UiRenderState {
|
|||||||
MaskIdx::NONE,
|
MaskIdx::NONE,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
MaskIdx::NONE,
|
||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,12 +209,27 @@ impl UiRenderState {
|
|||||||
mask: MaskIdx,
|
mask: MaskIdx,
|
||||||
old_children: Option<Vec<WidgetId>>,
|
old_children: Option<Vec<WidgetId>>,
|
||||||
old_move_slot: Option<MoveIdx>,
|
old_move_slot: Option<MoveIdx>,
|
||||||
|
old_own_mask: MaskIdx,
|
||||||
rsc: &mut dyn UiRsc,
|
rsc: &mut dyn UiRsc,
|
||||||
) {
|
) {
|
||||||
let mut old_children = old_children.unwrap_or_default();
|
let mut old_children = old_children.unwrap_or_default();
|
||||||
let mut old_move_slot = old_move_slot;
|
let mut old_move_slot = old_move_slot;
|
||||||
|
let mut own_mask = old_own_mask;
|
||||||
|
// Consumed here, not merely read: this call *is* the redraw the mark
|
||||||
|
// asked for, and leaving the mark set is what stranded a widget's
|
||||||
|
// primitives. `Painter::draw_twice` calls this twice for the same id
|
||||||
|
// in one frame (`List::place`'s measurement pass), and on the second
|
||||||
|
// call the still-set mark took the whole `if let` below -- including
|
||||||
|
// the `remove` that frees the first draw's primitives -- out of play,
|
||||||
|
// so `active.insert` at the end overwrote the only handles that could
|
||||||
|
// ever have freed them. The result is a full second copy of the row,
|
||||||
|
// drawn every frame from then on at the oversized measurement region
|
||||||
|
// and, with `List` setting no mask, outside the list's own bounds:
|
||||||
|
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
||||||
|
// The same shape reaches any dirty widget an ancestor redraws first.
|
||||||
|
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
if let Some(active) = self.active.get_mut(&id)
|
if let Some(active) = self.active.get_mut(&id)
|
||||||
&& !rsc.widgets().needs_redraw.contains(&id)
|
&& !dirty
|
||||||
{
|
{
|
||||||
// check to see if we can skip drawing first
|
// check to see if we can skip drawing first
|
||||||
if active.region == region {
|
if active.region == region {
|
||||||
@@ -166,6 +256,15 @@ impl UiRenderState {
|
|||||||
*r = r.outside(&from).within(®ion);
|
*r = r.outside(&from).within(®ion);
|
||||||
self.region_mut_count += 1;
|
self.region_mut_count += 1;
|
||||||
}
|
}
|
||||||
|
// `move_applied` is deliberately **not** touched here,
|
||||||
|
// unlike in `mov`: it counts the part of this widget's own
|
||||||
|
// move-slot delta that `region` has already absorbed, and
|
||||||
|
// this branch writes no delta at all -- the primitives were
|
||||||
|
// moved directly. Counting one would make
|
||||||
|
// `resolved_region` subtract a distance the chain never
|
||||||
|
// held, putting the hit box short of the drawing by
|
||||||
|
// exactly this step. See `ActiveData::move_applied`, and
|
||||||
|
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
|
||||||
active.region = region;
|
active.region = region;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,10 +272,25 @@ impl UiRenderState {
|
|||||||
let active = self.remove(id, false, rsc).unwrap();
|
let active = self.remove(id, false, rsc).unwrap();
|
||||||
old_children = active.children;
|
old_children = active.children;
|
||||||
old_move_slot = Some(active.move_slot);
|
old_move_slot = Some(active.move_slot);
|
||||||
|
own_mask = active.own_mask;
|
||||||
|
} else if dirty && self.active.contains_key(&id) {
|
||||||
|
// Dirty and already drawn: none of the fast paths above may be
|
||||||
|
// taken (the widget's own content changed, so its old primitives
|
||||||
|
// say nothing about its new ones), but they are also the only
|
||||||
|
// thing that frees them. Same two lines, reached the other way.
|
||||||
|
let active = self.remove(id, false, rsc).unwrap();
|
||||||
|
old_children = active.children;
|
||||||
|
old_move_slot = Some(active.move_slot);
|
||||||
|
own_mask = active.own_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw widget
|
// draw widget
|
||||||
self.draw_started.insert(id);
|
let reentrant = !self.draw_started.insert(id);
|
||||||
|
debug_assert!(
|
||||||
|
!reentrant,
|
||||||
|
"widget {id:?} is being drawn while its own draw is already on the stack; \
|
||||||
|
the second draw's primitives would orphan the first's"
|
||||||
|
);
|
||||||
|
|
||||||
let move_slot = match old_move_slot {
|
let move_slot = match old_move_slot {
|
||||||
// Reused across a real redraw of the same id: the fresh
|
// Reused across a real redraw of the same id: the fresh
|
||||||
@@ -205,11 +319,22 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The mask this widget was drawn *under*, kept aside because
|
||||||
|
// `Painter::set_mask` overwrites `painter.mask` with the widget's
|
||||||
|
// own new one -- and `ActiveData::mask`'s only consumer is
|
||||||
|
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
||||||
|
// the set one instead handed a `Masked` its own mask on every
|
||||||
|
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
||||||
|
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
||||||
|
// first time the composer's scroll area was redrawn on the
|
||||||
|
// emulator.
|
||||||
|
let inherited_mask = mask;
|
||||||
let mut painter = Painter {
|
let mut painter = Painter {
|
||||||
state: self,
|
state: self,
|
||||||
region,
|
region,
|
||||||
mask,
|
mask,
|
||||||
move_slot,
|
move_slot,
|
||||||
|
own_mask,
|
||||||
layer,
|
layer,
|
||||||
id,
|
id,
|
||||||
textures: Vec::new(),
|
textures: Vec::new(),
|
||||||
@@ -221,14 +346,26 @@ impl UiRenderState {
|
|||||||
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
||||||
painter.state.draw_count += 1;
|
painter.state.draw_count += 1;
|
||||||
let size = widget.draw(&mut painter);
|
let size = widget.draw(&mut painter);
|
||||||
|
// A reported length is consumed by containers that read `abs`,
|
||||||
|
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
|
||||||
|
// addition), so an unresolved `dp` in one is silently worth zero
|
||||||
|
// -- see `Len::fold_dp`, which is what a widget reporting a
|
||||||
|
// caller-declared size has to put it through.
|
||||||
|
debug_assert!(
|
||||||
|
size.x.dp == 0.0 && size.y.dp == 0.0,
|
||||||
|
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
|
||||||
|
report `Len::fold_dp(painter.density())` instead"
|
||||||
|
);
|
||||||
drop(widget);
|
drop(widget);
|
||||||
|
painter.state.draw_started.remove(&id);
|
||||||
|
|
||||||
let Painter {
|
let Painter {
|
||||||
state: _,
|
state: _,
|
||||||
rsc: _,
|
rsc: _,
|
||||||
region,
|
region,
|
||||||
mask,
|
mask: _,
|
||||||
move_slot,
|
move_slot,
|
||||||
|
own_mask,
|
||||||
textures,
|
textures,
|
||||||
primitives,
|
primitives,
|
||||||
children,
|
children,
|
||||||
@@ -244,10 +381,13 @@ impl UiRenderState {
|
|||||||
textures,
|
textures,
|
||||||
primitives,
|
primitives,
|
||||||
children,
|
children,
|
||||||
mask,
|
mask: inherited_mask,
|
||||||
layer,
|
layer,
|
||||||
size,
|
size,
|
||||||
move_slot,
|
move_slot,
|
||||||
|
own_mask,
|
||||||
|
move_applied: Vec2::ZERO,
|
||||||
|
repositioned: Vec2::ZERO,
|
||||||
};
|
};
|
||||||
|
|
||||||
// remove old children that weren't kept
|
// remove old children that weren't kept
|
||||||
@@ -275,6 +415,7 @@ impl UiRenderState {
|
|||||||
let from_px = from.top_left().to_abs(self.output_size);
|
let from_px = from.top_left().to_abs(self.output_size);
|
||||||
let to_px = to.top_left().to_abs(self.output_size);
|
let to_px = to.top_left().to_abs(self.output_size);
|
||||||
let delta = to_px - from_px;
|
let delta = to_px - from_px;
|
||||||
|
active.move_applied += delta;
|
||||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
||||||
entry.delta[0] += delta.x;
|
entry.delta[0] += delta.x;
|
||||||
entry.delta[1] += delta.y;
|
entry.delta[1] += delta.y;
|
||||||
@@ -309,17 +450,38 @@ impl UiRenderState {
|
|||||||
let Some(active) = self.active.get(&id) else {
|
let Some(active) = self.active.get(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let move_applied = active.move_applied;
|
||||||
|
let repositioned = active.repositioned;
|
||||||
let from = active
|
let from = active
|
||||||
.size
|
.size
|
||||||
.to_uivec2()
|
.to_uivec2(self.density)
|
||||||
.align(RegionAlign::TOP_LEFT)
|
.align(RegionAlign::TOP_LEFT)
|
||||||
.within(&active.region);
|
.within(&active.region);
|
||||||
let slot = active.move_slot;
|
let slot = active.move_slot;
|
||||||
let from_px = from.top_left().to_abs(self.output_size);
|
let from_px = from.top_left().to_abs(self.output_size);
|
||||||
let to_px = to.top_left().to_abs(self.output_size);
|
let to_px = to.top_left().to_abs(self.output_size);
|
||||||
let delta = to_px - from_px;
|
let delta = to_px - from_px;
|
||||||
|
// Not `delta` alone: a parent may have `mov`ed this widget to a
|
||||||
|
// region that itself moved earlier in the same frame, and that
|
||||||
|
// part of the slot is `move_applied`'s, not this call's. Writing
|
||||||
|
// `delta` on its own dropped it and put the content back at the
|
||||||
|
// pre-move position. `from` is computed against `active.region`,
|
||||||
|
// which `mov` already updated, so `delta` is purely the placement
|
||||||
|
// inside the region and the two summands never overlap.
|
||||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
||||||
entry.delta = [delta.x, delta.y];
|
debug_assert_eq!(
|
||||||
|
entry.delta,
|
||||||
|
[
|
||||||
|
move_applied.x + repositioned.x,
|
||||||
|
move_applied.y + repositioned.y
|
||||||
|
],
|
||||||
|
"widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \
|
||||||
|
the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`"
|
||||||
|
);
|
||||||
|
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
|
||||||
|
if let Some(active) = self.active.get_mut(&id) {
|
||||||
|
active.repositioned = delta;
|
||||||
|
}
|
||||||
self.mov_count += 1;
|
self.mov_count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +498,13 @@ impl UiRenderState {
|
|||||||
active.textures.clear();
|
active.textures.clear();
|
||||||
rsc.ui_mut().textures.free();
|
rsc.ui_mut().textures.free();
|
||||||
if undraw {
|
if undraw {
|
||||||
|
// A captured widget that goes away mid-gesture (List's
|
||||||
|
// virtualisation retiring a row, a rebuild) must not leave
|
||||||
|
// the pointer permanently captured by an id nothing will
|
||||||
|
// ever draw again -- `captured`'s own path out.
|
||||||
|
if *self.captured.lock().unwrap() == Some(id) {
|
||||||
|
*self.captured.lock().unwrap() = None;
|
||||||
|
}
|
||||||
// Permanent removal: retire this widget's own move slot
|
// Permanent removal: retire this widget's own move slot
|
||||||
// (the self-ownership ref taken when it was allocated) and
|
// (the self-ownership ref taken when it was allocated) and
|
||||||
// the up-link ref it held on its parent's slot -- read from
|
// the up-link ref it held on its parent's slot -- read from
|
||||||
@@ -343,6 +512,11 @@ impl UiRenderState {
|
|||||||
// the parent's own `ActiveData` may already be gone by the
|
// the parent's own `ActiveData` may already be gone by the
|
||||||
// time a deep descendant is retired (see LAYOUT.md
|
// time a deep descendant is retired (see LAYOUT.md
|
||||||
// section 2's lifecycle note).
|
// section 2's lifecycle note).
|
||||||
|
if active.own_mask != MaskIdx::NONE {
|
||||||
|
// The self-ownership ref `Painter::set_mask` took when
|
||||||
|
// it allocated this widget's own mask slot.
|
||||||
|
rsc.ui_mut().masks.remove(active.own_mask);
|
||||||
|
}
|
||||||
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
||||||
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
||||||
if parent_slot != MoveOffset::NONE_PARENT {
|
if parent_slot != MoveOffset::NONE_PARENT {
|
||||||
@@ -408,6 +582,100 @@ impl UiRenderState {
|
|||||||
self.active.len()
|
self.active.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Primitive instances still bound for the GPU whose owner is no
|
||||||
|
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
||||||
|
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
||||||
|
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
|
||||||
|
/// each.
|
||||||
|
///
|
||||||
|
/// Asserted empty at the end of every [`Self::update`], because this
|
||||||
|
/// is exactly the shape of the duplicated transcript row on Iris's
|
||||||
|
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
|
||||||
|
/// `active` alone cannot see it, since the orphan's owner is very
|
||||||
|
/// much alive -- it is the *earlier* set of primitives that got
|
||||||
|
/// stranded when the widget was drawn a second time without the first
|
||||||
|
/// draw being freed. O(primitives), debug builds only.
|
||||||
|
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
|
||||||
|
let mut orphans = Vec::new();
|
||||||
|
for (layer, primitives) in self.layers.iter() {
|
||||||
|
for (inst_idx, owner, is_image) in primitives.live_instances() {
|
||||||
|
let owned = self.active.get(&owner).is_some_and(|a| {
|
||||||
|
a.primitives.iter().any(|h| {
|
||||||
|
h.layer == layer
|
||||||
|
&& h.inst_idx == inst_idx
|
||||||
|
&& (h.binding == IMAGE_BINDING) == is_image
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !owned {
|
||||||
|
orphans.push((layer, inst_idx, owner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orphans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether every primitive still bound for the GPU is owned by a live
|
||||||
|
/// widget, decided by counting rather than by walking: an orphan is a
|
||||||
|
/// live instance no `ActiveData` names, so it can only ever make the
|
||||||
|
/// live count exceed the owned one. O(active widgets) -- a few dozen --
|
||||||
|
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
||||||
|
/// transcript is tens of thousands and made a debug build on a phone
|
||||||
|
/// too slow to finish a benchmark run.
|
||||||
|
fn primitive_counts_agree(&self) -> bool {
|
||||||
|
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
|
||||||
|
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||||
|
live == owned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message [`Self::update`]'s orphan assert prints -- built here
|
||||||
|
/// rather than inline so the (allocating, O(primitives)) work only
|
||||||
|
/// happens on the failing path.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
|
||||||
|
let orphans = self.orphaned_primitives();
|
||||||
|
let mut lines: Vec<String> = orphans
|
||||||
|
.iter()
|
||||||
|
.take(8)
|
||||||
|
.map(|(layer, idx, owner)| {
|
||||||
|
let alive = self.active.contains_key(owner);
|
||||||
|
format!(
|
||||||
|
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
|
||||||
|
rsc.widgets().label(*owner),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if orphans.len() > lines.len() {
|
||||||
|
lines.push(format!(" ... and {} more", orphans.len() - lines.len()));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{} primitive(s) are drawn but owned by nobody -- a stale copy \
|
||||||
|
nothing will ever move or free:\n{}",
|
||||||
|
orphans.len(),
|
||||||
|
lines.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give `id` exclusive pointer input from the next `run_sensors` call
|
||||||
|
/// on -- see `captured`'s field doc. Overwrites any previous capture
|
||||||
|
/// (a gesture that starts a new one has already decided the old one
|
||||||
|
/// is over).
|
||||||
|
pub fn capture_pointer(&self, id: WidgetId) {
|
||||||
|
*self.captured.lock().unwrap() = Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release exclusive pointer input, if any is held -- called once
|
||||||
|
/// `run_sensors` has delivered the terminal `Drop` to the capturing
|
||||||
|
/// widget, or by that widget itself if it decides the gesture is over
|
||||||
|
/// some other way.
|
||||||
|
pub fn release_pointer(&self) {
|
||||||
|
*self.captured.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget currently holding exclusive pointer input, if any.
|
||||||
|
pub fn captured_pointer(&self) -> Option<WidgetId> {
|
||||||
|
*self.captured.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
|
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
|
||||||
self.active.iter().filter_map(move |(&id, inst)| {
|
self.active.iter().filter_map(move |(&id, inst)| {
|
||||||
let l = widgets.label(id);
|
let l = widgets.label(id);
|
||||||
@@ -434,7 +702,12 @@ impl UiRenderState {
|
|||||||
/// section 2b.
|
/// section 2b.
|
||||||
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
||||||
let active = self.active.get(&id.id())?;
|
let active = self.active.get(&id.id())?;
|
||||||
let delta = self.resolve_move_chain(active.move_slot, rsc);
|
// The chain sum is what the shader adds to this widget's
|
||||||
|
// *primitives*, which were written before any of those moves.
|
||||||
|
// `region`, unlike them, has already been shifted by whatever
|
||||||
|
// part of this widget's own slot `mov` put there -- see
|
||||||
|
// `ActiveData::move_applied`, which is exactly that part.
|
||||||
|
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
|
||||||
Some(active.region.offset(UiVec2::abs(delta)))
|
Some(active.region.offset(UiVec2::abs(delta)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,26 +715,56 @@ impl UiRenderState {
|
|||||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||||
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||||
/// about where the chain ends.
|
/// about where the chain ends.
|
||||||
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||||
let offsets = &rsc.ui().move_offsets;
|
let offsets = &rsc.ui().move_offsets;
|
||||||
let mut delta = Vec2::ZERO;
|
let mut delta = Vec2::ZERO;
|
||||||
|
let mut at = slot;
|
||||||
for i in 0..MOVE_CHAIN_LIMIT {
|
for i in 0..MOVE_CHAIN_LIMIT {
|
||||||
let entry = &offsets[slot.idx()];
|
let entry = &offsets[at.idx()];
|
||||||
delta.x += entry.delta[0];
|
delta.x += entry.delta[0];
|
||||||
delta.y += entry.delta[1];
|
delta.y += entry.delta[1];
|
||||||
if entry.parent == MoveOffset::NONE_PARENT {
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
return delta;
|
return delta;
|
||||||
}
|
}
|
||||||
slot = Id::preset(entry.parent);
|
at = Id::preset(entry.parent);
|
||||||
|
// The chain itself, not just the fact that it was too long: a
|
||||||
|
// cycle and a tree genuinely nested deeper than the shader can
|
||||||
|
// follow are different faults with different fixes, and the
|
||||||
|
// slot numbers are the only thing that tells them apart.
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
i + 1 < MOVE_CHAIN_LIMIT,
|
i + 1 < MOVE_CHAIN_LIMIT,
|
||||||
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
|
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
|
||||||
probably cyclic"
|
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
|
||||||
|
nests deeper than shader.wgsl's own walk of the same bound",
|
||||||
|
chain = Self::move_chain_debug(slot, offsets)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
delta
|
delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||||
|
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||||
|
/// rather than as a chain that merely stops. Only ever called from the
|
||||||
|
/// failed assertion above.
|
||||||
|
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut at = slot;
|
||||||
|
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
|
||||||
|
let entry = &offsets[at.idx()];
|
||||||
|
parts.push(format!(
|
||||||
|
"{}({}, {})",
|
||||||
|
at.idx(),
|
||||||
|
entry.delta[0],
|
||||||
|
entry.delta[1]
|
||||||
|
));
|
||||||
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at = Id::preset(entry.parent);
|
||||||
|
}
|
||||||
|
parts.join(" -> ")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
||||||
let region = self.resolved_region(id, rsc)?;
|
let region = self.resolved_region(id, rsc)?;
|
||||||
Some(region.to_px(self.output_size))
|
Some(region.to_px(self.output_size))
|
||||||
@@ -470,7 +773,10 @@ impl UiRenderState {
|
|||||||
/// redraws a widget that's currently active (drawn)
|
/// redraws a widget that's currently active (drawn)
|
||||||
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
self.draw_started.remove(&id);
|
// An ancestor is drawing this widget right now, and that draw is
|
||||||
|
// about to write fresh primitives for it. Drawing it a second time
|
||||||
|
// here would leave one of the two copies on screen with nothing
|
||||||
|
// owning it -- see `draw_started`'s own doc.
|
||||||
if self.draw_started.contains(&id) {
|
if self.draw_started.contains(&id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -494,9 +800,9 @@ impl UiRenderState {
|
|||||||
active.mask,
|
active.mask,
|
||||||
Some(active.children),
|
Some(active.children),
|
||||||
Some(active.move_slot),
|
Some(active.move_slot),
|
||||||
|
active.own_mask,
|
||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If this widget's own reported size changed, its parent's layout
|
// If this widget's own reported size changed, its parent's layout
|
||||||
// (which placed it using the old size) is now stale and needs to
|
// (which placed it using the old size) is now stale and needs to
|
||||||
// relay out too. Checked after the real draw, not before it --
|
// relay out too. Checked after the real draw, not before it --
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ pub trait Widget: Any {
|
|||||||
fn access_role(&self) -> accesskit::Role {
|
fn access_role(&self) -> accesskit::Role {
|
||||||
accesskit::Role::Unknown
|
accesskit::Role::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance whatever this widget is animating to `now`, and say whether
|
||||||
|
/// it is still animating afterwards. Default: nothing is, so a widget
|
||||||
|
/// opts in by overriding this *and* by something calling
|
||||||
|
/// [`crate::UiData::animate`] with its id when the animation starts --
|
||||||
|
/// which is that animation's path out, since the driver
|
||||||
|
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
|
||||||
|
/// answers `false`.
|
||||||
|
///
|
||||||
|
/// Called once per frame, before the frame's draw, by whichever
|
||||||
|
/// backend owns the surface; a `true` answer is what makes that
|
||||||
|
/// backend ask for another frame. So this is the only thing in iris
|
||||||
|
/// that moves without an input event, and a widget that animates
|
||||||
|
/// without registering simply never moves -- which is exactly how a
|
||||||
|
/// finger fling looked on Iris's phone before this existed.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for () {
|
impl Widget for () {
|
||||||
|
|||||||
@@ -15,23 +15,19 @@
|
|||||||
//! +-----------+--------------------------------------+
|
//! +-----------+--------------------------------------+
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! **Deliberately left simple, and why**: every incoming SSE event refolds
|
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
||||||
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
|
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
||||||
//! already `O(items)` and a desktop session's conversation is small) and
|
//! item list as before, then `apply` updates only the row(s) that actually
|
||||||
//! rebuilds the whole right-hand widget tree from scratch, rather than
|
//! changed (almost always the one still-open assistant message a delta
|
||||||
//! reaching for `TranscriptScreen::push_row`'s incremental append.
|
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
||||||
//! `push_row` cannot update a row already on screen -- only append a new
|
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
||||||
//! one -- and a streaming assistant reply is exactly a row whose *text*
|
//! freshly loaded/selected session and for `apply`'s own rare
|
||||||
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
|
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
||||||
//! `fold_event` folding deltas into one growing item). A full rebuild
|
//! before the tail). The composer's in-progress text survives a rebuild
|
||||||
//! shows that growth correctly at the cost of redrawing everything each
|
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
||||||
//! time; fine for this proof, wrong for a long, fast-streaming transcript
|
//! followup while a reply streams in is the one case a naive rebuild
|
||||||
//! -- the incremental path that fixes it needs `transcript-ui` to expose
|
//! would otherwise lose data on -- `apply`'s own path never touches the
|
||||||
//! updating a row in place, which it does not yet. The composer's
|
//! composer at all, so this only matters on the fallback.
|
||||||
//! in-progress text survives a rebuild (`rebuild_transcript`'s
|
|
||||||
//! `in_progress` local) since the user typing a followup while a reply
|
|
||||||
//! streams in is the one case a naive rebuild would otherwise lose data
|
|
||||||
//! on.
|
|
||||||
//!
|
//!
|
||||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
//! Background network I/O (`client_core::api`/`event_stream`, both
|
||||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
||||||
@@ -209,8 +205,12 @@ impl DefaultAppState for Client {
|
|||||||
event,
|
event,
|
||||||
} => {
|
} => {
|
||||||
if self.current(&session_id, generation) {
|
if self.current(&session_id, generation) {
|
||||||
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, &event);
|
self.items = fold_event(&self.items, &event);
|
||||||
self.rebuild_transcript(rsc);
|
match &self.screen {
|
||||||
|
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||||
|
None => self.rebuild_transcript(rsc),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppEvent::StreamEnded {
|
AppEvent::StreamEnded {
|
||||||
|
|||||||
@@ -68,12 +68,15 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
|
|||||||
let mut span = Span::empty(Dir::DOWN);
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
span.push(text);
|
span.push(text);
|
||||||
span.push(img);
|
span.push(img);
|
||||||
span.pad(8.0).background(rect(tint)).add_strong(rsc).any()
|
span.pad(dp(8.0))
|
||||||
|
.background(rect(tint))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
} else {
|
} else {
|
||||||
wtext(row_text(i))
|
wtext(row_text(i))
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.color(text_color)
|
.color(text_color)
|
||||||
.pad(8.0)
|
.pad(dp(8.0))
|
||||||
.background(rect(tint))
|
.background(rect(tint))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "rig-input"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one
|
||||||
|
# of the `.touch` files the headless tests use into whatever window is
|
||||||
|
# under a Wayland compositor, so the *same recording* drives the
|
||||||
|
# assertion layer and the layer a person looks at.
|
||||||
|
#
|
||||||
|
# It exists because this machine's compositor has no pointer to move.
|
||||||
|
# `run-headless.sh` starts sway on the headless backend with no input
|
||||||
|
# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`),
|
||||||
|
# so `swaymsg seat - cursor press` reports success and nothing reaches
|
||||||
|
# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots
|
||||||
|
# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would
|
||||||
|
# be ignored by a compositor that is not reading libinput. The
|
||||||
|
# virtual-pointer protocol is what is left, and it is a client protocol,
|
||||||
|
# so it needs no devices and no root.
|
||||||
|
|
||||||
|
# Named for what it does rather than for the crate, since the crate may
|
||||||
|
# grow a keyboard replay beside it.
|
||||||
|
[[bin]]
|
||||||
|
name = "replay-touch"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# `TouchScript` -- the same parser the harness uses, so a file that
|
||||||
|
# replays here and one that replays headless can never disagree.
|
||||||
|
iris = { path = ".." }
|
||||||
|
wayland-client = "0.31.15"
|
||||||
|
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Replays a `.touch` file into the compositor as a left-button drag --
|
||||||
|
//! see this crate's `Cargo.toml` for why it exists rather than
|
||||||
|
//! `swaymsg seat - cursor`.
|
||||||
|
//!
|
||||||
|
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
|
||||||
|
//!
|
||||||
|
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
|
||||||
|
//! pointer protocol positions absolutely against an extent rather than
|
||||||
|
//! in pixels; passing the output size makes a script's coordinates mean
|
||||||
|
//! the same pixels they mean in the headless tests.
|
||||||
|
//!
|
||||||
|
//! Replayed in real time (the sleeps between samples are the gaps in the
|
||||||
|
//! file), because winit has no timestamp on a pointer event and dates
|
||||||
|
//! each one when it arrives -- so a 20ms flick has to actually take
|
||||||
|
//! 20ms here, unlike layer 1 where the sample carries its own time.
|
||||||
|
|
||||||
|
use iris::harness::{TouchAction, TouchScript};
|
||||||
|
use std::time::Duration;
|
||||||
|
use wayland_client::protocol::wl_pointer::ButtonState;
|
||||||
|
use wayland_client::protocol::{wl_registry, wl_seat};
|
||||||
|
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
|
||||||
|
use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||||
|
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||||
|
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
|
||||||
|
/// button code, not a wayland enum.
|
||||||
|
const BTN_LEFT: u32 = 0x110;
|
||||||
|
|
||||||
|
/// How long the pointer sits at the gesture's first position before the
|
||||||
|
/// script starts -- see the comment at the pre-step in `main`.
|
||||||
|
const SETTLE: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Globals {
|
||||||
|
seat: Option<wl_seat::WlSeat>,
|
||||||
|
manager: Option<ZwlrVirtualPointerManagerV1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} = event
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match interface.as_str() {
|
||||||
|
"wl_seat" => {
|
||||||
|
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||||
|
}
|
||||||
|
"zwlr_virtual_pointer_manager_v1" => {
|
||||||
|
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate_noop!(Globals: ignore wl_seat::WlSeat);
|
||||||
|
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
|
||||||
|
delegate_noop!(Globals: ZwlrVirtualPointerV1);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let [width, height, path] = args.as_slice() else {
|
||||||
|
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
|
||||||
|
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
|
||||||
|
|
||||||
|
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
|
||||||
|
fail(&format!(
|
||||||
|
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let mut queue = conn.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
let display = conn.display();
|
||||||
|
display.get_registry(&qh, ());
|
||||||
|
let mut globals = Globals::default();
|
||||||
|
queue
|
||||||
|
.roundtrip(&mut globals)
|
||||||
|
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
|
||||||
|
|
||||||
|
let manager = globals.manager.as_ref().unwrap_or_else(|| {
|
||||||
|
fail(
|
||||||
|
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
|
||||||
|
be synthesised; sway and every wlroots compositor do",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
|
||||||
|
|
||||||
|
// Put the pointer where the gesture starts and let the compositor
|
||||||
|
// settle before anything is pressed. Without this the press is
|
||||||
|
// dropped: sway has just learned about this pointer, and a button
|
||||||
|
// sent in the same breath as the motion that first puts it over a
|
||||||
|
// window arrives before there is a focused surface to send it to --
|
||||||
|
// winit sees `CursorEntered`, the moves and the *release*, never the
|
||||||
|
// press, so the gesture reads as a hover and nothing scrolls. Found
|
||||||
|
// by printing winit's own events; the settle is what fixed it.
|
||||||
|
if let Some(first) = script.samples.first() {
|
||||||
|
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
|
||||||
|
pointer.frame();
|
||||||
|
conn.flush()
|
||||||
|
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
|
||||||
|
std::thread::sleep(SETTLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut previous = 0;
|
||||||
|
for sample in &script.samples {
|
||||||
|
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
|
||||||
|
previous = sample.t_ms;
|
||||||
|
let t = sample.t_ms as u32;
|
||||||
|
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
|
||||||
|
// One frame per sample, so the compositor delivers them as
|
||||||
|
// separate pointer frames rather than coalescing the whole
|
||||||
|
// gesture -- the shape the file recorded is the point.
|
||||||
|
pointer.frame();
|
||||||
|
// The button goes in a frame of its own, *after* the motion has
|
||||||
|
// been committed. Sent in the same frame as the motion that
|
||||||
|
// first puts the pointer over the window, sway drops it: the
|
||||||
|
// client sees `CursorEntered` and the moves but never a
|
||||||
|
// `MouseInput { state: Pressed }`, so the whole gesture reads as
|
||||||
|
// a hover and nothing scrolls. Found exactly that way, by
|
||||||
|
// printing winit's events.
|
||||||
|
let state = match sample.action {
|
||||||
|
TouchAction::Down => Some(ButtonState::Pressed),
|
||||||
|
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
|
||||||
|
TouchAction::Move => None,
|
||||||
|
};
|
||||||
|
if let Some(state) = state {
|
||||||
|
pointer.button(t, BTN_LEFT, state);
|
||||||
|
pointer.frame();
|
||||||
|
}
|
||||||
|
conn.flush()
|
||||||
|
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
|
||||||
|
}
|
||||||
|
pointer.destroy();
|
||||||
|
conn.flush().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(text: &str, what: &str) -> u32 {
|
||||||
|
text.parse()
|
||||||
|
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(message: &str) -> ! {
|
||||||
|
eprintln!("replay-touch: {message}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||