Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fcbdccb9 | ||
|
|
a00376994e | ||
|
|
ba57086361 | ||
|
|
1a9655414e | ||
|
|
5e34dba2fd | ||
|
|
cc8148cbec | ||
|
|
a2e5e5881c | ||
|
|
c121bc0725 | ||
|
|
fe7dc9c728 | ||
|
|
02b277e7ad | ||
|
|
fc82d9d7e8 | ||
|
|
9e301f30c6 | ||
|
|
341b7a5922 | ||
|
|
c8785b6091 | ||
|
|
9c560e3492 | ||
|
|
e5a90c6135 | ||
|
|
af1b0c5ab2 | ||
|
|
cce4b28324 | ||
|
|
94d8373289 | ||
|
|
8310431497 | ||
|
|
b863f9f3df | ||
|
|
cdeb7b0857 | ||
|
|
476609e1d3 | ||
|
|
2756087e1c | ||
|
|
af7d5f3782 | ||
|
|
db0a41a7cd | ||
|
|
b9924e7617 | ||
|
|
f014e8d9cf | ||
|
|
0ccc444246 | ||
|
|
c6da735134 | ||
|
|
4f6ec3a900 | ||
|
|
38bf6309cb | ||
|
|
3eb0e033d5 | ||
|
|
b8ea723718 | ||
|
|
7485d78d50 | ||
|
|
b87f5a597e | ||
|
|
80a75c128e | ||
|
|
50e69995b6 | ||
|
|
85869d02f8 | ||
|
|
f99ae4c366 | ||
|
|
203f53470c | ||
|
|
b38e797db3 | ||
|
|
92985ba8e3 | ||
|
|
181ba64606 | ||
|
|
a6a100edc6 | ||
|
|
ff1d6ea932 | ||
|
|
2b20bb2c91 | ||
|
|
3c80d9d696 | ||
|
|
06b8a1f4b0 | ||
|
|
e10582a2cd | ||
|
|
551c01398f | ||
|
|
7e79ec11e0 | ||
|
|
2ec0fee84c | ||
|
|
992c472975 | ||
|
|
729098756d | ||
|
|
d8562d96a3 | ||
|
|
22210a42f5 | ||
|
|
ade572973a | ||
|
|
9b27e858b5 | ||
|
|
7e4e26a335 | ||
|
|
84a13e806b | ||
|
|
452c44249f | ||
|
|
238057ad5e | ||
|
|
896c93a59a | ||
|
|
690161e5e9 | ||
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 | ||
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 | ||
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 |
No files matched your search
@@ -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 +
|
||||||
@@ -57,9 +72,10 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
|||||||
conditions. Read it before touching anything under that branch.
|
conditions. Read it before touching anything under that branch.
|
||||||
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
||||||
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
||||||
own public API log, working list, decisions log, layout/render design,
|
own build log (**any major addition or design decision, not only
|
||||||
and texture-atlas design, and the client-core crate's design,
|
public API** -- Iris, 2026-09-08), working list, decisions log,
|
||||||
respectively.
|
layout/render design, and texture-atlas design, and the client-core
|
||||||
|
crate's design, respectively.
|
||||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here) and the APK, in parallel. It points at
|
||||||
@@ -85,6 +101,18 @@ the **Mono** face, where every glyph is one em square, which is what makes
|
|||||||
two icon buttons the same width without either being given one — and why
|
two icon buttons the same width without either being given one — and why
|
||||||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
`GLYPH_SIZE` is smaller than it looks like it should be.
|
||||||
|
|
||||||
|
**The Rust app does the same, from its own subset**:
|
||||||
|
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
|
||||||
|
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
|
||||||
|
with `Family::Icons`. Same rule about the two lists agreeing (there is a
|
||||||
|
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
|
||||||
|
Design family so an icon means the same thing in both apps. Its subset is
|
||||||
|
separate rather than shared because subsetting only what one app draws is
|
||||||
|
the point. This is the **only** font iris bundles — body and monospace
|
||||||
|
text come from the platform (docs/DECISIONS.md, 2026-09-07), and an icon
|
||||||
|
is the opposite case: a small closed set of codepoints no system font is
|
||||||
|
guaranteed to have.
|
||||||
|
|
||||||
## Checking your work
|
## Checking your work
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
||||||
@@ -268,6 +296,31 @@ Each exists because something was invisible without it.
|
|||||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
||||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
report -- written so the P0 build/install/tap/read-report cycle stops
|
||||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
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.
|
||||||
|
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
|
||||||
|
docs/DECISIONS.md). Its guest has no hardware Vulkan -- only SwiftShader
|
||||||
|
in software -- while its GLES *is* the host's real GPU through virgl at
|
||||||
|
ES 3.1, so an ordinary build's runtime fallback lands there by itself
|
||||||
|
and nothing should pass `force-gles` to arrange it. The Vulkan path is
|
||||||
|
verified on the desktop build and on Iris's phone. Do not boot the
|
||||||
|
emulator with SwiftShader Vulkan to "test the Vulkan path": that
|
||||||
|
measures a software rasteriser and steers iris away from the one
|
||||||
|
hardware-accelerated backend it has there. Every run says which adapter
|
||||||
|
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
|
||||||
|
that line before reading a number.
|
||||||
|
|
||||||
### Driving the UI
|
### Driving the UI
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -76,7 +82,10 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
name = "client-core"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -206,6 +215,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 +508,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"
|
||||||
@@ -553,9 +590,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -783,12 +820,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"
|
||||||
@@ -797,9 +846,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "3.4.0"
|
version = "3.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
@@ -817,9 +866,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq-proto"
|
name = "ureq-proto"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"http",
|
"http",
|
||||||
|
|||||||
@@ -404,12 +404,27 @@ done
|
|||||||
# Percent-encoded because the app URL-decodes the deep link's query: a
|
# Percent-encoded because the app URL-decodes the deep link's query: a
|
||||||
# token with '+' in it enrols as one with a space, and nothing reports it.
|
# token with '+' in it enrols as one with a space, and nothing reports it.
|
||||||
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
||||||
|
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
|
||||||
|
# the DER, which needs no percent-encoding). The Compose app ignores it and
|
||||||
|
# pins the copy its APK was built with; the iris app has no baked copy at
|
||||||
|
# all -- it is cross-compiled and could be pointed at any machine -- so
|
||||||
|
# without this it enrols and then trusts nothing. Minted here rather than by
|
||||||
|
# `--enroll-link` because this token is the sandbox's own, carried across
|
||||||
|
# restarts so the emulator stays enrolled (see the top of this file).
|
||||||
|
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
|
||||||
|
import base64, sys
|
||||||
|
pem = open(sys.argv[1]).read()
|
||||||
|
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
|
||||||
|
der = base64.b64decode("".join(body.split()))
|
||||||
|
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
|
||||||
|
CA
|
||||||
|
)
|
||||||
cat <<INFO
|
cat <<INFO
|
||||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||||
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||||
|
|
||||||
enrol the emulator (once; it survives sandbox restarts):
|
enrol the emulator (once; it survives sandbox restarts):
|
||||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
|
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
|
||||||
|
|
||||||
drive it:
|
drive it:
|
||||||
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
name = "client-core"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -173,6 +176,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 +437,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"
|
||||||
@@ -469,9 +500,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -661,12 +692,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"
|
||||||
@@ -675,9 +718,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "3.4.0"
|
version = "3.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
@@ -695,9 +738,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq-proto"
|
name = "ureq-proto"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"http",
|
"http",
|
||||||
|
|||||||
@@ -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,21 @@ 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"
|
||||||
|
# The enrollment link's `ca` parameter is base64url of the CA's DER
|
||||||
|
# (`config::parse_link`). Same version `wg-app-link` already pins for the
|
||||||
|
# minting half, so a workspace that has both resolves one copy.
|
||||||
|
base64 = "0.23"
|
||||||
|
# The logging facade only -- `log_ring` implements a `log::Log` backend and
|
||||||
|
# wraps whichever real one the platform installed (`android_logger` on the
|
||||||
|
# phone, `env_logger` on the desktop), which is why neither of those is a
|
||||||
|
# dependency here. See `log_ring`'s module doc.
|
||||||
|
log = { version = "0.4.34", features = ["std"] }
|
||||||
|
|
||||||
|
|
||||||
[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,15 +275,72 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
) -> Result<Vec<Value>, ApiError> {
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
self.json_request(
|
||||||
if let Some(before) = before {
|
"GET",
|
||||||
path.push_str(&format!("&before={before}"));
|
&transcript_path(session_id, before, limit, coalesce, None),
|
||||||
}
|
None,
|
||||||
if coalesce {
|
)
|
||||||
path.push_str("&coalesce=true");
|
|
||||||
}
|
|
||||||
self.json_request("GET", &path, None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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}");
|
||||||
|
if let Some(before) = before {
|
||||||
|
path.push_str(&format!("&before={before}"));
|
||||||
|
}
|
||||||
|
if coalesce {
|
||||||
|
path.push_str("&coalesce=true");
|
||||||
|
}
|
||||||
|
if let Some(after) = after {
|
||||||
|
path.push_str(&format!("&after={after}"));
|
||||||
|
}
|
||||||
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||||
|
|||||||
@@ -6,33 +6,57 @@
|
|||||||
//! the same text a phone would scan as a QR, with no second format
|
//! the same text a phone would scan as a QR, with no second format
|
||||||
//! invented for it (RUST.md's E4).
|
//! invented for it (RUST.md's E4).
|
||||||
//!
|
//!
|
||||||
//! What this type deliberately does not decide: where it is persisted, and
|
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
|
||||||
//! under what file permissions. A phone seals its token in the Android
|
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
|
||||||
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
//! desktop app, the app-private files directory on Android. **Which**
|
||||||
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
//! directory is the only part left to the platform: the format, the file
|
||||||
//! never in the repo). Both are caller-specific, so they stay out of this
|
//! mode and the "nothing saved yet is not an error" answer are the same on
|
||||||
//! crate per the code rules' "ask for the least you need" -- see
|
//! both, and were written twice before this.
|
||||||
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
//!
|
||||||
|
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
||||||
|
//! rules (`format`) are for configs a person hand-edits, and this file
|
||||||
|
//! never is one -- only the app itself writes or reads it.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||||
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
/// with `token` as a bearer header.
|
||||||
/// a public certificate rather than a secret, and where to find it differs
|
///
|
||||||
/// by caller (a phone pins the one its APK was built against; a desktop
|
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||||
/// client is told a path).
|
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||||
|
/// because an app built on the machine its server runs on pins the CA at
|
||||||
|
/// build time and needs nothing from the link; one built elsewhere -- the
|
||||||
|
/// iris Android client is cross-compiled in a VM and run against the
|
||||||
|
/// host's server -- has no other way to get it. A public certificate
|
||||||
|
/// rather than a secret, so it costs the link nothing but length.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct EnrolledServer {
|
pub struct EnrolledServer {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
/// `#[serde(default)]` so an enrollment saved before this field
|
||||||
|
/// existed still loads, as the enrolled server it always was.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ca_pem: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnrolledServer {
|
impl EnrolledServer {
|
||||||
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
|
||||||
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
/// does not matter; unrecognised keys are ignored). `token` is
|
||||||
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
|
||||||
/// contain `+`, which turns into a space if left to a naive splitter.
|
/// a raw token can contain `+`, which turns into a space if left to a
|
||||||
|
/// naive splitter.
|
||||||
|
///
|
||||||
|
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||||
|
/// here, because that is what every consumer of it wants
|
||||||
|
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||||
|
/// at). A `ca` that does not decode fails the whole link rather than
|
||||||
|
/// enrolling a server with no trust anchor: the link said which
|
||||||
|
/// certificate to pin, and quietly not pinning it is the one outcome
|
||||||
|
/// nothing downstream could notice.
|
||||||
pub fn parse_link(link: &str) -> Result<Self, String> {
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||||
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -44,6 +68,7 @@ impl EnrolledServer {
|
|||||||
let mut host = None;
|
let mut host = None;
|
||||||
let mut port = None;
|
let mut port = None;
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
|
let mut ca = None;
|
||||||
for pair in query.split('&') {
|
for pair in query.split('&') {
|
||||||
let Some((key, value)) = pair.split_once('=') else {
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
continue;
|
continue;
|
||||||
@@ -53,6 +78,7 @@ impl EnrolledServer {
|
|||||||
"host" => host = Some(value),
|
"host" => host = Some(value),
|
||||||
"port" => port = Some(value),
|
"port" => port = Some(value),
|
||||||
"token" => token = Some(value),
|
"token" => token = Some(value),
|
||||||
|
"ca" => ca = Some(value),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +89,14 @@ impl EnrolledServer {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||||
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||||
|
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
||||||
|
|
||||||
Ok(Self { host, port, token })
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
token,
|
||||||
|
ca_pem,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a `client_core::api::UreqTransport` reaches this server.
|
/// Where a `client_core::api::UreqTransport` reaches this server.
|
||||||
@@ -73,6 +105,80 @@ impl EnrolledServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
|
||||||
|
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||||
|
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(ca.as_bytes())
|
||||||
|
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
||||||
|
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
||||||
|
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
||||||
|
for line in body.as_bytes().chunks(64) {
|
||||||
|
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
||||||
|
pem.push('\n');
|
||||||
|
}
|
||||||
|
pem.push_str("-----END CERTIFICATE-----\n");
|
||||||
|
Ok(pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where one client keeps the enrollment it should not have to be told
|
||||||
|
/// about a second time. `dir` is the caller's, because that is the only
|
||||||
|
/// part that differs by platform -- see this module's doc.
|
||||||
|
pub struct EnrollmentStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrollmentStore {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(&self) -> PathBuf {
|
||||||
|
self.dir.join("enrollment.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `server` under `dir`, creating it if needed, and sets the
|
||||||
|
/// file owner-only -- it carries a bearer token, the same reason
|
||||||
|
/// `server/`'s own token store is 0600.
|
||||||
|
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||||
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
|
let path = self.file();
|
||||||
|
let json = serde_json::to_vec_pretty(server)
|
||||||
|
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||||
|
std::fs::write(&path, json)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
|
||||||
|
/// -- "not enrolled" is an ordinary first-run state, not a failure
|
||||||
|
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
|
||||||
|
/// just as well to a file that simply hasn't been written yet).
|
||||||
|
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||||
|
let path = self.file();
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(server))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn percent_decode(s: &str) -> String {
|
fn percent_decode(s: &str) -> String {
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
let mut out = Vec::with_capacity(bytes.len());
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
@@ -108,6 +214,7 @@ mod tests {
|
|||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 8547,
|
port: 8547,
|
||||||
token: "abcDEF123".to_string(),
|
token: "abcDEF123".to_string(),
|
||||||
|
ca_pem: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||||
@@ -141,6 +248,115 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The CA travels as base64url of the DER and comes back out as the
|
||||||
|
/// PEM every consumer of it wants -- the same round trip
|
||||||
|
/// `wg_app_link::enroll::ca_param` mints.
|
||||||
|
#[test]
|
||||||
|
fn a_ca_in_the_link_comes_back_as_pem() {
|
||||||
|
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||||
|
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
||||||
|
.unwrap();
|
||||||
|
let pem = server.ca_pem.expect("the link carried a CA");
|
||||||
|
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
||||||
|
assert!(
|
||||||
|
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
||||||
|
"{pem}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(
|
||||||
|
pem.lines()
|
||||||
|
.filter(|l| !l.starts_with("-----"))
|
||||||
|
.collect::<String>()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
der
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link with no `ca` is an ordinary link, not a broken one: an app
|
||||||
|
/// that pins at build time mints and reads exactly these.
|
||||||
|
#[test]
|
||||||
|
fn no_ca_parameter_is_none_not_an_error() {
|
||||||
|
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||||
|
assert_eq!(server.ca_pem, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half that cannot be noticed later: a `ca` that does not decode
|
||||||
|
/// must fail the link rather than enrolling with nothing pinned.
|
||||||
|
#[test]
|
||||||
|
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||||
|
let err =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("ca"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_enrollment_reads_back_the_same() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
let server = EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "tok".to_string(),
|
||||||
|
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
||||||
|
};
|
||||||
|
store.save(&server).unwrap();
|
||||||
|
assert_eq!(store.load().unwrap(), Some(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_saved_yet_is_none_not_an_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enrollment written before `ca_pem` existed still loads.
|
||||||
|
#[test]
|
||||||
|
fn an_enrollment_without_a_ca_still_loads() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
std::fs::create_dir_all(dir.path()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("enrollment.json"),
|
||||||
|
br#"{"host":"h","port":1,"token":"t"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn the_saved_file_is_owner_only() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
store
|
||||||
|
.save(&EnrolledServer {
|
||||||
|
host: "h".to_string(),
|
||||||
|
port: 1,
|
||||||
|
token: "t".to_string(),
|
||||||
|
ca_pem: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_file_is_named_in_the_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
||||||
|
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
||||||
|
assert!(err.to_string().contains("enrollment.json"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_non_numeric_port_is_named_in_the_error() {
|
fn a_non_numeric_port_is_named_in_the_error() {
|
||||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||||
|
|||||||
@@ -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,16 @@
|
|||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod log_ring;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod 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,812 @@
|
|||||||
|
//! The app's own recent log, held in memory so it can be read back
|
||||||
|
//! without `logcat`.
|
||||||
|
//!
|
||||||
|
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
|
||||||
|
//! no `adb`, and Android forbids one app reading another's logcat, so
|
||||||
|
//! nothing outside the process can recover what it wrote. The only way a
|
||||||
|
//! line reaches her is for the app to carry its own copy. This is that
|
||||||
|
//! copy: a bounded ring every `log::info!` in the process lands in, on top
|
||||||
|
//! of whichever platform logger was already installed (`android_logger`,
|
||||||
|
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
|
||||||
|
//!
|
||||||
|
//! Two consumers, both reading the same ring rather than each keeping
|
||||||
|
//! their own: the bench app's `Copy report`/`Diagnostics` (which reads
|
||||||
|
//! [`LogRing::tail_text`] and [`LogRing::summary`]) and whatever hands the
|
||||||
|
//! log out of the process -- on Android, the `DevLogProvider` Dev Updater
|
||||||
|
//! queries, which reads [`LogRing::since`] and [`LogRing::newest_seq`].
|
||||||
|
//! That is why reading does not consume: a line already handed over must
|
||||||
|
//! still be in the report, and a report taken twice must say the same
|
||||||
|
//! thing.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// How many lines a default ring holds, and how many bytes of message.
|
||||||
|
///
|
||||||
|
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||||
|
/// modes are different: a flood of short lines exhausts the count, and one
|
||||||
|
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||||
|
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
|
||||||
|
/// only by bytes can be emptied by a single line.
|
||||||
|
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||||
|
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// How many of the ring's newest lines [`LogRing::tail_text`] includes.
|
||||||
|
/// Sized for a phone's share sheet rather than for the ring itself: 150
|
||||||
|
/// lines of `HH:MM:SS.mmm LEVEL target: message` is a few KiB, comfortably
|
||||||
|
/// short of whatever made pasting the full (up to 2000-line) ring into a
|
||||||
|
/// chat's message box laggy on Iris's phone. The full ring is still
|
||||||
|
/// reachable through `devlog`'s provider, so this only bounds what a
|
||||||
|
/// report inlines.
|
||||||
|
pub const COPY_REPORT_TAIL_LINES: usize = 150;
|
||||||
|
|
||||||
|
/// One recorded line. `seq` is assigned by the ring and only ever
|
||||||
|
/// increases, so a reader that remembers where it got to can ask for what
|
||||||
|
/// came after -- and a gap in the sequence is exactly the lines the bound
|
||||||
|
/// dropped.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LogLine {
|
||||||
|
pub seq: u64,
|
||||||
|
/// Milliseconds since the unix epoch, from the app's own clock. The
|
||||||
|
/// app's rather than the receiver's: a line is timestamped when it
|
||||||
|
/// happened, and an upload can be minutes later or never.
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub level: log::Level,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogLine {
|
||||||
|
/// Roughly what the line costs the ring. The two `String`s dominate;
|
||||||
|
/// the fixed fields are counted as a flat overhead so a ring of empty
|
||||||
|
/// messages still has a bound.
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
self.target.len() + self.message.len() + 32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `12:34:56.789 INFO iris::android: the message`, the shape a
|
||||||
|
/// person skims. Time of day only -- the date is in the report's own
|
||||||
|
/// header, and a ring never spans one.
|
||||||
|
pub fn format(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {:<5} {}: {}",
|
||||||
|
clock_time(self.at_ms),
|
||||||
|
self.level,
|
||||||
|
self.target,
|
||||||
|
self.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
|
||||||
|
/// library: the only field this needs is the time of day, and dividing out
|
||||||
|
/// the day is the whole calculation. Deliberately not local time -- the
|
||||||
|
/// phone's offset is not knowable here, and a report that says UTC is
|
||||||
|
/// comparable with the server's log, which is what it gets read against.
|
||||||
|
fn clock_time(at_ms: u64) -> String {
|
||||||
|
let ms = at_ms % 1000;
|
||||||
|
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||||
|
format!(
|
||||||
|
"{:02}:{:02}:{:02}.{:03}",
|
||||||
|
secs_of_day / 3600,
|
||||||
|
(secs_of_day % 3600) / 60,
|
||||||
|
secs_of_day % 60,
|
||||||
|
ms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
|
||||||
|
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
|
||||||
|
/// the app down for.
|
||||||
|
pub fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Inner {
|
||||||
|
lines: VecDeque<LogLine>,
|
||||||
|
bytes: usize,
|
||||||
|
max_lines: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
next_seq: u64,
|
||||||
|
/// How many lines the bounds have discarded since the ring was made.
|
||||||
|
/// Reported rather than inferred, so "the log starts here" and "the
|
||||||
|
/// log was cut off here" are distinguishable -- the unknown state the
|
||||||
|
/// UI rules ask for.
|
||||||
|
dropped: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
|
||||||
|
/// there is one per process and every holder sees the same lines.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||||
|
|
||||||
|
impl LogRing {
|
||||||
|
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
|
||||||
|
assert!(
|
||||||
|
max_lines > 0 && max_bytes > 0,
|
||||||
|
"a ring with no room holds nothing"
|
||||||
|
);
|
||||||
|
Self(Arc::new(Mutex::new(Inner {
|
||||||
|
lines: VecDeque::new(),
|
||||||
|
bytes: 0,
|
||||||
|
max_lines,
|
||||||
|
max_bytes,
|
||||||
|
next_seq: 0,
|
||||||
|
dropped: 0,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
|
||||||
|
/// [`DEFAULT_MAX_BYTES`].
|
||||||
|
pub fn with_defaults() -> Self {
|
||||||
|
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A poisoned lock is a bug in a panicking logger, not a reason to
|
||||||
|
/// take the app down a second time -- the ring is a diagnostic, and
|
||||||
|
/// losing it must not be worse than the fault it was recording.
|
||||||
|
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
|
||||||
|
let mut guard = match self.0.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
f(&mut guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a line, evicting the oldest until both bounds hold again.
|
||||||
|
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||||
|
self.with(|inner| {
|
||||||
|
let line = LogLine {
|
||||||
|
seq: inner.next_seq,
|
||||||
|
at_ms: now_ms(),
|
||||||
|
level,
|
||||||
|
target: target.to_string(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
inner.next_seq += 1;
|
||||||
|
inner.bytes += line.weight();
|
||||||
|
inner.lines.push_back(line);
|
||||||
|
// `!is_empty()` rather than `len() > 1`: one line larger than
|
||||||
|
// the whole byte bound is kept, because dropping it would
|
||||||
|
// leave the ring silently empty while lines were arriving.
|
||||||
|
while inner.lines.len() > inner.max_lines
|
||||||
|
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
|
||||||
|
{
|
||||||
|
if let Some(evicted) = inner.lines.pop_front() {
|
||||||
|
inner.bytes -= evicted.weight();
|
||||||
|
inner.dropped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every line held, oldest first.
|
||||||
|
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||||
|
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lines with a sequence number at or after `seq`, oldest first,
|
||||||
|
/// and the sequence to ask from next time. Does not consume: see this
|
||||||
|
/// module's doc for why.
|
||||||
|
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||||
|
self.with(|inner| {
|
||||||
|
let lines: Vec<LogLine> = inner
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|line| line.seq >= seq)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
|
||||||
|
(lines, next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.with(|inner| inner.lines.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped(&self) -> u64 {
|
||||||
|
self.with(|inner| inner.dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sequence number of the newest line held, or `None` for a ring
|
||||||
|
/// nothing has been written to.
|
||||||
|
///
|
||||||
|
/// What a reader needs to notice that this process **restarted**: the
|
||||||
|
/// ring is in memory, so a new process starts again at zero, and a
|
||||||
|
/// reader holding a cursor from the previous one would otherwise ask
|
||||||
|
/// for lines after a number nothing will reach for hours and see
|
||||||
|
/// nothing at all -- silently, which is worse than seeing the log
|
||||||
|
/// begin again. Answering `None` rather than 0 for an empty ring is
|
||||||
|
/// the same distinction [`Self::summary`] draws: "nothing has been
|
||||||
|
/// logged" is not a sequence number.
|
||||||
|
pub fn newest_seq(&self) -> Option<u64> {
|
||||||
|
self.with(|inner| inner.lines.back().map(|line| line.seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the newest line was written, in unix milliseconds, or `None`
|
||||||
|
/// for a ring nothing has been written to.
|
||||||
|
pub fn last_at_ms(&self) -> Option<u64> {
|
||||||
|
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every line held, formatted one per line -- what `Copy report`
|
||||||
|
/// appends.
|
||||||
|
pub fn to_text(&self) -> String {
|
||||||
|
self.snapshot()
|
||||||
|
.iter()
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest `max_lines` lines, formatted, with a first line naming
|
||||||
|
/// how many older ones were left out of *this* text when the ring held
|
||||||
|
/// more than that -- what `Copy report` appends instead of
|
||||||
|
/// [`Self::to_text`].
|
||||||
|
///
|
||||||
|
/// Iris's own report: pasting the full ring (over a thousand lines on
|
||||||
|
/// a session that ran with tracing on) into a phone's message box was
|
||||||
|
/// what "causes a lot of lag" meant (docs/IRIS_TODO.md, 2026-09-07
|
||||||
|
/// night) -- nothing is actually lost, since `devlog`'s provider still
|
||||||
|
/// hands Dev Updater's Runtime tab the whole ring; this only caps what
|
||||||
|
/// gets inlined into a share.
|
||||||
|
pub fn tail_text(&self, max_lines: usize) -> String {
|
||||||
|
let lines = self.snapshot();
|
||||||
|
if lines.len() <= max_lines {
|
||||||
|
return lines
|
||||||
|
.iter()
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
let omitted = lines.len() - max_lines;
|
||||||
|
let tail = lines[lines.len() - max_lines..]
|
||||||
|
.iter()
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
format!("{omitted} earlier lines omitted; full log in Dev Updater's Runtime tab\n{tail}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest `max_lines` lines, formatted, or `None` if the ring is
|
||||||
|
/// locked at this instant.
|
||||||
|
///
|
||||||
|
/// For the one caller that must not block: **the panic hook**. A panic
|
||||||
|
/// raised while this ring's own lock was held -- an allocation failing
|
||||||
|
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
|
||||||
|
/// -- would deadlock the hook against the thread that is panicking,
|
||||||
|
/// and the process would hang instead of aborting, with nothing
|
||||||
|
/// written anywhere. Losing the context lines is the right trade
|
||||||
|
/// against that, and `None` says which happened rather than looking
|
||||||
|
/// like an empty log.
|
||||||
|
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||||
|
let guard = match self.0.try_lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
// A poisoned lock is uncontended, so its contents are still
|
||||||
|
// readable -- the same judgement as `with`.
|
||||||
|
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||||
|
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||||
|
};
|
||||||
|
let lines = &guard.lines;
|
||||||
|
let from = lines.len().saturating_sub(max_lines);
|
||||||
|
Some(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.skip(from)
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for a diagnostics pane: how much is held, how much was
|
||||||
|
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||||
|
/// wording rather than a count of zero with a made-up time, because
|
||||||
|
/// "nothing has been logged" and "logging is not running" would
|
||||||
|
/// otherwise look the same.
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
let (len, dropped, last) = self.with(|inner| {
|
||||||
|
(
|
||||||
|
inner.lines.len(),
|
||||||
|
inner.dropped,
|
||||||
|
inner.lines.back().map(|line| line.at_ms),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
match last {
|
||||||
|
None => "app log: no lines yet".to_string(),
|
||||||
|
Some(at) => {
|
||||||
|
let dropped = if dropped > 0 {
|
||||||
|
format!(", {dropped} dropped")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"app log: {len} lines held{dropped}, last {}",
|
||||||
|
clock_time(at)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a target belongs to this app's own crates (`iris` or
|
||||||
|
/// `client_core`) rather than a dependency's -- `starts_with` guarded by an
|
||||||
|
/// exact match or a `::` so an unrelated crate that merely begins with the
|
||||||
|
/// same letters (there is no such crate today, but the check should not
|
||||||
|
/// rely on that) is never mistaken for one of ours.
|
||||||
|
fn is_own_target(target: &str) -> bool {
|
||||||
|
target == "iris"
|
||||||
|
|| target.starts_with("iris::")
|
||||||
|
|| target == "client_core"
|
||||||
|
|| target.starts_with("client_core::")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a line at `level` from `target` belongs in the ring, given
|
||||||
|
/// whether tracing is on right now.
|
||||||
|
///
|
||||||
|
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||||
|
/// asked for, applied once here rather than at each `debug!` call site:
|
||||||
|
/// Info and above always ring, from anything, because a real warning or
|
||||||
|
/// error from a dependency is worth keeping. Debug and Trace ring only
|
||||||
|
/// from this app's own targets, and only while tracing is switched on --
|
||||||
|
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
|
||||||
|
/// (the process logger's own level, set once at install and unrelated to
|
||||||
|
/// tracing), which is what filled the ring with 1339 lines of it and
|
||||||
|
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
|
||||||
|
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
|
||||||
|
/// (commit 992c472); this is the backstop for lines this crate does not
|
||||||
|
/// control.
|
||||||
|
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||||
|
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
|
||||||
|
/// logger the platform already installs, so nothing that reads the
|
||||||
|
/// platform's log (`logcat`, a terminal) changes.
|
||||||
|
///
|
||||||
|
/// The inner logger is passed in rather than chosen here: `client-core`
|
||||||
|
/// has no business depending on `android_logger` or `env_logger`, and
|
||||||
|
/// which one is right is exactly what differs between the two platforms
|
||||||
|
/// (the sharing rule in AGENTS.md).
|
||||||
|
pub struct RingLogger {
|
||||||
|
ring: LogRing,
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
|
||||||
|
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
|
||||||
|
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
|
||||||
|
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
|
||||||
|
/// direction"), so the platform crate that depends on both is the one
|
||||||
|
/// that wires this closure through, the same way it already supplies
|
||||||
|
/// `inner`.
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingLogger {
|
||||||
|
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
|
||||||
|
Self {
|
||||||
|
ring,
|
||||||
|
inner,
|
||||||
|
trace_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl log::Log for RingLogger {
|
||||||
|
/// True for anything `log`'s own max level lets through: the ring
|
||||||
|
/// wants everything the *inner* logger might also want, even where the
|
||||||
|
/// platform logger would filter it out. Which lines the ring itself
|
||||||
|
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
|
||||||
|
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
|
||||||
|
self.ring
|
||||||
|
.push(record.level(), record.target(), record.args().to_string());
|
||||||
|
}
|
||||||
|
if self.inner.enabled(record.metadata()) {
|
||||||
|
self.inner.log(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
self.inner.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a [`RingLogger`] as the process logger and answers the ring it
|
||||||
|
/// records into.
|
||||||
|
///
|
||||||
|
/// Fails only if a logger is already installed, which is a programmer
|
||||||
|
/// error (two initialisation paths) rather than a recoverable condition --
|
||||||
|
/// the caller is named in the error so it is findable.
|
||||||
|
pub fn install(
|
||||||
|
ring: LogRing,
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
max_level: log::LevelFilter,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
) -> Result<(), log::SetLoggerError> {
|
||||||
|
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
|
||||||
|
log::set_max_level(max_level);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one ring this process records into.
|
||||||
|
///
|
||||||
|
/// **A deliberate process-global, where this project's rules otherwise say
|
||||||
|
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||||
|
/// has exactly one backend per process, set once, and every `log::info!`
|
||||||
|
/// anywhere in the binary goes to it. A ring handed around as a parameter
|
||||||
|
/// would be a *second* answer to "which lines exist" -- the report would
|
||||||
|
/// show one ring while the logger filled another, and which one a caller
|
||||||
|
/// got would depend on how far down the call tree it was. The tests above
|
||||||
|
/// all use their own [`LogRing`], so nothing here needs this to be
|
||||||
|
/// testable.
|
||||||
|
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||||
|
|
||||||
|
/// The process's ring, created on first use with the default bounds.
|
||||||
|
/// Safe to call before [`install_process_logger`] -- it will simply be
|
||||||
|
/// empty.
|
||||||
|
pub fn process_ring() -> &'static LogRing {
|
||||||
|
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs [`process_ring`] as the recording half of the process logger,
|
||||||
|
/// forwarding to `inner` (the platform's own logger, already configured).
|
||||||
|
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
|
||||||
|
/// else is shared. `trace_enabled` is the platform's own trace toggle
|
||||||
|
/// (`iris::diagnostics::trace_enabled` on Android) -- see
|
||||||
|
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
|
||||||
|
/// passed in rather than called directly.
|
||||||
|
pub fn install_process_logger(
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
max_level: log::LevelFilter,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
) -> Result<(), log::SetLoggerError> {
|
||||||
|
install(process_ring().clone(), inner, max_level, trace_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use log::Level;
|
||||||
|
|
||||||
|
fn fill(ring: &LogRing, count: usize) {
|
||||||
|
for n in 0..count {
|
||||||
|
ring.push(Level::Info, "test", format!("line {n}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_come_back_oldest_first() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(text, ["line 0", "line 1", "line 2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_line_bound_drops_the_oldest_and_says_how_many() {
|
||||||
|
let ring = LogRing::new(3, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
|
||||||
|
assert_eq!(ring.len(), 3);
|
||||||
|
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||||
|
// Room for 1000 lines but only a few hundred bytes.
|
||||||
|
let ring = LogRing::new(1000, 300);
|
||||||
|
for n in 0..10 {
|
||||||
|
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
ring.len() < 10,
|
||||||
|
"the byte bound evicted: {} held",
|
||||||
|
ring.len()
|
||||||
|
);
|
||||||
|
assert!(ring.dropped() > 0);
|
||||||
|
assert!(
|
||||||
|
ring.snapshot().last().unwrap().message.starts_with('9'),
|
||||||
|
"and it evicted from the old end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case the `len() > 1` guard exists for: one line larger than the
|
||||||
|
/// whole bound must still be readable, or a ring that is over budget
|
||||||
|
/// reads as a ring nothing was written to.
|
||||||
|
#[test]
|
||||||
|
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||||
|
let ring = LogRing::new(100, 64);
|
||||||
|
ring.push(Level::Error, "t", "y".repeat(5000));
|
||||||
|
assert_eq!(ring.len(), 1);
|
||||||
|
assert_eq!(ring.dropped(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_numbers_only_increase_and_survive_eviction() {
|
||||||
|
let ring = LogRing::new(2, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
|
||||||
|
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn since_returns_only_what_is_new_and_the_next_cursor() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let (first, cursor) = ring.since(0);
|
||||||
|
assert_eq!(first.len(), 3);
|
||||||
|
assert_eq!(cursor, 3);
|
||||||
|
|
||||||
|
let (none, cursor) = ring.since(cursor);
|
||||||
|
assert!(none.is_empty(), "nothing new yet");
|
||||||
|
assert_eq!(cursor, 3, "and the cursor does not move");
|
||||||
|
|
||||||
|
ring.push(Level::Warn, "test", "later".into());
|
||||||
|
let (more, cursor) = ring.since(cursor);
|
||||||
|
assert_eq!(more.len(), 1);
|
||||||
|
assert_eq!(more[0].message, "later");
|
||||||
|
assert_eq!(cursor, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The restart signal: a reader that saw sequence 4 and is now told
|
||||||
|
/// the newest is 0 knows the process is not the one it was reading.
|
||||||
|
#[test]
|
||||||
|
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
|
||||||
|
fill(&ring, 5);
|
||||||
|
assert_eq!(ring.newest_seq(), Some(4));
|
||||||
|
|
||||||
|
let restarted = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&restarted, 1);
|
||||||
|
assert_eq!(
|
||||||
|
restarted.newest_seq(),
|
||||||
|
Some(0),
|
||||||
|
"a fresh ring starts again, which is exactly what a reader has to notice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_does_not_consume() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 2);
|
||||||
|
let (sent, _) = ring.since(0);
|
||||||
|
assert_eq!(sent.len(), 2);
|
||||||
|
assert_eq!(ring.len(), 2, "the report still has them after an upload");
|
||||||
|
assert_eq!(ring.to_text().lines().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tail_text_is_the_whole_ring_untouched_when_under_the_cap() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
assert_eq!(ring.tail_text(150), ring.to_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tail_text_trims_to_the_newest_lines_and_says_how_many_were_left_out() {
|
||||||
|
let ring = LogRing::new(1000, 1 << 20);
|
||||||
|
fill(&ring, 200);
|
||||||
|
let tail = ring.tail_text(150);
|
||||||
|
let mut lines = tail.lines();
|
||||||
|
assert_eq!(
|
||||||
|
lines.next().unwrap(),
|
||||||
|
"50 earlier lines omitted; full log in Dev Updater's Runtime tab"
|
||||||
|
);
|
||||||
|
let rest: Vec<&str> = lines.collect();
|
||||||
|
assert_eq!(rest.len(), 150, "exactly the cap, after the header line");
|
||||||
|
assert!(
|
||||||
|
rest[0].ends_with("line 50"),
|
||||||
|
"the oldest line kept is the 50th, not line 0: {}",
|
||||||
|
rest[0]
|
||||||
|
);
|
||||||
|
assert!(rest.last().unwrap().ends_with("line 199"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_gives_the_newest_lines_with_no_header() {
|
||||||
|
let ring = LogRing::new(1000, 1 << 20);
|
||||||
|
fill(&ring, 200);
|
||||||
|
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
|
||||||
|
let lines: Vec<&str> = tail.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
|
||||||
|
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
|
||||||
|
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of the `try_`: the panic hook calls this from a
|
||||||
|
/// thread that may already hold the ring's lock, and a blocking read
|
||||||
|
/// there would hang the process instead of aborting it.
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let held = ring.0.lock().expect("fresh ring");
|
||||||
|
assert_eq!(ring.try_tail_text(80), None);
|
||||||
|
drop(held);
|
||||||
|
assert!(ring.try_tail_text(80).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
assert_eq!(ring.summary(), "app log: no lines yet");
|
||||||
|
assert_eq!(ring.last_at_ms(), None);
|
||||||
|
assert!(ring.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summary_names_dropped_lines_only_when_there_are_some() {
|
||||||
|
let ring = LogRing::new(2, 1 << 20);
|
||||||
|
fill(&ring, 2);
|
||||||
|
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
|
||||||
|
fill(&ring, 2);
|
||||||
|
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_formats_as_time_level_target_message() {
|
||||||
|
let line = LogLine {
|
||||||
|
seq: 0,
|
||||||
|
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
|
||||||
|
// hand rather than against another clock.
|
||||||
|
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||||
|
level: Level::Info,
|
||||||
|
target: "iris::android".into(),
|
||||||
|
message: "surface created".into(),
|
||||||
|
}
|
||||||
|
.format();
|
||||||
|
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forwarding half: a line reaches the ring *and* the logger the
|
||||||
|
/// platform already had, and one the inner logger filters out is still
|
||||||
|
/// in the ring.
|
||||||
|
#[test]
|
||||||
|
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||||
|
use log::Log;
|
||||||
|
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
|
||||||
|
impl Log for Collect {
|
||||||
|
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||||
|
metadata.level() <= self.1
|
||||||
|
}
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
self.0.lock().unwrap().push(record.args().to_string());
|
||||||
|
}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
// Own target, tracing on: this is the case where the ring and the
|
||||||
|
// inner logger disagree, which is the thing under test -- a
|
||||||
|
// foreign target is covered separately below.
|
||||||
|
let logger = RingLogger::new(
|
||||||
|
ring.clone(),
|
||||||
|
Box::new(Collect(seen.clone(), Level::Info)),
|
||||||
|
|| true,
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("kept"))
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("iris::test")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("filtered"))
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("iris::test")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*seen.lock().unwrap(),
|
||||||
|
["kept"],
|
||||||
|
"the inner logger's own filter still applies"
|
||||||
|
);
|
||||||
|
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(
|
||||||
|
held,
|
||||||
|
["kept", "filtered"],
|
||||||
|
"own-target debug still rings while tracing is on"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
|
||||||
|
/// unconditionally, and used to flood the ring even though nothing in
|
||||||
|
/// this app asked for their Debug output. A foreign target's Debug
|
||||||
|
/// line must not ring even while tracing is on -- tracing controls
|
||||||
|
/// this app's own diagnostics, not a dependency's chatter.
|
||||||
|
#[test]
|
||||||
|
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||||
|
use log::Log;
|
||||||
|
struct Discard;
|
||||||
|
impl Log for Discard {
|
||||||
|
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn log(&self, _: &log::Record) {}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("naga debug spam"))
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("naga::front")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("naga warning"))
|
||||||
|
.level(Level::Warn)
|
||||||
|
.target("wgpu_core::device")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(
|
||||||
|
held,
|
||||||
|
["naga warning"],
|
||||||
|
"Info-and-above always rings; foreign Debug never does"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_accepts_is_own_target_debug_only_while_tracing() {
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Info, "wgpu_core::device", false),
|
||||||
|
"Info+ from anything, tracing off"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Warn, "jni", true),
|
||||||
|
"Info+ from anything, tracing on"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ring_accepts(Level::Debug, "jni", true),
|
||||||
|
"foreign Debug, tracing on: still excluded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ring_accepts(Level::Debug, "iris::sense", false),
|
||||||
|
"own Debug, tracing off: excluded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Debug, "iris::sense", true),
|
||||||
|
"own Debug, tracing on: included"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Trace, "client_core::api", true),
|
||||||
|
"own Trace, tracing on: included"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_own_target_matches_the_crate_or_its_modules_only() {
|
||||||
|
assert!(is_own_target("iris"));
|
||||||
|
assert!(is_own_target("iris::sense"));
|
||||||
|
assert!(is_own_target("client_core"));
|
||||||
|
assert!(is_own_target("client_core::log_ring"));
|
||||||
|
assert!(!is_own_target("iris_something_else"));
|
||||||
|
assert!(!is_own_target("naga::front"));
|
||||||
|
assert!(!is_own_target("jni"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
},
|
},
|
||||||
@@ -294,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
|
||||||
@@ -369,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(),
|
||||||
});
|
});
|
||||||
@@ -379,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 {
|
||||||
@@ -401,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(),
|
||||||
});
|
});
|
||||||
@@ -457,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() {
|
||||||
@@ -472,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -557,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
|
||||||
@@ -796,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!(
|
||||||
@@ -808,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(),
|
||||||
}]
|
}]
|
||||||
@@ -955,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,451 @@ 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-08 (later still: the list's overscroll clamp, in frame)
|
||||||
|
|
||||||
|
Finishes the item the previous entry deferred. IRIS.md has the account.
|
||||||
|
|
||||||
|
- **`List` lays out a second time within the frame** when its walk lands
|
||||||
|
off the end of the content, instead of writing the correction to the
|
||||||
|
anchor and asking for another frame. The extra walk is paid only on an
|
||||||
|
overscrolled frame, and it is mostly O(1) moves.
|
||||||
|
- **`Painter::draw_again` is removed**, `List` having been its only
|
||||||
|
caller -- so the framework no longer offers a way to ask for a
|
||||||
|
corrective frame at all.
|
||||||
|
- **`List::place`'s top-known and bottom-known cases are one path**
|
||||||
|
(`Placement::edges`), which is the "write the logic once" rule applied
|
||||||
|
to two symmetric directions rather than a behaviour change.
|
||||||
|
|
||||||
|
## 2026-09-08 (later: a scroll area measures and places in one frame)
|
||||||
|
|
||||||
|
From Iris's phone report about the composer's padding while typing
|
||||||
|
newlines, and the rule she stated when she read the first fix: layout is
|
||||||
|
a pure function of the state, nothing self-heals, and two draws to place
|
||||||
|
something happen in the same frame. IRIS.md's entry has the account.
|
||||||
|
|
||||||
|
- **`Scroll::draw` draws its child twice** -- once at last frame's length
|
||||||
|
to measure it, once at the measured length to place it -- instead of
|
||||||
|
placing against the stale length and leaving a wrong frame on screen.
|
||||||
|
The second draw is free unless the content's length changed.
|
||||||
|
- **An end-anchored `Scroll` is at its end on its first drawn frame**, a
|
||||||
|
consequence of the above. Two layout tests now build their area with
|
||||||
|
`at_end: false`, which is what they meant: they scroll down from the
|
||||||
|
top.
|
||||||
|
- **`List::clamp_to_content`'s next-frame correction is left in place**
|
||||||
|
and written down in docs/IRIS_TODO.md instead of fixed here, because
|
||||||
|
`List::place` is a larger piece of machinery and deserves its own
|
||||||
|
before/after on the phone.
|
||||||
|
|
||||||
|
## 2026-09-08 (every crate to its latest version, wgpu 28 -> 30)
|
||||||
|
|
||||||
|
At Iris's request. RUST.md's "Every crate to its latest version" box has
|
||||||
|
the full list and the migration.
|
||||||
|
|
||||||
|
- **wgpu 30 taken now rather than pinned at 28.** Two majors of API
|
||||||
|
change, all mechanical (instance descriptor, optional bind-group and
|
||||||
|
vertex-buffer slots, `Queue::present`, a `CurrentSurfaceTexture` enum),
|
||||||
|
and one that would have been a startup abort on a device rather than a
|
||||||
|
compile error: naga now demands `@interpolate(flat)` on the shader's
|
||||||
|
integer varyings. Verified on both backends before this was called
|
||||||
|
done, since a renderer that compiles proves nothing.
|
||||||
|
- **The desktop instance now carries winit's display handle.** wgpu 30
|
||||||
|
asks for it when a GLES surface will be presented on Wayland, which is
|
||||||
|
what this machine's Vulkan-to-GLES fallback produces. Android passes
|
||||||
|
none: its surface comes from a `NativeWindow`.
|
||||||
|
- **`syn` 2 -> 3, `pollster` 0.4 -> 1.0** with no source change in
|
||||||
|
`iris/macro` or anywhere else.
|
||||||
|
|
||||||
|
## 2026-09-08 evening (the fling is shared; a cancel is not a release)
|
||||||
|
|
||||||
|
From Iris's four-item phone report; RUST.md's "2026-09-08 (evening)" box
|
||||||
|
has the reasoning and the tests, IRIS.md the summary.
|
||||||
|
|
||||||
|
- **A `Flinger` that does not know which way the content moves.** Every
|
||||||
|
scroll area flings now, on either axis, as Iris asked -- and the
|
||||||
|
physics is one type shared by `List` and `Scroll` rather than a copy
|
||||||
|
each. The choice worth reviewing is the seam: `Flinger` owns the curve
|
||||||
|
and the clock, and the *caller* owns the sign convention and where the
|
||||||
|
content ends. Rejected: teaching `Flinger` a direction, which would
|
||||||
|
have to be told to it -- and being told is the same thing as not
|
||||||
|
knowing, with an extra field to get wrong.
|
||||||
|
- **A cancel is a first-class end to a gesture, not an early release.**
|
||||||
|
`CursorState::cancelled` is new state on the pointer sample, set by
|
||||||
|
Android's `ACTION_CANCEL` and the harness's `TouchAction::Cancel`.
|
||||||
|
Rejected: mapping a cancel to `PressEnd` and having each widget decide
|
||||||
|
what to suppress, which is what shipped and is why leaving the app
|
||||||
|
flung the transcript.
|
||||||
|
- **A `DragGesture` ignores a `Cancel` it caused.** One gesture is
|
||||||
|
driven by several widgets, so the widget that was pressed can be a
|
||||||
|
"loser" on the frame its own gesture won. The test is whether the
|
||||||
|
gesture's own capture id is the holder. This is what makes it safe for
|
||||||
|
every widget driving a gesture to register the whole `drag_senses()`
|
||||||
|
set, which is now the rule without exception.
|
||||||
|
- **`List::place` draws a resized row twice in one frame.** The old
|
||||||
|
comment accepted a one-frame lag by analogy with `Scroll`'s content
|
||||||
|
length. That analogy was wrong: a stale *length* only misplaces the
|
||||||
|
next thing, while a stale *box* is drawn, because a background fills
|
||||||
|
whatever box it is handed. The extra draw is bounded to frames where a
|
||||||
|
row's height actually changed.
|
||||||
|
|
||||||
|
## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted)
|
||||||
|
|
||||||
|
- **Directed by Iris.** Her question on seeing `widget::mark`: "why does
|
||||||
|
mark exist? The font should be working if it's working for compose and
|
||||||
|
nerd fonts are bundled." It was not: the Compose app draws its icons
|
||||||
|
from **its own committed Nerd Fonts subset**, while iris was setting
|
||||||
|
the disclosure mark with bare Unicode geometric codepoints
|
||||||
|
(U+25B8/25BE/25B4) out of whatever face the platform resolved -- an
|
||||||
|
empty box on her phone, a dot on this VM. The 2026-09-07 entry below,
|
||||||
|
which said "iris had no equivalent icon font to keep", is what left
|
||||||
|
that gap: iris had no icon font because it had never had one, not
|
||||||
|
because it needed none.
|
||||||
|
- **So iris now bundles the same kind of subset**:
|
||||||
|
`iris/core/build-icon-font.sh` writes
|
||||||
|
`iris/core/assets/fonts/nerd_icons.ttf` (992 bytes, three Material
|
||||||
|
Design glyphs today), `iris::icon` names the codepoints, and
|
||||||
|
`Family::Icons` draws them. This does **not** reopen the platform-fonts
|
||||||
|
decision: body and monospace text still come from the platform, and an
|
||||||
|
icon is the opposite case -- a small, closed, known set of codepoints,
|
||||||
|
which is exactly the division the Compose app already makes.
|
||||||
|
- **`iris::widget::mark` is deleted** (added earlier the same day). It
|
||||||
|
drew a correct triangle, but only a triangle, and every further icon
|
||||||
|
would have been another bespoke rasteriser. An icon as text also takes
|
||||||
|
the size, colour and baseline of the line it sits in for free.
|
||||||
|
|
||||||
|
## 2026-09-08 (the emulator is a GLES machine, and Vulkan is verified elsewhere)
|
||||||
|
|
||||||
|
- **Directed by Iris, carried out here**: "make sure the setup uses GL for
|
||||||
|
the android emulator and remove any vulkan requirements. That'll be
|
||||||
|
tested through both the desktop version as well as my phone." So the
|
||||||
|
emulator is settled as a GLES rig and nothing chases hardware Vulkan in
|
||||||
|
it any more; the Vulkan path is covered by the desktop build and by her
|
||||||
|
phone.
|
||||||
|
- **Nothing had to be forced to make that true.** Measured in the guest
|
||||||
|
the same day: the emulator has no hardware Vulkan at all (its only
|
||||||
|
Vulkan is SwiftShader, in software) and its GLES is the host's real RX
|
||||||
|
7900 XT through virgl at ES 3.1. iris's existing runtime fallback --
|
||||||
|
`Backends::PRIMARY`, no adapter, rebuild on `Backends::GL` -- already
|
||||||
|
lands there, verified end to end with an ordinary (no `force-gles`)
|
||||||
|
debug APK.
|
||||||
|
- **The emulator and the phone therefore run the same binary**, differing
|
||||||
|
only in what that binary finds. That is deliberate and worth not
|
||||||
|
undoing: a build flag that changed the backend would mean the thing
|
||||||
|
measured on the emulator is not the thing shipped. `force-gles` stays,
|
||||||
|
but only for pinning the backend on a machine that *does* have Vulkan
|
||||||
|
(the desktop), and never for a phone build.
|
||||||
|
- **Every run now says which adapter drew it.** The Android renderer logs
|
||||||
|
the full adapter line at startup the way the desktop already did -- only
|
||||||
|
the backend enum was logged before, which cannot separate `Gl` on the
|
||||||
|
host's GPU from `Gl` on SwiftShader, or a phone's real Vulkan from a
|
||||||
|
software one. `run-bench.sh` prints that line before any number.
|
||||||
|
- **No Vulkan requirement was found in iris to remove.** `device_limits()`
|
||||||
|
asks for nothing beyond wgpu's defaults (and zeroes the compute fields),
|
||||||
|
neither backend requires a feature, and both probe rather than
|
||||||
|
`.expect()` an adapter. What was removed was the *documentation* telling
|
||||||
|
people to boot the emulator with SwiftShader Vulkan.
|
||||||
|
|
||||||
|
## 2026-09-07 (platform fonts, not bundled ones)
|
||||||
|
|
||||||
|
- **Iris's own decision, carried out as directed**: removed the 3.6 MB of
|
||||||
|
bundled Noto Sans/Noto Sans Mono TTFs from `iris-core` and load text
|
||||||
|
from the platform's own font collection instead (`fontique`'s system
|
||||||
|
discovery, already on by default). Matches what the Compose app does --
|
||||||
|
it takes body text from `FontFamily.Default` and code text from
|
||||||
|
`FontFamily.Monospace`, both platform-resolved, and ships no text font
|
||||||
|
of its own. Rejected alternative (the one this pass had left open
|
||||||
|
2026-09-06): subsetting the bundled Noto Sans to Latin/common
|
||||||
|
punctuation instead of removing it outright, which would have kept
|
||||||
|
identical rendering across devices for a smaller (not zero) size cost;
|
||||||
|
Iris chose to match Compose instead.
|
||||||
|
- `.so` **-3,748,136 bytes** (11,193,608 -> 7,445,472), matching the
|
||||||
|
original 3.6 MB estimate. Fallback still lands on the platform's own
|
||||||
|
tofu for a codepoint no resolved face has (checked with CJK + emoji on
|
||||||
|
desktop) rather than blank space, so the UI_RULES unknown-glyph rule
|
||||||
|
still holds.
|
||||||
|
- **Gap found, then closed same day**: this fontique version's Android
|
||||||
|
backend never resolved the `Monospace` generic family at all (confirmed
|
||||||
|
on this checkout's emulator, `mono=None` in the startup diagnostic) --
|
||||||
|
two pre-existing bugs in fontique's own `fonts.xml` parsing stacked (an
|
||||||
|
ordering bug, and a `<family name="monospace">` declaration whose
|
||||||
|
`<font>` children the backend's parser never reads), not something this
|
||||||
|
change introduced, but this change is what stopped masking it (the
|
||||||
|
bundled mono font used to be registered ahead of the broken platform
|
||||||
|
lookup, so it always won). Checked `linebender/parley`'s `main` branch
|
||||||
|
on GitHub: neither bug is fixed there, so there was no newer release to
|
||||||
|
bump to. Fixed instead in `iris-core` itself
|
||||||
|
(`TextData::patch_android_monospace`, Android-only): reads
|
||||||
|
`/system/etc/fonts.xml`'s own `"monospace"` declaration for the font
|
||||||
|
filename it names, then registers whichever of fontique's actually-
|
||||||
|
scanned families owns that file as the `Monospace` generic -- the same
|
||||||
|
authority Compose's `Typeface.MONOSPACE` resolves through, without
|
||||||
|
pinning an OEM-specific family name. Verified on this checkout's
|
||||||
|
emulator: `mono=Some("Droid Sans Mono")`, and a screenshot showing the
|
||||||
|
bench-fixture's code block and tool-card values in a visibly monospaced
|
||||||
|
face beside sans body text; the desktop `fontconfig` backend is
|
||||||
|
unaffected (still resolves monospace correctly, confirmed unchanged).
|
||||||
|
docs/RUST.md's "Platform fonts (2026-09-07)" has the full account.
|
||||||
|
|
||||||
|
## 2026-09-07 (a phone log reaches Iris through Dev Updater's own tab)
|
||||||
|
|
||||||
|
**Supersedes the "how a phone log reaches Iris" entry below, same day.**
|
||||||
|
Iris's call once the route was working: put it in Dev Updater properly
|
||||||
|
rather than smuggling the lines through `ai-server`'s log.
|
||||||
|
|
||||||
|
- **The app exposes its own log on the device, and Dev Updater reads it
|
||||||
|
there.** A `ContentProvider` at `<applicationId>.devlog`, one table of
|
||||||
|
lines queried with `?since=<seq>` so a poll is incremental, plus a
|
||||||
|
`status` row (`held`, `dropped`, `newest_seq`). Dev Updater's phone app
|
||||||
|
polls it while the component's **Runtime** tab is open and forwards what
|
||||||
|
is new to its own build machine, into that APK component's runtime log
|
||||||
|
-- so the same tab renders both kinds and the history outlives the
|
||||||
|
phone. No tunnel, no token, no second enrolment: the two apps are on the
|
||||||
|
same phone.
|
||||||
|
|
||||||
|
**It is a contract, not a feature for iris.** Written down in
|
||||||
|
dev-updater's `README.md` ("An app's own log"), so any app that server
|
||||||
|
delivers gets the tab by implementing it; the Compose app in `app/` can
|
||||||
|
do the same later. That is the reason it beat the route below on its
|
||||||
|
second look -- the earlier one only ever worked for the one project that
|
||||||
|
had a server, and put a phone's lines under a *different component* than
|
||||||
|
the one they came from.
|
||||||
|
|
||||||
|
- **Read access is `protectionLevel="normal"`, and that is a real trade.**
|
||||||
|
`signature` is what this wants and is not available: Dev Updater and the
|
||||||
|
apps it delivers are built on one machine but signed with different
|
||||||
|
locally generated keys, so a signature permission would be held by
|
||||||
|
nothing at all. What `normal` costs is that any app on that phone which
|
||||||
|
requests `dev.updater.permission.READ_DEVLOG` by name can read another
|
||||||
|
app's dev log. Accepted because these are development builds on a
|
||||||
|
development phone and the alternative was no log; stated in the manifest
|
||||||
|
beside the declaration and in dev-updater's README so it is not
|
||||||
|
rediscovered as a surprise.
|
||||||
|
|
||||||
|
- **The provider polls rather than notifying.** `notifyChange` was not
|
||||||
|
implemented: the ring is filled by a `log::Log` backend on whatever
|
||||||
|
thread logged, and giving that a route to a `ContentProvider` means
|
||||||
|
plumbing a callback through `client-core` for every platform. Dev
|
||||||
|
Updater's contract therefore says it polls (about a second, only while
|
||||||
|
the tab is open), which is what keeps implementing the contract cheap --
|
||||||
|
a provider that does notify loses nothing.
|
||||||
|
|
||||||
|
- **What was deleted, so there is one mechanism**: `client-core`'s
|
||||||
|
`log_upload` module, `POST /client-log` on `ai-server`, the
|
||||||
|
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` baking in `iris/android-app/build.rs`
|
||||||
|
(which left that file with nothing to do, so it is gone too), and the
|
||||||
|
uploader fields on both Android clients. Kept: the ring, `RingLogger`,
|
||||||
|
`install_process_logger`, and the Diagnostics line counting what is
|
||||||
|
held. The upload-status line there is now **"devlog provider:
|
||||||
|
content://<authority>"** -- named from what the provider registered
|
||||||
|
rather than composed from the package here, so a screenshot of that pane
|
||||||
|
is evidence the contract is live and says which package's log it is.
|
||||||
|
|
||||||
|
## 2026-09-07 (how a phone log reaches Iris) -- superseded, see above
|
||||||
|
|
||||||
|
- **The app sends its own log to `ai-server`, and Dev Updater shows it as
|
||||||
|
`ai-server`'s runtime log.** Iris has no `adb`/`logcat` on her phone, and
|
||||||
|
Android forbids one app reading another's logcat, so the app has to carry
|
||||||
|
its own copy and post it somewhere. `POST /client-log` on `ai-server`
|
||||||
|
re-emits each line into that server's own `tracing` output; Dev Updater
|
||||||
|
already runs `ai-server` as a `Managed` component, whose stdout its own
|
||||||
|
service script redirects to a file and reports through
|
||||||
|
`GET /apps/{key}/components/{name}/logs?kind=runtime`, which the phone
|
||||||
|
app's log dialog already offers as a **Runtime** tab for a `server`
|
||||||
|
component. So **no change to Dev Updater at all** -- one route on
|
||||||
|
`ai-server`, and the client in `client-core`.
|
||||||
|
|
||||||
|
**Rejected: posting to Dev Updater's own server** (the first candidate,
|
||||||
|
and what the entry above went on to build -- the estimate below was
|
||||||
|
right about the work and wrong about it being too much).
|
||||||
|
It would need a new authenticated *write* route on a TLS surface whose
|
||||||
|
module doc says every route on it "is, or decides, the bytes that get
|
||||||
|
handed to `REQUEST_INSTALL_PACKAGES` next"; a per-app device-log store;
|
||||||
|
a change to `component_logs` so an APK component can have a runtime log;
|
||||||
|
a change to the phone app's `hasBothKinds = component.kind == "server"`
|
||||||
|
gate and to what `hasRuntimeLogs` means on the wire; and -- the real
|
||||||
|
cost -- a **second** enrollment for the iris app, since it has no CA or
|
||||||
|
token for Dev Updater and Dev Updater mints tokens per device by QR.
|
||||||
|
Five changes across two repos against one route, for the same line
|
||||||
|
landing in the same viewer.
|
||||||
|
|
||||||
|
**Rejected: a share intent from a debug button** (a log file in the app's
|
||||||
|
external files dir, shared by hand). It works today and needs no server,
|
||||||
|
but every line costs Iris a manual export and a message, which is the
|
||||||
|
round trip through a person this was meant to remove. It is still the
|
||||||
|
fallback when the tunnel is down, and GrapheneOS's own per-app log export
|
||||||
|
already covers the crash case (that is how the `ToolInput.highlighted`
|
||||||
|
crash was reported).
|
||||||
|
|
||||||
|
- **The ring is in `client-core`, not in the Android crate.** A bounded
|
||||||
|
in-memory ring (2000 lines or 256 KiB, whichever bites first) behind a
|
||||||
|
`log::Log` backend that *forwards* to whichever logger the platform
|
||||||
|
already installed, so `logcat` and a desktop terminal see exactly what
|
||||||
|
they saw before. The platform supplies only its own logger and its
|
||||||
|
destination. `Copy report` appends the ring to what goes on the
|
||||||
|
clipboard, and flushes the uploader first.
|
||||||
|
|
||||||
|
- **The destination is baked in at build time, from the build machine's
|
||||||
|
own files** (`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA) --
|
||||||
|
*gone; the provider above replaced it.* What is worth keeping from it is
|
||||||
|
the reason it went: an APK good only for the server that built it cannot
|
||||||
|
be built in this VM for Iris's phone, which is the case that mattered.
|
||||||
|
all three or none, never two. The same trust boundary the transcript
|
||||||
|
config and the Compose APK's CA already use: nothing secret is
|
||||||
|
committed, and an APK is good for the server that built it. A build told
|
||||||
|
nothing still keeps its ring and still copies it; the diagnostics pane
|
||||||
|
says which of "not tried yet", "failing -- <why>" and "no server
|
||||||
|
configured" it is, because otherwise all three look like silence.
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -291,3 +736,55 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
|
|||||||
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||||
have the full account. The iris-vs-Masonry choice itself is still
|
have the full account. The iris-vs-Masonry choice itself is still
|
||||||
Iris's to make.
|
Iris's to make.
|
||||||
|
|
||||||
|
## 2026-09-07: the enrolment link carries the CA, so an APK need not be built where its server runs
|
||||||
|
|
||||||
|
**Problem.** Every phone build pinned the CA of the machine that compiled
|
||||||
|
it -- the Compose app from `GeneratePinnedCert`, the iris app from
|
||||||
|
`build.rs` reading `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. That is fine
|
||||||
|
while the two are the same machine and impossible when they are not, which
|
||||||
|
is exactly the iris client's situation: cross-compiled in this VM,
|
||||||
|
delivered to a phone, run against `ai-server` on the host. Baking the
|
||||||
|
host/port/token as well made it worse -- a token in a built artifact.
|
||||||
|
|
||||||
|
**Decided: the CA rides in the enrolment link**, as `&ca=<base64url of the
|
||||||
|
DER>` (`wg_app_link::enroll::ca_param`), optional and per mint. The app
|
||||||
|
that opens the link pins what the link said, and an APK built anywhere
|
||||||
|
works against whatever server it is pointed at.
|
||||||
|
|
||||||
|
Two alternatives were worked out and rejected.
|
||||||
|
|
||||||
|
- **A CA *fingerprint* in the link, pinned at the TLS handshake.** The
|
||||||
|
smallest link (43 more characters) and the strongest shape, but `ureq`
|
||||||
|
3.4 exposes no hook for a custom `rustls` `ServerCertVerifier`: its
|
||||||
|
`TlsConfig` builds the `ClientConfig` itself, so this needs a hand-written
|
||||||
|
`Connector` on the `unversioned` API and `rustls` as a direct dependency
|
||||||
|
of `client-core`. A lot of machinery in the one crate that must stay
|
||||||
|
light.
|
||||||
|
- **A fingerprint in the link plus an unauthenticated `GET /ca.pem`.**
|
||||||
|
Small code, but it needs a first connection with verification disabled,
|
||||||
|
and it breaks a documented, tested posture -- `auth.rs`'s "gates every
|
||||||
|
route with zero unauthenticated endpoints", which is a load-bearing
|
||||||
|
decision rather than an implementation detail. Not something to change
|
||||||
|
silently for this.
|
||||||
|
|
||||||
|
**What it costs**, measured rather than guessed: on this project's P-256
|
||||||
|
CA the link goes from 89 bytes to 652, and `print_enrollment`'s terminal
|
||||||
|
QR from 45x23 to 93x47 characters. That is why the parameter is the
|
||||||
|
minter's choice per call: `ai-server` passes it (its iris client needs it),
|
||||||
|
`dev-updater` passes `None` (its app is built on the machine it talks to,
|
||||||
|
and its QR stays scannable in an 80-column terminal). The URI printed under
|
||||||
|
the QR is the fallback either way, and is the path Dev Updater's Enroll
|
||||||
|
button already uses -- it opens the link with `ACTION_VIEW`, so Android
|
||||||
|
offers whichever apps registered the scheme, which needed no change here.
|
||||||
|
|
||||||
|
The CA is a public certificate, so putting it in the QR leaks nothing the
|
||||||
|
token did not already: photographing the terminal still costs exactly the
|
||||||
|
token, which is rotatable.
|
||||||
|
|
||||||
|
**The log upload's destination is moot**, so it is not wired to this. On
|
||||||
|
the same day Iris decided Dev Updater will read an APK's runtime log from
|
||||||
|
an on-device ContentProvider instead, which removes `log_upload`,
|
||||||
|
`POST /client-log` and the `AI_APP_LOG_*` baking altogether -- so the
|
||||||
|
enrolment landed without touching any of them, for that change to delete
|
||||||
|
whole.
|
||||||
@@ -7,6 +7,35 @@ order and what "done" looks like. Tick and date them in place.
|
|||||||
|
|
||||||
## Fix
|
## Fix
|
||||||
|
|
||||||
|
- [x] **`List::clamp_to_content` still corrects on the next frame
|
||||||
|
(2026-09-08).** Iris's rule, stated while the composer's caret was
|
||||||
|
being fixed: "nothing in the framework should ever self heal because
|
||||||
|
it should not be drawn incorrectly in the first place. If you need 2
|
||||||
|
draws to get something into the correct position then that should
|
||||||
|
happen within the same frame. Layout should never be frame dependent,
|
||||||
|
it should be a pure function of the state." `Scroll::draw` was brought
|
||||||
|
to that rule the same day (it measures its content and places it
|
||||||
|
again in the one frame, IRIS.md's entry). Done for `List` later the
|
||||||
|
same day: the walk outward from the anchor is now `List::lay_out`, and
|
||||||
|
`draw` runs it, asks `overscroll_gap` (a pure measurement, no painter
|
||||||
|
and no redraw handle) whether the layout landed off the end of the
|
||||||
|
content, and on a gap moves the anchor and runs the walk a second time
|
||||||
|
**inside the same frame**. `Painter::draw_again` had no other caller
|
||||||
|
and is gone with it, so there is now no "ask for a corrective frame"
|
||||||
|
mechanism in the framework at all. One further pass always settles it:
|
||||||
|
the gap is measured from the edges the walk actually placed, so moving
|
||||||
|
the anchor by it puts that edge exactly on the viewport's, and the
|
||||||
|
opposite end cannot open a new gap without the content being shorter
|
||||||
|
than the viewport, which `overscroll_gap` declines to touch. The extra
|
||||||
|
walk is paid only on an overscrolled frame and re-offers every row the
|
||||||
|
same box at a new offset, which `draw_inner` dispatches as an O(1)
|
||||||
|
move. Three tests draw no settling frame on purpose and fail without
|
||||||
|
the change: `fling_toward_the_start_stops_at_the_first_row`,
|
||||||
|
`scrolling_past_the_start_is_given_back_in_the_same_frame` (both in
|
||||||
|
`list.rs`) and `scrolling_past_the_first_row_settles_on_it` /
|
||||||
|
`scrolling_past_the_last_row_settles_on_it` (layer 1,
|
||||||
|
`transcript-fixture/tests/top_edge.rs`).
|
||||||
|
|
||||||
- [x] **`request_device` asked for compute-shader limits it never uses
|
- [x] **`request_device` asked for compute-shader limits it never uses
|
||||||
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
||||||
and `iris/src/default/render.rs`) requests desktop-tier compute limits
|
and `iris/src/default/render.rs`) requests desktop-tier compute limits
|
||||||
@@ -149,6 +178,342 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
confirming this was the whole story on real touch input rather than
|
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
|
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||||
calling it fully closed.
|
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
|
||||||
|
|
||||||
@@ -339,29 +704,41 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
`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
|
- [x] **Masks defined relative to each other. (Done: chaining
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
2026-09-07 in d507ae4, the multiply 2026-09-08.)** Built exactly
|
||||||
mask, the way the move chain references a parent offset. Today masks
|
beside the move chain, as this asked: `Mask::parent` is a slot index
|
||||||
are independent regions. Design it beside the move chain (same shape:
|
and the fragment stage walks it under the same bound the move chain
|
||||||
a parent index and a bounded walk in the shader); do it when a real
|
uses. Each step multiplies the referenced primitive's coverage into
|
||||||
widget needs it, not before.
|
the pixel's alpha, so a pixel inside two feathered corners is dimmed
|
||||||
|
by both — the "multiplies by something *and also* applies mask B" half.
|
||||||
|
The real widget that needed it was the transcript's code fence inside
|
||||||
|
the list. See docs/LAYOUT.md's "Masks with a shape".
|
||||||
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
||||||
rejected, letting a scroll update one float rather than positions:
|
rejected, letting a scroll update one float rather than positions:
|
||||||
input handling cares about most elements in a list, so absolute
|
input handling cares about most elements in a list, so absolute
|
||||||
@@ -374,6 +751,203 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
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. docs/RUST.md's P1b box has the
|
||||||
|
fuller account.
|
||||||
|
|
||||||
|
**No entry here is worked around any more** (Iris, 2026-09-08: "All of
|
||||||
|
those should be fixed. There should never be workaround code. Do the
|
||||||
|
same for those; fix them if they're trivial, diagnose and report if
|
||||||
|
not."). Two are fixed and ticked; the two that are left are missing
|
||||||
|
*capabilities* rather than defects being dodged, and each carries its
|
||||||
|
diagnosis and what building it actually costs.
|
||||||
|
|
||||||
|
- [x] **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 was: `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 used. Bisected at the
|
||||||
|
time: removing the inner `Span` fixed it, and so did removing the
|
||||||
|
children's own `Pad`; the background `Stack`, the `Sized` wrappers and
|
||||||
|
the `WidgetPtr` per child made no difference. **Not** the `mov`-vs-
|
||||||
|
`reposition` fault f5b8893 fixed -- it survived that commit.
|
||||||
|
**Not reproducible on 2026-09-08.** Both spans are nested again and the
|
||||||
|
group has its 4dp inset back; that same headless render puts every
|
||||||
|
card's content in its own box, and `iris`'s
|
||||||
|
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`
|
||||||
|
(`layout_tests.rs`, the same shape inside a `List`, which is the
|
||||||
|
context the real one is in) pins it at layer 1. Something between
|
||||||
|
09-06 and 09-08 fixed it -- most likely the nested-mask pass or the
|
||||||
|
`mov` work after f5b8893. Left ticked with the original symptom
|
||||||
|
recorded rather than deleted, in case it comes back.
|
||||||
|
- [x] **`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.
|
||||||
|
**Not reproducible on 2026-09-08**: `raw_block` was changed to
|
||||||
|
`.scrollable_on(Axis::X).pad(..).masked_by(..)` and the command draws
|
||||||
|
normally (`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot`,
|
||||||
|
the `rm -rf target` card). Something between 09-06 and 09-08 fixed it --
|
||||||
|
the shaped-mask work (`.masked_by`, 38bf630) is the likeliest, since the
|
||||||
|
old chain was `.masked()` *inside* the padding. Left ticked with the
|
||||||
|
original symptom recorded rather than deleted, in case it comes back.
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
|
**Diagnosed 2026-09-08, and it is not trivial.** parley has no
|
||||||
|
ellipsis of its own (checked: nothing in the vendored crates), so iris
|
||||||
|
would build it, and the shape that looks easy is the one that breaks
|
||||||
|
something. The easy half really is easy: shape at
|
||||||
|
`max_advance = width - ellipsis_advance` with wrapping on, take line
|
||||||
|
0's `text_range()`, and re-shape `text[..end].trim_end() + "…"` with
|
||||||
|
wrapping off -- parley's own line breaker finds the cut, so nothing
|
||||||
|
here counts glyph advances by hand. The hard half is that
|
||||||
|
`TextBuffer` has exactly one string and everything addresses it by
|
||||||
|
byte offset: the inline spans that carry a fence's colours and a
|
||||||
|
link's range, `TextEditCtx::byte_at` (which turns a tap into a byte to
|
||||||
|
match a link against), `Selection`'s `select`/`selected_text`, and
|
||||||
|
`RowBlocks::apply_delta`. Truncating the buffer moves every one of
|
||||||
|
those. So the real work is giving `TextBuffer` a **displayed** string
|
||||||
|
distinct from its source, with one mapping from display byte to source
|
||||||
|
byte that all of those go through -- worth doing, and not a
|
||||||
|
by-the-way. Doing it only for text that is neither editable nor
|
||||||
|
selectable would avoid all of that and is exactly the kind of
|
||||||
|
exemption that comes back later.
|
||||||
|
|
||||||
|
It also wants an API change while it is open: `TextAttrs::wrap: bool`
|
||||||
|
cannot say three states. Something like `Overflow::{Wrap, Clip,
|
||||||
|
Ellipsis(End)}` replaces it, with `End::{Head, Tail}` making
|
||||||
|
UI_RULES's "choose which end to truncate" a thing a caller must
|
||||||
|
answer rather than a default nobody reads.
|
||||||
|
- [x] **A chevron the platform cannot fail to have.** **Done
|
||||||
|
2026-09-08**, twice. First as `iris::widget::mark(dir, dp, colour)`,
|
||||||
|
which rasterised an antialiased triangle into the ordinary texture path
|
||||||
|
-- correct, but one bespoke shape, and it built a texture *per widget*,
|
||||||
|
which is what crashed the bench (RUST.md's 2026-09-08 evening entry).
|
||||||
|
Then, on Iris's question -- "why does mark exist? The font should be
|
||||||
|
working if it's working for compose and nerd fonts are bundled" -- as
|
||||||
|
what the Compose app has always done: **iris ships its own Nerd Fonts
|
||||||
|
subset** (`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/
|
||||||
|
nerd_icons.ttf`, 992 bytes, three Material Design glyphs), named in
|
||||||
|
`iris::icon` and drawn with `Family::Icons`. `mark` is deleted. That
|
||||||
|
serves every future icon rather than one triangle, and an icon is text,
|
||||||
|
so it takes the size, colour and baseline of the line it sits in for
|
||||||
|
free. The original entry, for the record: *the bundled fonts were
|
||||||
|
removed on 2026-09-07 in favour of the platform collection, so the mark
|
||||||
|
is a codepoint the phone's own faces may not have -- Iris's 2026-09-08
|
||||||
|
screenshot shows an empty box where it should be, and the desktop render
|
||||||
|
draws it as a small dot. UI_RULES: "don't rely on characters the
|
||||||
|
platform might not have."*
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
|
**Diagnosed 2026-09-08: mechanical, but more than a sitting.** There
|
||||||
|
is no key collision to design around, which was the open question:
|
||||||
|
a `TranscriptRow::Tools` has *only* cards and no markdown blocks at
|
||||||
|
all, so a card is free to number its own texts from 0 in reading
|
||||||
|
order. What it costs is the registration lifecycle rather than the
|
||||||
|
key. Each card's `TextEdit`s have to `Selection::register` as they are
|
||||||
|
built and `unregister` when they are not -- and a card is rebuilt from
|
||||||
|
several directions (`redraw_card` when a result arrives,
|
||||||
|
`Shared::set_content` when the group is toggled or a call joins the
|
||||||
|
run, and the per-card `WidgetPtr` swap), each of which frees widgets
|
||||||
|
the map would otherwise still point at. That is the exact shape of the
|
||||||
|
crash `Selection::clear`'s doc records from
|
||||||
|
docs/REVIEW-2026-09-06.md: a handle in that map outliving the widget
|
||||||
|
panics on the *next* long press, somewhere else entirely. So the work
|
||||||
|
is a per-card base index with a stride (and a `debug_assert` that a
|
||||||
|
card stays inside it), one register/unregister path that every rebuild
|
||||||
|
route goes through, and a test per route that a rebuilt card leaves no
|
||||||
|
stale handle behind.
|
||||||
|
|
||||||
|
## Warnings standing in the bench build (2026-09-08)
|
||||||
|
|
||||||
|
Seen while checking `cargo ndk -t arm64-v8a check -p iris-android-app
|
||||||
|
--features bench`, pre-existing rather than added by this pass, and left
|
||||||
|
rather than silenced because each is a decision:
|
||||||
|
|
||||||
|
- [ ] **`PlatformHandle::show_diagnostics_overlay` has no caller.** It
|
||||||
|
and the ~60 lines of `IrisView.showDiagnosticsOverlay` behind it are a
|
||||||
|
plain-`TextView` overlay with Copy and Close, drawn over whatever iris
|
||||||
|
is doing -- built so a report can be read *even if iris itself has
|
||||||
|
stopped drawing*, which is the one case the in-iris diagnostics pane
|
||||||
|
that replaced it cannot cover. So this is a live escape hatch nobody
|
||||||
|
calls, not dead code: deleting both halves clears the warning and
|
||||||
|
removes the fallback, and wiring it back to something is a product
|
||||||
|
decision (Iris has no `logcat` on her phone). Ask before doing either.
|
||||||
|
- [ ] **`unused dependency: tabs-ui`.** Already explained in
|
||||||
|
`iris/android-app/Cargo.toml`'s own comment at the `tabs-ui` line.
|
||||||
|
|
||||||
## 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
|
||||||
@@ -419,6 +993,31 @@ do not duplicate it there.
|
|||||||
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.
|
||||||
|
|
||||||
|
- [ ] **A `Stack` that chooses its mask the way it chooses its size
|
||||||
|
(Iris, 2026-09-08).** She asked whether `masked_by` deserves to exist:
|
||||||
|
"a method that just does 2 separate things you can already easily do
|
||||||
|
does not deserve to exist." For a square-cornered surface it is indeed
|
||||||
|
redundant -- `.background(rect(BAR_FILL)).masked()` was measured
|
||||||
|
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
|
||||||
|
size and density and the two are identical to the pixel. What the pair
|
||||||
|
cannot express is a clip that is not a box: `Painter::set_mask` writes
|
||||||
|
a `RectPrimitive::color(Color::NONE)` at the widget's own region, with
|
||||||
|
no radius, so `.background(rect(fill).radius(r)).masked()` draws a
|
||||||
|
rounded panel and then cuts its content square. Both other call sites
|
||||||
|
(`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why
|
||||||
|
the method stands for now.
|
||||||
|
Her suggestion for removing it properly: **`Stack` already names where
|
||||||
|
its size comes from (`StackSize::Child(n)`); let it name where its
|
||||||
|
*mask* comes from the same way.** Then `.background(x)` is the one way
|
||||||
|
to put a surface behind something, and clipping to that surface is a
|
||||||
|
property of the stack rather than a second wrapper -- `masked_by` goes,
|
||||||
|
and `Masked::shape` with it. Worth checking while designing it: what a
|
||||||
|
stack with no mask child means (today's behaviour), whether the mask
|
||||||
|
child must also have been *drawn* first (`set_mask_to_widget` requires
|
||||||
|
it, and `Stack` draws in order, so naming child 0 is safe and naming a
|
||||||
|
later one is not), and what happens when the named child is the same
|
||||||
|
one the size comes from.
|
||||||
|
|
||||||
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
## 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
|
- [x] **A third length kind beside relative and pixels, so display scales
|
||||||
@@ -450,3 +1049,232 @@ do not duplicate it there.
|
|||||||
and control sizes; the emulator at two densities and the phone draw the
|
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
|
same layout at the same physical size. After the bench setup is
|
||||||
finished, before P1 draws any new screen.
|
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)
|
||||||
|
|
||||||
|
- [x] **"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.
|
||||||
|
|
||||||
|
**Done, e922b73 + d507ae4.** Three causes, and the rule above is what
|
||||||
|
they are all fixed with (`List::intersects_viewport`).
|
||||||
|
`iris/transcript-fixture/tests/top_edge.rs` is the layer-1
|
||||||
|
reproduction -- the real screen under a bench-app-shaped header --
|
||||||
|
and each test was confirmed to fail on its own subject and no other.
|
||||||
|
|
||||||
|
1. *Drawn over the header*: **nothing was clipping the list at all**,
|
||||||
|
and a row straddling an edge is drawn in full, so the part above
|
||||||
|
the list was on screen. It could not be `.masked()` before, either:
|
||||||
|
`Painter::set_mask` aborted when an ancestor already had a mask,
|
||||||
|
and the list's own rows use `.masked()` (a code fence, a tool
|
||||||
|
card's title). So masks nest now -- `Mask::parent`, walked in the
|
||||||
|
fragment stage, chained rather than intersected on the CPU because
|
||||||
|
each mask moves with its own widget. `the_list_is_clipped_to_its_
|
||||||
|
own_box`.
|
||||||
|
2. *Rows already scrolled past still drawn*: the layout walk runs from
|
||||||
|
the anchor, `scroll` moves the anchor's offset and nothing else, so
|
||||||
|
panning leaves the anchor's row further and further outside the
|
||||||
|
viewport and **every row between it and the viewport was drawn,
|
||||||
|
every frame** -- measured at 64 rows for a 2012px viewport after 8
|
||||||
|
scrolls of 3000px. `place` skips a row whose known box does not
|
||||||
|
overlap, and `rehome_anchor` puts the anchor back on a visible row
|
||||||
|
each frame without moving anything drawn.
|
||||||
|
`rows_that_have_left_the_viewport_are_not_drawn`.
|
||||||
|
3. *The blank band*: not a culling rule at all -- the list could rest
|
||||||
|
**past its own first row** (`fling_toward_the_start_stops_at_the_
|
||||||
|
first_row` was leaving it 1398px below a 600px viewport, a blank
|
||||||
|
screen, and that test's own assertion could not see it).
|
||||||
|
the overscroll clamp gives the gap back. Both ends:
|
||||||
|
`scrolling_past_the_first_row_settles_on_it`,
|
||||||
|
`scrolling_past_the_last_row_settles_on_it`. This is also the first
|
||||||
|
item of the later report below.
|
||||||
|
|
||||||
|
What was suspected and is *not* what happened: the visible-range test
|
||||||
|
never compared a row's top against the viewport's top (there was no
|
||||||
|
culling test at all), and `03c6be8`'s header duplicate is untouched by
|
||||||
|
any of this -- it stays open. A row straddling the top edge is drawn
|
||||||
|
both before and after; the test that would catch that mistake
|
||||||
|
(`the_row_across_the_top_edge_is_drawn`) is in place, and fails if the
|
||||||
|
rule is written against the row's top instead of its bottom.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
|
||||||
|
|
||||||
|
- [x] **"You shouldn't be able to scroll below the bottom (or above
|
||||||
|
top)."** Done in e922b73, as a clamp in `List::draw` rather than as a
|
||||||
|
clamp inside the scroll setter: nothing at the moment of a `scroll`
|
||||||
|
call knows where the content ends (that is what walking the rows finds
|
||||||
|
out), so the correction is measured from the ends the layout walk
|
||||||
|
already placed and written to the anchor. In the app that lands in the
|
||||||
|
same frame -- a scrolled list is dirty, and `redraw_updates` drains
|
||||||
|
the mark the correction sets before the frame is submitted -- so
|
||||||
|
nothing displaced is displayed; only a full-tree redraw (a resize)
|
||||||
|
could show one frame of it. A fling that reaches an end already ends
|
||||||
|
there (`tick_fling`'s `hit_bound`), and now stops *on* the end rather
|
||||||
|
than wherever the spline's last step had put it. Layer-1 tests at both
|
||||||
|
ends, listed in the item above. The list's offset is not clamped to its content range while
|
||||||
|
dragging and/or flinging. Compose's `LazyColumn` never moves content
|
||||||
|
past its ends -- the overscroll *effect* on Android 12+ is a stretch
|
||||||
|
drawn over clamped content, not a displacement. Clamp the offset in
|
||||||
|
one place (`List`'s scroll setter, so drag, fling, page-in and
|
||||||
|
programmatic scroll all go through it) and end a fling that hits the
|
||||||
|
clamp. Test at layer 1: a drag past either end leaves the offset at
|
||||||
|
the end; a fling into the end stops there.
|
||||||
|
- [x] **"Flinging now actually works but is slower than Compose's
|
||||||
|
immediately after releasing the flick (the slow down seems
|
||||||
|
correct)."** Done; RUST.md's "The fling started too slow" has the
|
||||||
|
derivation and the table. On `flick-120hz.touch` the release velocity
|
||||||
|
goes from **12250px/s to 15250px/s**, and on an accelerating flick --
|
||||||
|
the shape a real finger makes, and what the recording is too short to
|
||||||
|
show -- from 1080 to 2445px/s. The curve was right; `VelocityTracker`
|
||||||
|
was averaging total motion over the sample span, which cannot tell an
|
||||||
|
accelerating flick from a steady drag.
|
||||||
|
**Two things the plan for this item had wrong, both found by reading
|
||||||
|
the sources rather than remembering them.** Compose's touch path is
|
||||||
|
**not** `Strategy.Impulse`: `scrollable`/`draggable` release through
|
||||||
|
the 2D `VelocityTracker`, which on Android is two
|
||||||
|
`VelocityTracker1D(strategy = Lsq2)` over absolute *positions* -- a
|
||||||
|
degree-2 least-squares fit, differentiated at the newest sample.
|
||||||
|
Impulse is reached only by `DifferentialVelocityTracker`, for mouse
|
||||||
|
wheel and trackpad. And there is **no minimum** fling velocity on that
|
||||||
|
path: `ViewConfiguration.minimumFlingVelocity`'s 50dp/s is used only by
|
||||||
|
`NestedScrollInteropConnection`, while `DefaultFlingBehavior` skips
|
||||||
|
`abs(v) <= 1f` to dodge a NaN from the spline. So iris ports Lsq2, caps
|
||||||
|
at 8000dp/s, and floors at 1px/s -- no 50dp/s threshold Compose does
|
||||||
|
not have. `iris/benches/velocity_reference.py` is the independent
|
||||||
|
transcription the checked-in numbers come from; the negative control
|
||||||
|
(reverting to the average) fails exactly the seven tests about the
|
||||||
|
estimator and none of the rest. The release log gains a debug
|
||||||
|
`iris drag release samples:` line so a flick reported from the phone can
|
||||||
|
be replayed at layer 1.
|
||||||
|
- [~] **Input-event and timing report from the phone.** Iris: "add
|
||||||
|
another button to copy input event info so that I can do some stuff
|
||||||
|
manually and then send the event log to you ... instrument a lot of
|
||||||
|
the code with timings so I can give you time reports through the
|
||||||
|
same button." **Built on the log ring, 2026-09-07** (docs/RUST.md's
|
||||||
|
own section): `iris::sense::log_input_event` (one line per platform
|
||||||
|
pointer sample -- Android's `MotionEvent`, historical samples inline;
|
||||||
|
winit's `WindowEvent`; the harness's `TouchScript` line) and
|
||||||
|
`iris::diagnostics::log_frame` (one line per frame: frame number,
|
||||||
|
the frame clock, time since the last input, layout/draw durations,
|
||||||
|
`redraw_all`/`redraw_updates`/neither, primitives on screen,
|
||||||
|
whether something is animating), both under
|
||||||
|
`iris::diagnostics::trace_enabled()`, off by default because the ring
|
||||||
|
is only 2000 lines / 256 KiB and both targets at 120Hz fill that in
|
||||||
|
seconds. `iris/benches/report_to_touch.py` turns a report's
|
||||||
|
`iris::input` lines back into a `.touch` file for layer 1/2 replay --
|
||||||
|
round-tripped in `iris/transcript-fixture/tests/
|
||||||
|
input_log_roundtrip.rs`. **Not wired to a button**: the Diagnostics
|
||||||
|
pane is `iris/android-app/src/bench_client.rs`, open under another
|
||||||
|
agent at the time this landed; `set_trace(bool)` is the whole surface
|
||||||
|
a control needs. `docs/REVIEW-2026-09-07.md`'s D1 (the ring already
|
||||||
|
drowned in per-frame `debug!` lines that predated this pass) is fixed
|
||||||
|
in the same change -- see RUST.md's section for which four call
|
||||||
|
sites.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-07, night (build 92985ba, ai-app-bench bf2088b)
|
||||||
|
|
||||||
|
Iris pasted a full Copy report (Mali-G715 Vulkan, 2.55, 120Hz). What it
|
||||||
|
showed, beyond her words:
|
||||||
|
|
||||||
|
- [x] **"Sometimes when I try to catch it while it's still moving
|
||||||
|
(particularly if I drag) then it fails to stop & snap to where finger
|
||||||
|
is." (done 2026-09-07, b87f5a5.)** Built as described below.
|
||||||
|
`DragArbiter::press_start` takes a `PressState` -- what the target
|
||||||
|
looked like at the moment the press landed -- rather than asking the
|
||||||
|
list later, because by then the fling has already been cancelled and
|
||||||
|
the answer is no. The defect layer 1 found doing it: one touch-down
|
||||||
|
reaches every sensor under the finger, so a block and the tool row
|
||||||
|
containing it deliver the same `PressStart` twice, and re-reading the
|
||||||
|
state on the second delivery turned every catch back into an ordinary
|
||||||
|
slop-waiting press. Tests in
|
||||||
|
`iris/transcript-fixture/tests/catch_a_fling.rs`, with
|
||||||
|
`the_same_small_drag_on_a_settled_list_moves_nothing` as the half the
|
||||||
|
change had no reason to touch. **Not yet confirmed from the phone.**
|
||||||
|
The original reading follows. The report's release lines show catches ending as
|
||||||
|
`v=-41`/`v=-274` pans, so the gesture *does* reach `Panning`, but the
|
||||||
|
content under the finger does not follow it while the fling is still
|
||||||
|
running and the slop has not been crossed. Compose: a down while
|
||||||
|
`isScrollInProgress` stops the fling *at the down* and starts the
|
||||||
|
drag immediately with no touch slop (`scrollable`'s
|
||||||
|
`startDragImmediately = isScrollInProgress`); the content is pinned to
|
||||||
|
the finger from the first sample. Port that: `PressStart` on a list
|
||||||
|
with a live fling ends the fling on that sample and enters `Panning`
|
||||||
|
without waiting for `DRAG_SLOP`; a release with no movement is then a
|
||||||
|
`Released(None)`, not a tap (Compose does not deliver a click either).
|
||||||
|
Layer-1 test on a flick followed by a down + small drag 150 ms later:
|
||||||
|
offset tracks the finger sample-for-sample from the down.
|
||||||
|
- [x] **"The copy report button seemed impossible to hit until I hit the
|
||||||
|
diagnostics one." (done 2026-09-07, b8ea723).** Not hit-testing: the
|
||||||
|
button logged `iris bench report: nothing to copy -- run the benchmark
|
||||||
|
first` six times and did nothing on screen. A control that silently
|
||||||
|
declines is the UI_RULES failure "a failure is reported where it
|
||||||
|
happened": `copy_report` now always copies something -- the
|
||||||
|
diagnostics pane's own text (with a first line saying no benchmark has
|
||||||
|
run) when nothing has run yet, or the last report otherwise -- and
|
||||||
|
never depends on another button having been pressed first.
|
||||||
|
- [x] **"The logs seem way too big to send in this message box, causes a
|
||||||
|
lot of lag." (done 2026-09-07, 7485d78 + b8ea723).** Two causes. (1)
|
||||||
|
The ring was 1339 lines of `naga::front` / `wgpu_core` / `jni` DEBUG
|
||||||
|
output with 4050 dropped: the ring logger accepted every crate at
|
||||||
|
Debug, and the trace gate (992c472) only covered iris's own lines.
|
||||||
|
`client_core::log_ring::ring_accepts` is the one filter now, applied at
|
||||||
|
the ring rather than per callsite: Debug/Trace only from `iris`/
|
||||||
|
`client_core` targets when tracing is on, Info and above from
|
||||||
|
everything else. (2) Copy report appended the whole ring; it now
|
||||||
|
appends `LogRing::tail_text(COPY_REPORT_TAIL_LINES)` (150, named at the
|
||||||
|
constant) with a first line saying how many older lines were left out
|
||||||
|
-- the full ring is still what the devlog provider hands Dev Updater.
|
||||||
|
- [x] Keyboard: the report shows `ime_bottom=891 ime_visible=true` then
|
||||||
|
back to 0 on the phone, so the insets now arrive with a height; the
|
||||||
|
push-up was not reported broken this time.
|
||||||
@@ -947,3 +947,170 @@ 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, built 2026-09-08)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### What was built (2026-09-08), and where it differs
|
||||||
|
|
||||||
|
The commands and the screenshot are in docs/RUST.md's queue entry. Four
|
||||||
|
places the code is narrower than the design above, each deliberate:
|
||||||
|
|
||||||
|
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
|
||||||
|
The referenced instance already carries its own `binding`, so a copy
|
||||||
|
of it in the mask is a second thing to keep in step; *alpha only* is
|
||||||
|
the only mode there is, so there is nothing to select. Both are a
|
||||||
|
field away if a second mode appears.
|
||||||
|
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
|
||||||
|
by name, rather than leaving the shader to read a `rects` entry that
|
||||||
|
is not there. A glyph would need a CPU-side alpha plane before the
|
||||||
|
hit test could agree with the shader, and a standalone image needs a
|
||||||
|
bind-group switch the fragment stage cannot make (`masks_layout`'s own
|
||||||
|
comment on why an image's bind group must not name the masks buffer).
|
||||||
|
So **the texture-mask pass condition is not met and no texture mask
|
||||||
|
exists** — the point of the reference design is that adding one is a
|
||||||
|
binding check and a sampled alpha, with no new shader path, and the
|
||||||
|
shader's `mask_coverage` already has the branch where it would go.
|
||||||
|
- **The shape is a primitive of its own, not always a drawn one.** A
|
||||||
|
plain `.masked()` writes an undrawn `RectPrimitive` at its region
|
||||||
|
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
|
||||||
|
box" and "clip to that widget's rounded background" are one mechanism
|
||||||
|
and square-cornered clipping did not become a special case.
|
||||||
|
`.masked_by(shape)` draws `shape` behind the content — in its own
|
||||||
|
layer, the way `Stack` puts a background under its content — and
|
||||||
|
clips to the first primitive it drew.
|
||||||
|
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
|
||||||
|
the only test in the workspace that needs an adapter. It lifts
|
||||||
|
`distance_from_rect` and `rounded_rect_coverage` out of
|
||||||
|
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
|
||||||
|
so the thing under test is the shader itself rather than a copy of it
|
||||||
|
that would be edited alongside.
|
||||||
|
|
||||||
|
## What a widget's *offered* box may and may not be (2026-09-08)
|
||||||
|
|
||||||
|
Two rules that were each true in one place and missing from a sibling,
|
||||||
|
found together by Iris's 2026-09-08 phone report.
|
||||||
|
|
||||||
|
**Padding works in whatever container it is placed in, and is an inset or
|
||||||
|
an outset depending on how tight that container's region is.** Iris's
|
||||||
|
own words, 2026-09-08: "padding should work no matter what container a
|
||||||
|
widget is placed in, and acts as both inset and outset depending on how
|
||||||
|
tight the parent region is." `Pad` offers its child the region it was
|
||||||
|
handed, inset on each side, and reports `used + padding` — so given a
|
||||||
|
generous box it insets the child inside it, and given a box already the
|
||||||
|
size of the content it reports a larger size and the parent grows. What
|
||||||
|
this rules out is any container that offers a padded child a box and then
|
||||||
|
ignores what it reported, and any caller that reshapes its tree to avoid
|
||||||
|
a `Pad` (which `transcript-ui/src/tool.rs` did until 2026-09-08, at the
|
||||||
|
cost of a tool group's 4dp inset).
|
||||||
|
|
||||||
|
**A widget offered a box it does not fit is drawn again at the box its
|
||||||
|
own reported size implies, in the same frame.** Not next frame. The
|
||||||
|
temptation to defer is real — `List::place` offers a row its *cached*
|
||||||
|
height precisely so that an unchanged row hits `draw_inner`'s cheap
|
||||||
|
skip-or-move path, and `Scroll` sizes its child region from last frame's
|
||||||
|
content length for the same reason. But a `Rect` fills whatever region it
|
||||||
|
is given (`Size::REST`, and `rect.rs`'s `is_size_independent` doc says
|
||||||
|
why it must), and `.background(rect(..))` is the ordinary way to style
|
||||||
|
anything — so a one-frame-stale box is a background drawn at the wrong
|
||||||
|
size while the text inside it is already right. On screen that is a tool
|
||||||
|
card that looks closed while its text is there and open while it is not.
|
||||||
|
A `reposition` is not the fix and cannot be: it writes an offset, never a
|
||||||
|
size.
|
||||||
|
|
||||||
|
The cost is bounded and worth stating, because it is what makes the rule
|
||||||
|
safe to apply everywhere: the second draw happens only on the frame a
|
||||||
|
widget's own size actually changes, which is a frame that was already
|
||||||
|
redrawing it. A widget whose reported size is a function of the box it
|
||||||
|
was *offered* would disagree every frame and redraw every frame — which
|
||||||
|
is why `List` requires content-sized rows, and has since long before
|
||||||
|
this.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
# Review, 2026-09-07 — `ba2afba..origin/rustify`
|
||||||
|
|
||||||
|
Read-only review of the day's 24 commits: the glyph-atlas fix, the fling
|
||||||
|
spline and Lsq2 velocity estimator, keyboard/IME insets and `targetSdk`,
|
||||||
|
historical touch samples and the input clock, list culling / clamp /
|
||||||
|
anchor re-homing, nested masks and `draw_again`, the headless harness +
|
||||||
|
`transcript-fixture` + `rig-input`, desktop density, the release profile,
|
||||||
|
platform fonts + the Android monospace patch, and the client-core log ring
|
||||||
|
with `POST /client-log`.
|
||||||
|
|
||||||
|
**Verified while reviewing** (working tree, which also carries three other
|
||||||
|
agents' uncommitted edits — `iris/src/sense.rs`, `iris/core/src/ui/render_state.rs`,
|
||||||
|
`iris/src/lib.rs`, `iris/core/src/orientation/axis.rs`, and an untracked
|
||||||
|
`iris/src/diagnostics.rs`): `cargo fmt --check` clean in `iris/`,
|
||||||
|
`client-core/` and `server/`; `cargo clippy --all-targets` clean in `iris/`
|
||||||
|
and `client-core/`; `cargo test --lib -p iris` 101 passed, `cargo test -p
|
||||||
|
transcript-fixture` 10 passed. The `iris` doctest target fails to link
|
||||||
|
(`extern location for iris_core does not exist`) — a stale build artefact,
|
||||||
|
not a code fault, but worth knowing before trusting `cargo test -p iris`
|
||||||
|
as a whole.
|
||||||
|
|
||||||
|
The work is unusually well documented and the two "a test that compared
|
||||||
|
the code with itself" findings the authors made themselves are real and
|
||||||
|
were fixed correctly. What follows is what is left.
|
||||||
|
|
||||||
|
Counts: **5 defects, 7 risks, 3 tests that cannot fail in the bug's
|
||||||
|
direction, 7 rule findings, 2 nits.**
|
||||||
|
|
||||||
|
## Fix pass, 2026-09-07 evening
|
||||||
|
|
||||||
|
Every finding below carries a **Status** line. In summary: **13 fixed**
|
||||||
|
(D1, D4, D5, R1, R5, R7, T1, T2, T3 and four of the rule findings and both
|
||||||
|
nits), **6 moot or deferred** (D2, D3, R3, R4 and two rule findings, all
|
||||||
|
of them in the phone-logging route that `06b8a1f` deleted or in files the
|
||||||
|
devlog agent held open), and **2 not done on purpose** (R2, which waits on
|
||||||
|
docs/LAYOUT.md's mask redesign, and R6, which needs Iris's own phone).
|
||||||
|
|
||||||
|
The commits are `2ec0fee` (D4), `7e79ec1` (D5), `551c013` (R1), `e10582a`
|
||||||
|
(T1-T3), `ff1d6ea` (R5, R7) and `a6a100e` (the rename and the nits). Each
|
||||||
|
fix that the rig can express carries a test, and each of those was
|
||||||
|
confirmed by breaking its subject on purpose -- the break is recorded
|
||||||
|
beside the assertion, so the next reader does not have to re-derive it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defects
|
||||||
|
|
||||||
|
### D1 — the app's own log ring is drowned by the same day's per-frame `debug!` lines, so the route built to get Iris's logs to her carries almost none of them
|
||||||
|
|
||||||
|
`iris/android-app/src/lib.rs:132` installs the ring at `LevelFilter::Debug`,
|
||||||
|
and `client-core/src/log_ring.rs:279` (`RingLogger::enabled`) returns
|
||||||
|
`true` unconditionally by design, so **every `log::debug!` in the process
|
||||||
|
lands in a 2000-line / 256 KiB ring**. In the same commit range that ring
|
||||||
|
became the only way a line reaches Iris, three ungated per-frame `debug!`
|
||||||
|
callsites are live:
|
||||||
|
|
||||||
|
- `iris/src/android/view.rs:446` and `:509` — two lines *per rendered frame*.
|
||||||
|
- `iris/src/widget/list.rs:576` — `iris fling tick:`, one line per fling tick.
|
||||||
|
- `iris/src/widget/text/mod.rs:81` — one per text shape (many per frame while rows compose).
|
||||||
|
|
||||||
|
**Failure scenario.** Iris flicks the transcript on a 120 Hz phone. That is
|
||||||
|
~240–360 debug lines a second; the ring's 2000-line bound is exhausted in
|
||||||
|
**under ten seconds**, so by the time she presses `Copy report` every
|
||||||
|
`log::info!` about what she was actually investigating has been evicted.
|
||||||
|
The uploader makes it worse: it sends at most the ring per 10 s wake
|
||||||
|
(2000 lines ≈ 200 lines/s) against ~350 lines/s produced, so it also runs
|
||||||
|
permanently behind and pushes tens of KB/s of frame spam over the tunnel.
|
||||||
|
|
||||||
|
Note that another agent has already built the right mechanism — the
|
||||||
|
untracked `iris/src/diagnostics.rs` has `set_trace`/`trace_enabled`, a
|
||||||
|
default-off gate, and its module doc states this exact problem in as many
|
||||||
|
words. It gates `iris::input`/`iris::frame`; it does **not** gate the four
|
||||||
|
callsites above.
|
||||||
|
|
||||||
|
*Fix*: put `List::tick_fling`'s line and `view.rs`'s two `render():` lines
|
||||||
|
behind `iris::diagnostics::trace_enabled()` (the mechanism that already
|
||||||
|
exists for exactly this), and/or record into the ring at `Info` while
|
||||||
|
leaving `android_logger` at `Debug`.
|
||||||
|
|
||||||
|
**Status:** fixed in `992c472` (verified 2026-09-07: all four callsites, plus `sense.rs`'s drag-release samples line, now sit behind `iris::diagnostics::trace_enabled`, and `input_log_roundtrip` proves both directions).
|
||||||
|
|
||||||
|
### D2 — `POST /client-log` can make `ai-server` write an unbounded runtime log at an authenticated client's request
|
||||||
|
|
||||||
|
`server/src/routes.rs:1473` bounds the **line count** (500) and nothing
|
||||||
|
else. The route sits inside the router that applies
|
||||||
|
`DefaultBodyLimit::max(32 * 1024 * 1024)` at `server/src/routes.rs:179`
|
||||||
|
(raised for phone photos), so one request may carry 500 lines of ~64 KiB
|
||||||
|
each, and each is re-emitted verbatim into `tracing`. There is no
|
||||||
|
per-message cap on the server, no rate limit, and the runtime log
|
||||||
|
`ai-server` writes is the file Dev Updater tails and never rotates.
|
||||||
|
`MAX_MESSAGE_BYTES` (4096) exists only in the *client*
|
||||||
|
(`client-core/src/log_upload.rs:33`), i.e. the server trusts a value the
|
||||||
|
attacker controls.
|
||||||
|
|
||||||
|
**Failure scenario.** A buggy client (a `log::debug!` in a loop is enough —
|
||||||
|
see D1) or one holding a leaked bearer token posts 32 MiB every 10 s; the
|
||||||
|
host's disk fills and every other component's log goes with it.
|
||||||
|
|
||||||
|
*Fix*: give the route its own `DefaultBodyLimit` (the attachments route at
|
||||||
|
`:175` is the precedent for a per-route limit) and truncate each `message`
|
||||||
|
server-side to the same 4096 bytes rather than assuming the client did.
|
||||||
|
|
||||||
|
**Status:** moot -- `POST /client-log` was deleted with the whole upload route (`06b8a1f`), the app hands its log to Dev Updater through an on-device ContentProvider instead. Nothing to bound.
|
||||||
|
|
||||||
|
### D3 — lines the ring drops before the uploader sends them vanish with nothing saying so
|
||||||
|
|
||||||
|
`LogRing::since` (`client-core/src/log_ring.rs:169`) filters `seq >= cursor`
|
||||||
|
and silently returns fewer lines when eviction has passed the cursor;
|
||||||
|
`LogUploader::flush_once` (`:94`) then advances to whatever came back.
|
||||||
|
`dropped` is counted (`log_ring.rs:109`) and shown in the *local*
|
||||||
|
diagnostics pane, but it is never put in the upload body, and
|
||||||
|
`ClientLogBody` has no field for it.
|
||||||
|
|
||||||
|
**Failure scenario.** The tunnel is down for two minutes; the ring wraps.
|
||||||
|
When it comes back, the server log jumps from `#812` to `#5106` with no
|
||||||
|
line saying anything was lost. This is precisely the "unknown state
|
||||||
|
sharing a value with the empty state" UI_RULES asks to design first, and
|
||||||
|
the module doc for `dropped` claims it is "reported rather than inferred"
|
||||||
|
— it is, but only on the half of the path nobody is reading.
|
||||||
|
|
||||||
|
*Fix*: carry `dropped` (or `firstSeq`) in the batch and have `client_log`
|
||||||
|
emit one `warn!` when the sequence is not contiguous with the last batch
|
||||||
|
from that `source`.
|
||||||
|
|
||||||
|
**Status:** moot -- `client-core/src/log_upload.rs` was deleted with the route (`06b8a1f`). Whatever the ContentProvider does about eviction is that design's question, not this one's.
|
||||||
|
|
||||||
|
### D4 — the input clock anchors on the first event's *own* time, so that event's historical samples are dated before the anchor: the ordering assert fires, and release silently collapses them onto one instant
|
||||||
|
|
||||||
|
`iris/src/android/view.rs:628` takes the anchor as
|
||||||
|
`(Instant::now(), event.event_time_nanos())` from the first `MotionEvent`
|
||||||
|
the view ever sees, and `at()` computes
|
||||||
|
`anchor_at + (sample_time - anchor_nanos).max(0)`. Historical samples of
|
||||||
|
that same event are by definition **earlier** than its own `event_time`.
|
||||||
|
|
||||||
|
**Failure scenario.** The first event this view receives is an
|
||||||
|
`ACTION_MOVE` (the `DOWN` was delivered to another view, or the view was
|
||||||
|
attached mid-gesture). Its historical samples are, say, 12 ms before
|
||||||
|
`anchor_nanos`; `at()` clamps all of them to `anchor_at`, so the tracker
|
||||||
|
receives three samples with identical timestamps, the Lsq2 fit is
|
||||||
|
degenerate, and the flick reads 0 px/s. In a debug build the
|
||||||
|
`debug_assert!(ht >= previous)` at `:653` fires first — but `previous`
|
||||||
|
starts at `anchor_nanos` (`:651`), which is a value from a *different*
|
||||||
|
event, so that assert is also the wrong comparison for the first sample of
|
||||||
|
every later event.
|
||||||
|
|
||||||
|
*Fix*: anchor on the earliest sample of the first event
|
||||||
|
(`historical_event_time_nanos(0)` when `history_size() > 0`, else
|
||||||
|
`event_time`), and seed `previous` from the previous event's last sample
|
||||||
|
rather than from the anchor.
|
||||||
|
|
||||||
|
**Status:** fixed in `2ec0fee`. The arithmetic moved into `sense::PointerClock`, which anchors at `now - (event_time - oldest_sample)` and carries the last sample seen *across* events, so the ordering assert compares against the previous event's last sample rather than the anchor. It lives in `sense` because `iris::android` is `cfg`'d out everywhere but the device: `sense_tests.rs`'s `the_first_events_batched_samples_are_dated_apart` reports `[0ns, 0ns, 0ns]` against the old anchoring.
|
||||||
|
|
||||||
|
### D5 — the "before" velocity quoted in four places is not what the reference script prints
|
||||||
|
|
||||||
|
`iris/benches/velocity_reference.py`, run today, prints **12250 px/s** for
|
||||||
|
`flick-120hz.touch`'s average and **12500 px/s** for "press and one move
|
||||||
|
frame". Four places say 11750 for both:
|
||||||
|
|
||||||
|
- `docs/RUST.md:900` (`flick-120hz.touch | 11750 px/s`)
|
||||||
|
- `docs/RUST.md:905` (`press + one move frame | 11750 px/s`)
|
||||||
|
- `docs/IRIS_TODO.md:1026`
|
||||||
|
- `iris/transcript-fixture/tests/phone_screen.rs:55`
|
||||||
|
|
||||||
|
`iris/src/sense.rs:1406` has the correct 12250, so the two halves of the
|
||||||
|
same change disagree. The file that carries the wrong number is the one
|
||||||
|
that says "every number below is printed by `velocity_reference.py` … do
|
||||||
|
not 'fix' one by running the Rust and copying what it said". One of the
|
||||||
|
two rows also being 11750 for a completely different sample set is the
|
||||||
|
tell.
|
||||||
|
|
||||||
|
*Fix*: replace 11750 with the script's own 12250 / 12500 in those four
|
||||||
|
places, or say which run produced 11750.
|
||||||
|
|
||||||
|
**Status:** fixed in `7e79ec1`. All four places now say 12250 / 12500, the 1.30x ratio becomes 1.24x, and RUST.md records where 11750 half came from (196 px over a 16.68 ms **60 Hz** frame rather than the recording's own 16 ms -- which explains the flick row and not the other one, so that one was copied).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
### R1 — every new invariant guard is a `debug_assert!`, and the phone runs release
|
||||||
|
|
||||||
|
The five guards added today —
|
||||||
|
`iris/src/widget/list.rs:1156` (a `List` must be inside a `.masked()`),
|
||||||
|
`:1218` (`extents` holds only on-screen rows),
|
||||||
|
`iris/src/android/view.rs:653` (historical sample ordering),
|
||||||
|
`iris/src/sense.rs:1076` (`poly_fit_least_squares` sample count), and
|
||||||
|
`iris/core/src/ui/painter.rs`'s doubled-`set_mask` check — are all
|
||||||
|
`debug_assert!`. `docs/RUST.md` records that the bench APK **must** be
|
||||||
|
installed as `release` on the emulator (the debug `libmain.so` is 325 MB
|
||||||
|
and will not install) and Iris's phone gets release too. So none of these
|
||||||
|
can fire on any build anybody actually runs; in release a `List` drawn
|
||||||
|
without a mask silently paints over its surroundings again — the exact
|
||||||
|
fault e922b73 was written to fix.
|
||||||
|
|
||||||
|
*Fix*: for the two that are cheap and once-per-draw (`is_masked`, the
|
||||||
|
extents check), consider a plain `assert!` or a one-shot `log::error!`, so
|
||||||
|
the guard survives into the build the defect was found in.
|
||||||
|
|
||||||
|
**Status:** fixed in `551c013`. `is_masked`, the `extents` check, `set_mask`'s doubled-call check, `Painter::glyphs`'s atlas generation and `List::fling`'s finiteness are `assert!`/`assert_eq!` now; `List::place`'s slot precondition, `poly_fit_least_squares`'s two, and `PointerClock::sample`'s ordering stay `debug_assert!` and say in a comment why. The layer-1 suites pass in `--release` as well as debug, which is what says the promoted ones do not fire on a real replayed flick.
|
||||||
|
|
||||||
|
### R2 — a straddling row is now invisible above the list and still tappable through the header
|
||||||
|
|
||||||
|
Masks are applied in the fragment shader
|
||||||
|
(`iris/core/src/render/shader.wgsl:203`); the CPU hit path
|
||||||
|
(`UiRenderState::resolved_region`, `iris/core/src/ui/render_state.rs:709`)
|
||||||
|
does not consult `masks` at all. Before today the top of a straddling row
|
||||||
|
was drawn over the header *and* hit-testable there; now it is clipped away
|
||||||
|
but still hit-testable, which is worse — a tap on "Run benchmark" can land
|
||||||
|
on an invisible link in the row behind it. `docs/LAYOUT.md:1012` ("Hit-
|
||||||
|
testing applies the shape") is design, not code.
|
||||||
|
|
||||||
|
*Fix*: until LAYOUT.md's mask redesign lands, intersect a widget's hit
|
||||||
|
region with its mask chain in `resolved_region`; the chain walk already
|
||||||
|
exists on the GPU side.
|
||||||
|
|
||||||
|
**Status:** not done, deliberately -- docs/LAYOUT.md's mask redesign ("masks reference a drawn primitive instead of copying a shape", `1121d7c`) is where hit-testing gets the shape, and intersecting a chain in `resolved_region` now would be a second mechanism to unpick. Pointer left here rather than a fix.
|
||||||
|
|
||||||
|
### R3 — three copies of one wire contract, none of them linked
|
||||||
|
|
||||||
|
`client-core/src/log_upload.rs:28` (`MAX_LINES_PER_BATCH = 500`) and
|
||||||
|
`server/src/routes.rs:1418` (`CLIENT_LOG_MAX_LINES = 500`) must agree, in
|
||||||
|
different crates, with only a comment saying so; the body itself is built
|
||||||
|
by hand with `serde_json::json!` on one side and parsed by a
|
||||||
|
`#[serde(deny_unknown_fields)]` struct on the other. This project already
|
||||||
|
has the mechanism for exactly this — `event-model`, a crate both `server`
|
||||||
|
and `client-core` depend on precisely so "the app hand-mirroring it" stops
|
||||||
|
happening (`server/Cargo.toml:16` says so).
|
||||||
|
|
||||||
|
**Failure scenario.** Somebody raises the client's batch to 1000. Every
|
||||||
|
upload now returns 400, the uploader retries the *same* batch from the same
|
||||||
|
cursor forever, and the only sign is one line in a diagnostics pane on a
|
||||||
|
phone.
|
||||||
|
|
||||||
|
*Fix*: move `ClientLogLine`/`ClientLogBody` and the batch constant into a
|
||||||
|
shared crate.
|
||||||
|
|
||||||
|
**Status:** moot -- both copies went with the route (`06b8a1f`). If a client/server contract comes back, `event-model` is still the answer.
|
||||||
|
|
||||||
|
### R4 — `build.rs` bakes in a CA it never asks Cargo to watch, and the bench build now has no rebuild trigger at all
|
||||||
|
|
||||||
|
`emit_log_config` (`iris/android-app/build.rs:92`) calls `read_pinned_ca()`
|
||||||
|
but emits only `rerun-if-env-changed` for `AI_APP_LOG_HOST/_PORT/_TOKEN` —
|
||||||
|
no `rerun-if-changed` for the CA *file*, and (because the bench build
|
||||||
|
returns at `:65`, before the transcript path's declarations) no
|
||||||
|
`rerun-if-env-changed=AI_APP_CA`/`XDG_CONFIG_HOME` either. Emitting any
|
||||||
|
`rerun-if-*` directive turns off Cargo's default "rerun when anything in
|
||||||
|
the package changes" heuristic, so the bench build lost the only trigger it
|
||||||
|
had.
|
||||||
|
|
||||||
|
**Failure scenario.** `~/.config/ai-app` is wiped (AGENTS.md calls this the
|
||||||
|
one-way door), `ai-server` mints a new CA, the APK is rebuilt — and
|
||||||
|
`build.rs` does not re-run, so the APK still pins the dead CA and every
|
||||||
|
upload fails with a TLS error nobody can attribute.
|
||||||
|
|
||||||
|
*Fix*: `println!("cargo:rerun-if-changed={}", ca_path.display())` inside
|
||||||
|
`read_pinned_ca`, and move the `AI_APP_CA`/`XDG_CONFIG_HOME` declarations
|
||||||
|
above the bench early-return.
|
||||||
|
|
||||||
|
**Status:** moot -- `iris/android-app/build.rs` was deleted (`06b8a1f`/`d8562d9`): the destination comes from the enrolment link now, so nothing is baked in at build time and there is nothing for Cargo to watch.
|
||||||
|
|
||||||
|
### R5 — desktop density is read once and never updated
|
||||||
|
|
||||||
|
`iris/src/default/mod.rs:254` reads `content_scale(window)` at startup and
|
||||||
|
sets it on both `rsc.ui.text.density` and `render`. `WindowEvent::
|
||||||
|
ScaleFactorChanged` is not handled, and `UiRenderer::resize` deliberately
|
||||||
|
no longer consults `scale_factor`. Dragging the window to a monitor with a
|
||||||
|
different scale leaves every `dp(...)` and every rasterised glyph at the
|
||||||
|
old density — the same class of disagreement the commit removed elsewhere.
|
||||||
|
It is invisible here (every display on this machine is 1.0), which is why
|
||||||
|
it needs writing down.
|
||||||
|
|
||||||
|
**Status:** fixed in `ff1d6ea`. `WindowEvent::ScaleFactorChanged` re-reads `content_scale` -- through that function, so `IRIS_SCALE` still pins `--phone`'s density instead of following the monitor -- and `UiRenderState::set_density` marks the tree for a full redraw when the value actually changes, since `Text::shape` keys its cache on `(attrs, width, density)`.
|
||||||
|
|
||||||
|
### R6 — removing the bundled fonts removed the guard for a fault that was found on the phone, and the check was run on the desktop
|
||||||
|
|
||||||
|
`iris/core/src/primitive/text.rs`'s `register_bundled_fonts` existed
|
||||||
|
because "bold spans on a real phone rendered as blank gaps of the correct
|
||||||
|
advance width" — the deleted doc says so. Its removal is Iris's own call
|
||||||
|
and is recorded properly in `docs/DECISIONS.md`, but the verification
|
||||||
|
recorded there is "checked with CJK + emoji **on desktop**", which is the
|
||||||
|
half that cannot fail: the fault was Android's font enumeration resolving
|
||||||
|
a weight/style. `iris/transcript-ui/src/tool.rs:110`'s comment is honest
|
||||||
|
that `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) are now "a bet"
|
||||||
|
that the platform monospace face has them — which is UI_RULES' "don't rely
|
||||||
|
on characters the platform might not have", stated and then accepted.
|
||||||
|
|
||||||
|
*Fix*: before the next phone build, look at a bold run and the three
|
||||||
|
chevrons on Iris's device specifically; the emulator's font set is not
|
||||||
|
evidence for hers.
|
||||||
|
|
||||||
|
**Status:** not done here -- it is a *look at it on Iris's phone* item, and no build in this VM is evidence about her device's font set. Carried forward as the review said: before the next phone build, look at a bold run and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on her device specifically.
|
||||||
|
|
||||||
|
### R7 — the least-squares fit clamps a degenerate norm instead of detecting it
|
||||||
|
|
||||||
|
`iris/src/sense.rs:1105`: `1.0 / dot(...).sqrt().max(1e-6)`. Compose's
|
||||||
|
`polyFitLeastSquares` treats `norm < 1e-6` as "vectors are linearly
|
||||||
|
dependent, no solution" and bails; clamping instead produces a `q` row of
|
||||||
|
zeros, a zero on `r`'s diagonal, and a `0/0` that the `is_finite` check at
|
||||||
|
`:1059` happens to catch. It works, but it works by accident and the escape
|
||||||
|
is not the one the source it is transcribed from takes.
|
||||||
|
|
||||||
|
**Status:** fixed in `ff1d6ea`. `poly_fit_least_squares` returns `Option` and bails at `DEGENERATE_NORM` (Compose's `0.000001f`) instead of clamping; `velocity()` answers 0 on `None`. `a_fit_through_linearly_dependent_points_has_no_solution` reports `Some([NaN, NaN, NaN])` with the clamp back in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests that cannot fail in the direction the bug would go
|
||||||
|
|
||||||
|
### T1 — `iris/transcript-fixture/tests/phone_screen.rs:64` computes the expected fling duration with the calculator under test, and asserts it one-sidedly
|
||||||
|
|
||||||
|
`let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);` then
|
||||||
|
`assert!(ran_for <= expected + 2 frames)`. This is the same
|
||||||
|
"calculator compared with itself" shape the fling-spline commit
|
||||||
|
(73f956f) identified and fixed elsewhere, and the direction it can fail in
|
||||||
|
is "the fling ran too long" — never "the fling stopped dead", which is
|
||||||
|
literally Iris's reported symptom. The companion
|
||||||
|
`assert_ne!(before, after)` passes on one pixel of travel. A fling that
|
||||||
|
settles on the first tick passes this test.
|
||||||
|
|
||||||
|
*Fix*: add a lower bound from `velocity_reference.py`'s number (a fling at
|
||||||
|
-15250 px/s at density 2.55 must run ≥ ~1.4 s and travel ≥ ~6000 px), not
|
||||||
|
from `FlingCalculator`.
|
||||||
|
|
||||||
|
**Status:** fixed in `e10582a`. Both bounds come from `fling_spline_reference.py`, which gained this case's own line (`density=2.55 v=15250.0: distance=11057.424px duration=2.0716s`), and travel is measured in pixels from a row's own on-screen extent (10527px measured). Scaling `tick_fling`'s elapsed by 1000 reports "stopped after 8ms"; scaling its delta by 0.01 reports "travelled 111px".
|
||||||
|
|
||||||
|
### T2 — `top_edge.rs:150` checks a row *count* on the leg where the culling bug appeared, and the box only on the other leg
|
||||||
|
|
||||||
|
`rows_that_have_left_the_viewport_are_not_drawn` asserts `rows.len() <= 24`
|
||||||
|
on the outbound leg and the per-row `inside the box` predicate only on the
|
||||||
|
return leg. The doc explains why (an unmeasured row must be drawn to be
|
||||||
|
measured), which is correct — but it means the test's name is only true of
|
||||||
|
half of it, and a regression that draws 20 rows in the wrong *place* on the
|
||||||
|
outbound leg passes.
|
||||||
|
|
||||||
|
**Status:** fixed in `e10582a`. The first leg still cannot assert the box (an unmeasured row has to be drawn to be measured), so there is a third leg -- back again, every height known. Widening `intersects_viewport` downwards passes all 40 forward steps and fails at "back 6".
|
||||||
|
|
||||||
|
### T3 — `top_edge.rs:116` checks that a mask exists and where it is, not that it reaches anything
|
||||||
|
|
||||||
|
`the_list_is_clipped_to_its_own_box` asserts `active.mask != MaskIdx::NONE`
|
||||||
|
and that the mask's region lies within the list's box. It never checks the
|
||||||
|
row primitives actually reference that mask, so a broken `Mask::parent`
|
||||||
|
chain — the thing d507ae4 introduced — would leave this green while a code
|
||||||
|
fence inside a row drew unclipped again.
|
||||||
|
|
||||||
|
*Fix*: assert that a row primitive's mask chain contains the list's mask
|
||||||
|
slot.
|
||||||
|
|
||||||
|
**Status:** fixed in `e10582a`. It walks every row primitive's mask chain and requires the list's own slot on it, and rejects a chain that loops. Forcing `Painter::set_mask`'s `parent` to `NONE` fails it with "clips to [Id(1)], a chain that never reaches the list's own mask Id(0)".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **`iris/src/widget/list.rs:576` is a second mechanism for per-frame
|
||||||
|
instrumentation.** `iris::diagnostics::trace_enabled` exists for exactly
|
||||||
|
"a default-off `debug!` in a hot path" and this line does not use it.
|
||||||
|
(Cause of D1; the gate is in the untracked `diagnostics.rs`, so at the
|
||||||
|
reviewed commit the line is simply ungated.)
|
||||||
|
- **`server/src/routes.rs:1518` (`client_log_time`) duplicates
|
||||||
|
`client-core/src/log_ring.rs:76` (`clock_time`)** — the same arithmetic
|
||||||
|
written twice in two crates, with a comment noting they must agree. Same
|
||||||
|
shared-crate answer as R3.
|
||||||
|
- **`client-core/src/log_ring.rs:301`'s doc claims more than the code
|
||||||
|
delivers**: "the caller is named in the error so it is findable" —
|
||||||
|
`log::SetLoggerError` names nobody. `iris/android-app/src/app_log.rs:44`
|
||||||
|
repeats the claim.
|
||||||
|
- **Stale comment: `iris/src/android/view.rs:624`** cites
|
||||||
|
`VelocityTracker::add_sample`'s debug assert; the method was renamed to
|
||||||
|
`add_position` in the same commit range.
|
||||||
|
- **`MOVE_CHAIN_LIMIT` now bounds two different chains** (move offsets and
|
||||||
|
masks) under a name that says one, in both
|
||||||
|
`iris/core/src/ui/render_state.rs:63` and `shader.wgsl:97`. The shader's
|
||||||
|
comment already calls it "the bound on the parent walk"; the constant
|
||||||
|
should say that too, or masks should get their own.
|
||||||
|
- **`iris/src/sense.rs:1434`'s stated negative control is not reproducible
|
||||||
|
as written.** "Reverting `velocity` to `total / span` fails exactly this
|
||||||
|
one, the flick recording, and `phone_screen.rs`" — but `samples` now
|
||||||
|
holds *positions*, so `total / span` over them gives 2750 for the steady
|
||||||
|
drag too, and the commit message for the same change says "exactly seven
|
||||||
|
tests". Two numbers for one experiment.
|
||||||
|
- **`iris/android-app/src/bench_client.rs:393`'s `ime_visible` is right and
|
||||||
|
its sibling one line up is not.** `set_bottom_inset(rsc,
|
||||||
|
insets.bottom.max(insets.ime_bottom))` still infers "make room" from a
|
||||||
|
`max`, so during the slide-in the composer is padded by the system-bar
|
||||||
|
inset while `ime_visible` already says the keyboard is up. Harmless
|
||||||
|
today; it is the same conflation the comment beside it warns about.
|
||||||
|
|
||||||
|
**Status of the rule findings, 2026-09-07 evening.**
|
||||||
|
|
||||||
|
- `list.rs:576`'s ungated per-frame line -- **fixed in `992c472`** with
|
||||||
|
the rest of D1.
|
||||||
|
- `routes.rs:1518`'s `client_log_time` duplicating `log_ring.rs`'s
|
||||||
|
`clock_time` -- **moot**: the route was deleted (`06b8a1f`).
|
||||||
|
- `log_ring.rs:301`'s "the caller is named in the error" -- **deferred to
|
||||||
|
the devlog agent**; `client-core/src/log_ring.rs` is its file this pass,
|
||||||
|
and `app_log.rs` no longer repeats the claim.
|
||||||
|
- `view.rs:624`'s stale `VelocityTracker::add_sample` -- **fixed in
|
||||||
|
`2ec0fee`**; the paragraph was rewritten for the anchoring change and
|
||||||
|
now names `PointerClock` rather than a method that no longer exists.
|
||||||
|
- `MOVE_CHAIN_LIMIT` naming two chains -- **fixed in `a6a100e`**: renamed
|
||||||
|
to `PARENT_CHAIN_LIMIT` in `render_state.rs` and `shader.wgsl` at once
|
||||||
|
(it had no other users), with the doc naming both chains it governs.
|
||||||
|
- `sense.rs:1434`'s unreproducible negative control -- **fixed in
|
||||||
|
`7e79ec1`**. Rerun with `velocity` reverted to `(newest - oldest) /
|
||||||
|
span`: seven fail in `-p iris` (the flick recording, the accelerating
|
||||||
|
flick, the horizon, the stopped finger, the minimum sample count, both
|
||||||
|
`drag_gesture` flick tests) plus `phone_screen.rs`'s flick. RUST.md's
|
||||||
|
"exactly seven" was right; the doc comment's "exactly this one, the
|
||||||
|
flick recording, and `phone_screen.rs`" was not, and now says the same
|
||||||
|
thing RUST.md does.
|
||||||
|
- `bench_client.rs:393`'s `set_bottom_inset(.., max(..))` -- **deferred to
|
||||||
|
the devlog agent**; `iris/android-app/**` was open under it this pass.
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- `iris/src/sense.rs:798` computes `self.velocity.velocity()` twice on a
|
||||||
|
release when `info` logging is on (once for the outcome, once for the
|
||||||
|
log line) — a full Lsq2 fit each.
|
||||||
|
- `iris/transcript-ui/src/selection.rs:303` calls `ui.ui_mut().animate(id)`
|
||||||
|
even when `fling()` bailed (`|v| <= 1.0`, or no anchor). Harmless — the
|
||||||
|
first `tick` unregisters — but it registers an animation that is known
|
||||||
|
not to exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status of the nits, both fixed in `a6a100e`.** `DragGesture`'s release
|
||||||
|
computes `velocity()` once into a local both the outcome and the
|
||||||
|
`iris drag release:` line read. `selection.rs`'s `animate(id)` is behind
|
||||||
|
`is_scrolling()`, which is the same answer `List::fling` itself reached --
|
||||||
|
and `phone_screen.rs`'s recorded flick still flings, which is the half
|
||||||
|
that says the guard did not turn a working release off.
|
||||||
|
|
||||||
|
## Commits reviewed
|
||||||
|
|
||||||
|
```
|
||||||
|
7e4e26a iris: resolve fontique's Android monospace generic family ourselves
|
||||||
|
84a13e8 iris: a fling starts at Compose's velocity, which is a curve fit and not an average
|
||||||
|
452c442 docs/RUST.md: queue -- logging landed; iris app enrolment ...
|
||||||
|
238057a docs: the phone-logging decision, how to use it, and two build-apk traps
|
||||||
|
896c93a iris: drop bundled Noto Sans, match Compose's platform-font fonts
|
||||||
|
690161e docs: the transcript's edges were three faults, and what the rig found
|
||||||
|
e922b73 iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
|
||||||
|
d507ae4 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
|
||||||
|
9ed01e2 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity ...
|
||||||
|
5be9f1b iris-android-app: keep the app's own log, put it in Copy report, upload it
|
||||||
|
977bdb9 client-core: the app's own log ring, and POST /client-log to get it off a phone
|
||||||
|
9cd1263 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
|
||||||
|
42af780 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
|
||||||
|
4274b8b Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
|
||||||
|
73f956f iris: the fling curve was the identity function, and the keyboard was a targetSdk
|
||||||
|
038f6a3 docs: the test rig's layers 1 and 2, with their commands and their limits
|
||||||
|
1121d7c docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape ...
|
||||||
|
232de0e iris: a phone-shaped desktop window, driven by the same touch recordings
|
||||||
|
e430880 docs: phone report 2026-09-07, rows at the transcript's top edge culled early ...
|
||||||
|
a999bd1 docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue
|
||||||
|
6840edf iris-android-app: the bench's fixture half comes from transcript-fixture
|
||||||
|
3332201 iris: a headless in-process harness, and the bench fixture as a shared crate
|
||||||
|
7f4ea7e docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range
|
||||||
|
591128e AGENTS.md: the phone app and the planned desktop app share widgets and styling
|
||||||
|
```
|
||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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.34"
|
||||||
|
|
||||||
# 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,18 +58,24 @@ 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: pins the
|
||||||
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so the
|
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one
|
||||||
# same build can be measured against SwiftShader's software Vulkan ICD (the
|
# build can be measured on either backend. A compile-time feature rather
|
||||||
# default) or virgl's GLES path, without a second env-var plumbing path that
|
# than an env var because nothing on this machine can hand an env var to an
|
||||||
# nothing on this machine can hand to an already-launched Android process
|
# already-launched Android process (there is no `am start` environment and
|
||||||
# (there is no `am start` environment and no system-property reader here to
|
# no system-property reader here to add one).
|
||||||
# add one). Android-only; `android/render.rs` is the only reader.
|
#
|
||||||
|
# **Not needed to get GLES in the emulator**, whatever the history here
|
||||||
|
# says: the emulator's guest has no hardware Vulkan at all, so an ordinary
|
||||||
|
# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the
|
||||||
|
# emulator gives a GPU app"). Keeping the emulator on the same binary the
|
||||||
|
# phone runs is the point. What this feature is still for is forcing GLES
|
||||||
|
# on a machine that *does* have Vulkan -- the desktop -- which is why
|
||||||
|
# `default/render.rs` reads it too:
|
||||||
|
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||||
|
# --features iris/force-gles
|
||||||
force-gles = []
|
force-gles = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -73,6 +84,9 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"]
|
|||||||
# package is fine -- cargo excludes dev-dependencies from the graph used
|
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||||
# to build the library itself, so this only matters for `--examples`.
|
# to build the library itself, so this only matters for `--examples`.
|
||||||
tabs-ui = { path = "tabs-ui" }
|
tabs-ui = { path = "tabs-ui" }
|
||||||
|
# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it
|
||||||
|
# reads back. wgpu and pollster are ordinary dependencies already.
|
||||||
|
bytemuck = { workspace = true }
|
||||||
|
|
||||||
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||||
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||||
@@ -83,25 +97,62 @@ 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]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
# Debug info is the reason a `cargo test --workspace` here was taking half
|
||||||
|
# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's
|
||||||
|
# default `debug = true`, linking this workspace's test binaries wrote
|
||||||
|
# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an
|
||||||
|
# **88 GB** `target/`. Eight test binaries each statically link the whole
|
||||||
|
# wgpu + naga + winit + parley graph, and at the default every one of them
|
||||||
|
# gets a full copy of that graph's DWARF written into it. On a btrfs at 83%
|
||||||
|
# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible,
|
||||||
|
# waiting on space reservation -- at about 20 MB/s between them, which is
|
||||||
|
# what "the tests are slow" actually was. Not CPU: the machine was 87% idle
|
||||||
|
# throughout.
|
||||||
|
#
|
||||||
|
# `line-tables-only` keeps what is actually read from a backtrace -- the
|
||||||
|
# file and line of every frame, which is what a panicking test prints and
|
||||||
|
# what gdb needs to name the frames of a segfault. What it gives up is
|
||||||
|
# inspecting variables in a debugger; when that is wanted, ask for it on
|
||||||
|
# the command line for that one run rather than paying for it on every
|
||||||
|
# build:
|
||||||
|
#
|
||||||
|
# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# The tests are what this is really for; `cargo test` uses `dev` for
|
||||||
|
# dependencies and `test` for the test targets themselves, so setting only
|
||||||
|
# `dev` leaves the eight big binaries at the default.
|
||||||
|
[profile.test]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
pollster = "0.4.0"
|
pollster = "1.0.1"
|
||||||
winit = "0.30.12"
|
winit = "0.30.13"
|
||||||
wgpu = "28.0.0"
|
wgpu = "30.0.1"
|
||||||
bytemuck = "1.23.1"
|
bytemuck = "1.25.2"
|
||||||
image = "0.25.6"
|
image = "0.25.10"
|
||||||
parley = "0.11.1"
|
parley = "0.11.1"
|
||||||
swash = "0.2.10"
|
swash = "0.2.10"
|
||||||
fxhash = "0.2.1"
|
fxhash = "0.2.1"
|
||||||
@@ -109,7 +160,7 @@ arboard = "3.6.1"
|
|||||||
accesskit = "0.25.0"
|
accesskit = "0.25.0"
|
||||||
iris-core = { path = "core" }
|
iris-core = { path = "core" }
|
||||||
iris-macro = { path = "macro" }
|
iris-macro = { path = "macro" }
|
||||||
tokio = "1.49.0"
|
tokio = "1.53.1"
|
||||||
# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block
|
# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block
|
||||||
# model, the same crate E2's uncommitted `e2-transcript` experiment used for
|
# model, the same crate E2's uncommitted `e2-transcript` experiment used for
|
||||||
# the identical job (RUST.md), rather than reimplementing a CommonMark
|
# the identical job (RUST.md), rather than reimplementing a CommonMark
|
||||||
|
|||||||
@@ -566,18 +566,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-vec"
|
name = "bit-vec"
|
||||||
version = "0.8.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit_field"
|
name = "bit_field"
|
||||||
@@ -606,12 +606,6 @@ dependencies = [
|
|||||||
"no_std_io2",
|
"no_std_io2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "block"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -621,6 +615,15 @@ dependencies = [
|
|||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -744,7 +747,10 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|||||||
name = "client-core"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -761,9 +767,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.12.0"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
|
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
@@ -834,16 +840,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-foundation"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -857,8 +853,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"core-graphics-types 0.1.3",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -870,18 +866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-graphics-types"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"core-foundation 0.10.1",
|
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1070,7 +1055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1387,9 +1372,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.16.0"
|
version = "0.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
|
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
@@ -1420,26 +1405,6 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gpu-descriptor"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"gpu-descriptor-types",
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gpu-descriptor-types"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -1505,12 +1470,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hexf-parse"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1773,6 +1732,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tabs-ui",
|
"tabs-ui",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"transcript-fixture",
|
||||||
"transcript-ui",
|
"transcript-ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1796,7 +1756,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2040,15 +2000,6 @@ dependencies = [
|
|||||||
"imgref",
|
"imgref",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "malloc_buf"
|
|
||||||
version = "0.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2083,21 +2034,6 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "metal"
|
|
||||||
version = "0.33.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"block",
|
|
||||||
"core-graphics-types 0.2.0",
|
|
||||||
"foreign-types",
|
|
||||||
"log",
|
|
||||||
"objc",
|
|
||||||
"paste",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2130,9 +2066,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135"
|
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
@@ -2141,11 +2077,11 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"codespan-reporting",
|
"codespan-reporting",
|
||||||
"half",
|
"half",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"hexf-parse",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"libm",
|
"libm",
|
||||||
"log",
|
"log",
|
||||||
|
"naga-types",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
@@ -2154,6 +2090,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "naga-types"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"indexmap",
|
||||||
|
"rustc-hash",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2303,15 +2251,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
|
||||||
dependencies = [
|
|
||||||
"malloc_buf",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -2344,13 +2283,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
"objc2-core-image",
|
"objc2-core-image",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2372,7 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2384,7 +2323,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2396,7 +2335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2431,10 +2370,10 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2443,7 +2382,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-contacts",
|
"objc2-contacts",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2472,7 +2411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -2506,7 +2445,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2519,11 +2458,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-metal"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2531,10 +2482,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-quartz-core"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-metal 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2554,7 +2519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
@@ -2562,7 +2527,7 @@ dependencies = [
|
|||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-link-presentation",
|
"objc2-link-presentation",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core 0.2.2",
|
||||||
"objc2-symbols",
|
"objc2-symbols",
|
||||||
"objc2-uniform-type-identifiers",
|
"objc2-uniform-type-identifiers",
|
||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
@@ -2574,7 +2539,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2586,7 +2551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2634,7 +2599,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2858,9 +2823,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pollster"
|
name = "pollster"
|
||||||
version = "0.4.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
@@ -3139,6 +3104,18 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "raw-window-metal"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -3305,7 +3282,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3569,9 +3546,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spirv"
|
name = "spirv"
|
||||||
version = "0.3.0+sdk-1.3.268.0"
|
version = "0.4.0+sdk-1.4.341.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
|
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
@@ -3661,7 +3638,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3863,6 +3840,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"
|
||||||
@@ -3899,7 +3887,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4252,9 +4240,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f"
|
checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
@@ -4262,7 +4250,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -4282,9 +4270,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core"
|
name = "wgpu-core"
|
||||||
version = "28.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9"
|
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
@@ -4293,10 +4281,11 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
|
"naga-types",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
@@ -4309,66 +4298,70 @@ dependencies = [
|
|||||||
"wgpu-core-deps-emscripten",
|
"wgpu-core-deps-emscripten",
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-apple"
|
name = "wgpu-core-deps-apple"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da"
|
checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-emscripten"
|
name = "wgpu-core-deps-emscripten"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79"
|
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae"
|
checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "28.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb"
|
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"ash",
|
"ash",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block",
|
"block2 0.6.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics-types 0.2.0",
|
|
||||||
"glow",
|
"glow",
|
||||||
"glutin_wgl_sys",
|
"glutin_wgl_sys",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"gpu-descriptor",
|
"hashbrown 0.17.1",
|
||||||
"hashbrown 0.16.1",
|
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"metal",
|
|
||||||
"naga",
|
"naga",
|
||||||
|
"naga-types",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"objc",
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-metal 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4377,26 +4370,44 @@ dependencies = [
|
|||||||
"profiling",
|
"profiling",
|
||||||
"range-alloc",
|
"range-alloc",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
"raw-window-metal",
|
||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"static_assertions",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
"wayland-sys",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core",
|
"windows-core",
|
||||||
|
"windows-result",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wgpu-naga-bridge"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
|
||||||
|
dependencies = [
|
||||||
|
"naga",
|
||||||
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-types"
|
name = "wgpu-types"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c"
|
checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"naga-types",
|
||||||
|
"raw-window-handle",
|
||||||
|
"static_assertions",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4406,7 +4417,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4760,12 +4771,12 @@ dependencies = [
|
|||||||
"android-activity",
|
"android-activity",
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop",
|
"calloop",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ crate-type = ["cdylib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
iris = { path = "../" }
|
iris = { path = "../" }
|
||||||
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
android_logger = "0.15.0"
|
android_logger = "0.15.1"
|
||||||
log = "0.4.28"
|
log = "0.4.34"
|
||||||
# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's
|
# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's
|
||||||
# Android integration) are mutually exclusive -- one `ActiveClient` type is
|
# Android integration) are mutually exclusive -- one `ActiveClient` type is
|
||||||
# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps
|
# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps
|
||||||
@@ -29,6 +29,10 @@ 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 }
|
||||||
@@ -38,7 +42,7 @@ serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
|||||||
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
||||||
# at 0.2.179) -- this makes it a direct dependency at the same version
|
# at 0.2.179) -- this makes it a direct dependency at the same version
|
||||||
# rather than a second, possibly-drifting resolution.
|
# rather than a second, possibly-drifting resolution.
|
||||||
libc = { version = "0.2.179", optional = true }
|
libc = { version = "0.2.189", optional = true }
|
||||||
# P0's bench build only: the scroll animation and the streaming phase are
|
# 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
|
# 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
|
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
|
||||||
@@ -65,10 +69,22 @@ force-gles = ["iris/force-gles"]
|
|||||||
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
# listed, which is how this crate's build command names both explicitly.
|
# listed, which is how this crate's build command names both explicitly.
|
||||||
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
|
bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
# Measured 2026-09-07 (docs/RUST.md's "APK size" subsection): together these
|
||||||
|
# take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the APK
|
||||||
|
# from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a release. `strip`
|
||||||
|
# also works around AGP's own stripReleaseDebugSymbols failing silently on
|
||||||
|
# this .so ("packaging them as they are"). `opt-level = "s"` over `"z"`:
|
||||||
|
# `z` measured another ~800 KB smaller but was not checked against iris's
|
||||||
|
# own frame-time bench, so it is not worth the unmeasured risk -- see the
|
||||||
|
# doc for the number and the follow-up this leaves.
|
||||||
|
strip = true
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = "s"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
@@ -13,8 +13,37 @@ 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,45 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- The enrollment link Dev Updater's Enroll button opens
|
||||||
|
(what `ai-server` mints), the same one the Compose app
|
||||||
|
in `app/` registers: which app answers it is the phone
|
||||||
|
owner's choice at the moment of the tap, and both being
|
||||||
|
offered is the intended behaviour rather than a clash.
|
||||||
|
BROWSABLE so a link tapped in another app reaches here,
|
||||||
|
and `android:host` so this app is not offered for every
|
||||||
|
aiapp:// URI a future route invents. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="aiapp" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<meta-data android:name="android.app.lib_name" android:value="main" />
|
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- This app's own recent log, for Dev Updater to read on the
|
||||||
|
phone. Iris runs these builds with no adb, and Android
|
||||||
|
forbids one app reading another's logcat, so this is the
|
||||||
|
only way a log::info! here reaches her. The shape is Dev
|
||||||
|
Updater's contract (its README.md, "An app's own log"), not
|
||||||
|
something invented for this app.
|
||||||
|
|
||||||
|
The authority carries ${applicationId}, so the bench package
|
||||||
|
and the ordinary one each get their own and neither can read
|
||||||
|
the other's log. Exported, because the whole point is
|
||||||
|
another app reading it, and guarded by a permission Dev
|
||||||
|
Updater declares at protectionLevel="normal" (a signature
|
||||||
|
permission is not available: the two apps are signed with
|
||||||
|
different locally generated keys). Read-only: insert,
|
||||||
|
update and delete throw. -->
|
||||||
|
<provider
|
||||||
|
android:name=".DevLogProvider"
|
||||||
|
android:authorities="${applicationId}.devlog"
|
||||||
|
android:exported="true"
|
||||||
|
android:readPermission="dev.updater.permission.READ_DEVLOG" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.UriMatcher;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This app's own recent log, exposed on the device.
|
||||||
|
*
|
||||||
|
* Iris runs these builds on a phone with no {@code adb}, and Android
|
||||||
|
* forbids one app reading another's {@code logcat} -- so nothing outside
|
||||||
|
* this process can recover what it wrote. The process already keeps a
|
||||||
|
* bounded copy of its log (Rust: {@code client_core::log_ring}); this
|
||||||
|
* hands it to Dev Updater, which is on the same phone, so it needs no
|
||||||
|
* tunnel, no token and no second enrolment.
|
||||||
|
*
|
||||||
|
* <p>The shape is <em>Dev Updater's contract</em>, not something invented
|
||||||
|
* here -- see that project's {@code README.md}, "An app's own log". Any
|
||||||
|
* app it delivers can implement the same and get the same Runtime tab.
|
||||||
|
* Two paths:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code lines?since=<seq>} -- every held line with a sequence at or
|
||||||
|
* after {@code since}, oldest first.
|
||||||
|
* <li>{@code status} -- one row: how many lines are held, how many the
|
||||||
|
* ring's own bound has dropped, and the newest sequence ({@code -1}
|
||||||
|
* for a log nothing has been written to, which is also how a reader
|
||||||
|
* notices this process restarted).
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Read-only: there is nothing here for anyone else to change, so the
|
||||||
|
* three writing methods throw rather than silently doing nothing.
|
||||||
|
*
|
||||||
|
* <p>The authority is {@code <applicationId>.devlog}, filled in from
|
||||||
|
* Gradle so the bench build and the ordinary one each get their own and
|
||||||
|
* neither can read the other's. Read access is guarded by
|
||||||
|
* {@code dev.updater.permission.READ_DEVLOG}, declared in the manifest.
|
||||||
|
*
|
||||||
|
* <p>No {@code notifyChange}: the ring is filled by a {@code log::Log}
|
||||||
|
* backend on whatever thread logged, and giving that a way to reach a
|
||||||
|
* provider would mean plumbing a callback through {@code client-core} for
|
||||||
|
* every platform. Dev Updater polls while its tab is open, which its
|
||||||
|
* contract says it does precisely so implementing this stays cheap.
|
||||||
|
*/
|
||||||
|
public final class DevLogProvider extends ContentProvider {
|
||||||
|
static {
|
||||||
|
// The provider is created before any activity, so it cannot rely
|
||||||
|
// on MainActivity's own load. Loading twice is a no-op.
|
||||||
|
System.loadLibrary("main");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches {@link #nativeLinesSince}'s flat answer. Both sides say it once. */
|
||||||
|
private static final int FIELDS_PER_LINE = 5;
|
||||||
|
|
||||||
|
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||||
|
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
|
||||||
|
|
||||||
|
private static final int LINES = 1;
|
||||||
|
private static final int STATUS = 2;
|
||||||
|
|
||||||
|
private UriMatcher matcher;
|
||||||
|
|
||||||
|
/** Every held line, {@link #FIELDS_PER_LINE} strings each, oldest first. */
|
||||||
|
private static native String[] nativeLinesSince(long since);
|
||||||
|
|
||||||
|
/** Three strings: held, dropped, newest sequence. */
|
||||||
|
private static native String[] nativeStatus();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the Rust side which authority this build registered under, so
|
||||||
|
* the diagnostics pane can name somewhere a reader can actually query
|
||||||
|
* -- and so "declared but never created" is a state it can say. Only
|
||||||
|
* the provider knows it was instantiated; Android creates one lazily.
|
||||||
|
*
|
||||||
|
* <p>The files directory goes with it because <em>this is usually the
|
||||||
|
* only thing running</em>: after the app has died, Dev Updater's query
|
||||||
|
* starts the process for the provider alone, with no activity, so
|
||||||
|
* {@code MainActivity.nativeSetFilesDir} is never called and the line
|
||||||
|
* the panic hook left on disk is never replayed into the ring. That is
|
||||||
|
* exactly the run whose log somebody wants.
|
||||||
|
*/
|
||||||
|
private static native void nativeReady(String authority, String filesDir);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
// The authority is not a constant here: it is derived from this
|
||||||
|
// build's applicationId, so the bench package and the ordinary one
|
||||||
|
// do not share one. Read back from the manifest rather than
|
||||||
|
// recomposed, so there is one answer to what it is.
|
||||||
|
String authority = getContext().getPackageName() + ".devlog";
|
||||||
|
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||||
|
matcher.addURI(authority, "lines", LINES);
|
||||||
|
matcher.addURI(authority, "status", STATUS);
|
||||||
|
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(
|
||||||
|
Uri uri,
|
||||||
|
String[] projection,
|
||||||
|
String selection,
|
||||||
|
String[] selectionArgs,
|
||||||
|
String sortOrder) {
|
||||||
|
switch (matcher.match(uri)) {
|
||||||
|
case LINES:
|
||||||
|
return lines(sinceOf(uri));
|
||||||
|
case STATUS:
|
||||||
|
return status();
|
||||||
|
default:
|
||||||
|
// Null rather than an exception: an unknown path is a
|
||||||
|
// reader asking for something this app does not have, and
|
||||||
|
// the contract's own answer for that is no cursor.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code ?since=} as a number, or 0 for a reader starting from the
|
||||||
|
* beginning. A value that is not a number is treated as 0 rather than
|
||||||
|
* refused -- what a caller wants from a malformed cursor is the log,
|
||||||
|
* not a stack trace about the query string.
|
||||||
|
*/
|
||||||
|
private static long sinceOf(Uri uri) {
|
||||||
|
String since = uri.getQueryParameter("since");
|
||||||
|
if (since == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(since);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor lines(long since) {
|
||||||
|
String[] fields = nativeLinesSince(since);
|
||||||
|
if (fields == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
|
||||||
|
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[at]),
|
||||||
|
Long.parseLong(fields[at + 1]),
|
||||||
|
fields[at + 2],
|
||||||
|
fields[at + 3],
|
||||||
|
fields[at + 4],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor status() {
|
||||||
|
String[] fields = nativeStatus();
|
||||||
|
if (fields == null || fields.length != STATUS_COLUMNS.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
|
||||||
|
});
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri) {
|
||||||
|
// A MIME type is for something meant to be handed to another app
|
||||||
|
// as data; these rows are read by one reader that knows the
|
||||||
|
// columns. Saying nothing is the honest answer, not a gap.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,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);
|
||||||
|
|
||||||
@@ -35,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
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package dev.iris.android.demo;
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
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
|
||||||
@@ -18,9 +22,28 @@ public final class MainActivity extends Activity {
|
|||||||
System.loadLibrary("main");
|
System.loadLibrary("main");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's private directory, where the Rust side keeps its enrollment
|
||||||
|
* (`src/enrollment.rs`). Handed over before the view is built, because
|
||||||
|
* the client the view creates reads the enrollment as it starts.
|
||||||
|
*/
|
||||||
|
private static native void nativeSetFilesDir(String path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's
|
||||||
|
* Enroll button opens it. Parsed and stored on the Rust side, which is
|
||||||
|
* where the enrollment lives for the desktop app too -- nothing about
|
||||||
|
* the link's format is known here.
|
||||||
|
*/
|
||||||
|
private static native void nativeEnroll(String uri);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle state) {
|
public void onCreate(Bundle state) {
|
||||||
super.onCreate(state);
|
super.onCreate(state);
|
||||||
|
// Before the view: creating it starts the Rust client, which asks
|
||||||
|
// straight away which server it is enrolled with.
|
||||||
|
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||||
|
handleEnrollmentIntent(getIntent());
|
||||||
IrisView view = new IrisView(this);
|
IrisView view = new IrisView(this);
|
||||||
view.setLayoutParams(new FrameLayout.LayoutParams(
|
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
@@ -31,36 +54,136 @@ 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) -> {
|
||||||
int left = insets.getSystemWindowInsetLeft();
|
sendInsets((IrisView) v, insets);
|
||||||
int top = insets.getSystemWindowInsetTop();
|
|
||||||
int right = insets.getSystemWindowInsetRight();
|
|
||||||
int bottom = insets.getSystemWindowInsetBottom();
|
|
||||||
// The manifest declares adjustResize (AGENTS.md: without it the
|
|
||||||
// keyboard pans the whole window instead of resizing it), and
|
|
||||||
// under adjustResize the window itself shrinks to make room for
|
|
||||||
// the keyboard -- which is exactly the condition under which
|
|
||||||
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
|
||||||
// measures how much of the window the keyboard overlaps, and
|
|
||||||
// resize already made that overlap zero by construction. That
|
|
||||||
// numeric inset is not a usable "is the keyboard open" signal
|
|
||||||
// here (found while root-causing why bench_client.rs's keyboard
|
|
||||||
// phase and auto-diagnostics never fired on the emulator despite
|
|
||||||
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
|
||||||
// survive adjustResize is the boolean isVisible() answer, set
|
|
||||||
// from the platform's own start/end of the transition over a
|
|
||||||
// different path than the inset amount -- the same fact
|
|
||||||
// AGENTS.md's "Things that have bitten" already names for the
|
|
||||||
// Compose side's identical trap. Passed through as a 0/1 stand-
|
|
||||||
// in for the ime_bottom pixel amount, since nothing on the Rust
|
|
||||||
// side reads it as a real pixel value -- only `> 0.0`.
|
|
||||||
int imeBottom = 0;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
|
||||||
&& insets.isVisible(WindowInsets.Type.ime())) {
|
|
||||||
imeBottom = 1;
|
|
||||||
}
|
|
||||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
|
||||||
return insets;
|
return insets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link that arrives while the activity is already up. `singleTop` is
|
||||||
|
* not set, so this is the resumed case only -- the fresh-launch case
|
||||||
|
* goes through `onCreate`'s `getIntent`. `setIntent` so a later
|
||||||
|
* `getIntent` reports the one actually being acted on rather than the
|
||||||
|
* one this activity started with.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
handleEnrollmentIntent(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands a VIEW intent's URI to the Rust side, which decides whether it
|
||||||
|
* is an enrollment link -- the scheme is checked here only so a launch
|
||||||
|
* intent (which carries no data) costs nothing.
|
||||||
|
*/
|
||||||
|
private static void handleEnrollmentIntent(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
nativeEnroll(data.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
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 imeVisible = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,19 +12,29 @@
|
|||||||
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||||
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||||
# x86_64 for this checkout's own AVD. --features defaults to
|
# x86_64 for this checkout's own AVD. --features defaults to
|
||||||
# "transcript-screen bench" -- deliberately *without* `force-gles`, unlike
|
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
|
||||||
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
|
# nothing should add it back for the emulator's sake.
|
||||||
# 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
|
# **The emulator does not need a GLES build, because it has no hardware
|
||||||
# iris's frame time goes") -- it was never meant to reach a real device,
|
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
|
||||||
# but this script's old default put it in every arm64 build regardless,
|
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
|
||||||
# so the P0 bench APK delivered to Iris's phone forced GLES there too.
|
# in software, its GLES is the host's real GPU through virgl, and iris's
|
||||||
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
|
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
|
||||||
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
|
# `Backends::GL` -- takes an ordinary build there by itself. So the
|
||||||
# run, and GLES is the backend the same box's own SwiftShader finding
|
# emulator and the phone run the *same binary* and differ only in what
|
||||||
# already flagged as the fragile one for this shader's storage buffers.
|
# that binary finds, which is the whole point: a build flag that changed
|
||||||
# Pass `--features "transcript-screen force-gles bench"` explicitly for
|
# the backend would mean the thing measured here is not the thing
|
||||||
# an emulator backend-isolation run; never for a build meant for a phone.
|
# shipped.
|
||||||
|
#
|
||||||
|
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
|
||||||
|
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
|
||||||
|
# frame time goes"), and the desktop is the better place to run it now
|
||||||
|
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
|
||||||
|
# to reach a real device, but this script's old default put it in every
|
||||||
|
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
|
||||||
|
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
|
||||||
|
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
|
||||||
|
# build meant for a phone.
|
||||||
set -eu
|
set -eu
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
@@ -52,11 +62,28 @@ if [ -z "$NDK_DIR" ]; then
|
|||||||
fi
|
fi
|
||||||
export ANDROID_NDK_HOME="$NDK_DIR"
|
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
|
||||||
|
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
|
||||||
|
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
|
||||||
|
# build that switches ABI packages the previous ABI: an `--abi x86_64`
|
||||||
|
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
|
||||||
|
# aborted at startup with `Could not get adapter!: NotFound {
|
||||||
|
# active_backends: VULKAN }` under libndk_translation -- which reads
|
||||||
|
# exactly like the phone's own Vulkan problem and is nothing of the kind.
|
||||||
|
# Scoped to the merge task's directory rather than all of `app/build`, so
|
||||||
|
# an ABI change costs the native merge and not the whole Gradle build.
|
||||||
|
rm -rf app/build/intermediates/merged_native_libs \
|
||||||
|
app/build/intermediates/stripped_native_libs \
|
||||||
|
app/build/intermediates/merged_jni_libs
|
||||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||||
else
|
else
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
GRADLE_TASK="assembleDebug"
|
GRADLE_TASK="assembleDebug"
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
// Only does anything under the `transcript-screen` feature (RUST.md's I5
|
|
||||||
// Android integration) -- the plain tabs build (I2/I4) needs none of this
|
|
||||||
// and stays untouched, same reasoning as the feature gate in Cargo.toml.
|
|
||||||
//
|
|
||||||
// Bakes the sandbox server's host, port, token and pinned CA in at build
|
|
||||||
// time, the same way `app/androidApp/build.gradle.kts`'s
|
|
||||||
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that
|
|
||||||
// file's comment for why reading the machine's own certificate at build
|
|
||||||
// time is the right trust boundary. This build additionally bakes the
|
|
||||||
// host/port/token, which the Compose app does not: that app enrolls at
|
|
||||||
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI
|
|
||||||
// (Keystore-sealed token storage, a QR/link scanner) is real, separate
|
|
||||||
// scope this integration does not need to build to answer RUST.md's
|
|
||||||
// question -- there is nothing here yet resembling `ServerConfig.kt`. So
|
|
||||||
// this is a **deliberate simplification for this rig only**: an APK built
|
|
||||||
// this way is good for exactly the emulator/server pair that built it, and
|
|
||||||
// must never be treated as a template for a real enrollment flow. Recorded
|
|
||||||
// in RUST.md's I5 box rather than left to be rediscovered.
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
|
||||||
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_PORT");
|
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_CA");
|
|
||||||
println!("cargo:rerun-if-env-changed=XDG_CONFIG_HOME");
|
|
||||||
|
|
||||||
let host = require_env(
|
|
||||||
"AI_APP_TRANSCRIPT_HOST",
|
|
||||||
"the sandbox server's host as the emulator reaches it, e.g. 10.0.2.2",
|
|
||||||
);
|
|
||||||
let port = require_env(
|
|
||||||
"AI_APP_TRANSCRIPT_PORT",
|
|
||||||
"the sandbox server's port -- app/ui-sandbox.sh's start banner prints it",
|
|
||||||
);
|
|
||||||
let token = require_env(
|
|
||||||
"AI_APP_TRANSCRIPT_TOKEN",
|
|
||||||
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
|
|
||||||
);
|
|
||||||
|
|
||||||
let ca_path = std::env::var_os("AI_APP_CA")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let home = std::env::var_os("HOME").expect("HOME must be set");
|
|
||||||
PathBuf::from(home).join(".config")
|
|
||||||
});
|
|
||||||
base.join("ai-app").join("certs").join("ca.pem")
|
|
||||||
});
|
|
||||||
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"no CA certificate at {} ({e}).\n\
|
|
||||||
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
|
|
||||||
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
|
|
||||||
against a different one.",
|
|
||||||
ca_path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let ca_pem = ca_pem.trim();
|
|
||||||
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
|
|
||||||
panic!("{} is not a PEM certificate.", ca_path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
|
||||||
let generated = format!(
|
|
||||||
"// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\
|
|
||||||
pub const HOST: &str = {host_lit:?};\n\
|
|
||||||
pub const PORT: u16 = {port};\n\
|
|
||||||
pub const TOKEN: &str = {token_lit:?};\n\
|
|
||||||
pub const CA_PEM: &str = {ca_lit:?};\n",
|
|
||||||
host = host,
|
|
||||||
port = port
|
|
||||||
.parse::<u16>()
|
|
||||||
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
|
|
||||||
ca = ca_path.display(),
|
|
||||||
host_lit = host,
|
|
||||||
token_lit = token,
|
|
||||||
ca_lit = ca_pem,
|
|
||||||
);
|
|
||||||
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_env(name: &str, what: &str) -> String {
|
|
||||||
std::env::var(name).unwrap_or_else(|_| {
|
|
||||||
panic!("{name} must be set to build the transcript-screen feature -- {what}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -46,14 +46,34 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
|
|||||||
|
|
||||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||||
|
|
||||||
|
# Which adapter drew, before any number is printed. The emulator is a GLES
|
||||||
|
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
|
||||||
|
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
|
||||||
|
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
|
||||||
|
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
|
||||||
|
# magnitude apart and are otherwise indistinguishable in this report, so
|
||||||
|
# the line is printed rather than left in logcat for somebody to think of.
|
||||||
|
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
|
||||||
|
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
|
||||||
|
if [ -n "$ADAPTER" ]; then
|
||||||
|
echo "run-bench.sh: $ADAPTER"
|
||||||
|
else
|
||||||
|
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
# Poll for the report line rather than a fixed sleep -- the run itself is
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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
|
i=0
|
||||||
while [ "$i" -lt 260 ]; do
|
while [ "$i" -lt 260 ]; do
|
||||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
|
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||||
if [ -n "$LINE" ]; then
|
if [ -n "$LINE" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -66,4 +86,4 @@ if [ -z "$LINE" ]; then
|
|||||||
fi
|
fi
|
||||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
# -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.
|
# 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 "iris bench report:"
|
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! The platform half of this app's logging: what
|
||||||
|
//! `client_core::log_ring` needs that only Android can supply, which is
|
||||||
|
//! `android_logger` as the logger to forward to and nothing else.
|
||||||
|
//!
|
||||||
|
//! Everything general -- the ring, its bounds, the `log::Log` backend --
|
||||||
|
//! is in `client-core`, shared with the desktop app (AGENTS.md's sharing
|
||||||
|
//! rule).
|
||||||
|
//!
|
||||||
|
//! **Why an app carries its own log at all**: Iris tests these builds on a
|
||||||
|
//! GrapheneOS phone with no `adb`, and Android forbids one app reading
|
||||||
|
//! another's `logcat`. Nothing outside this process can recover what it
|
||||||
|
//! wrote, so the process keeps a copy -- and hands it to Dev Updater on
|
||||||
|
//! the same phone through `devlog`'s `ContentProvider`. See
|
||||||
|
//! `docs/DECISIONS.md`, 2026-09-07.
|
||||||
|
|
||||||
|
use client_core::log_ring::{self, LogRing};
|
||||||
|
|
||||||
|
/// Installs the ring in front of `android_logger`, so `logcat` still sees
|
||||||
|
/// exactly what it saw before and the ring sees it too.
|
||||||
|
///
|
||||||
|
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
|
||||||
|
/// itself; the message says which caller, since two initialisation paths
|
||||||
|
/// is a programmer error rather than something to recover from.
|
||||||
|
pub fn install(max_level: log::LevelFilter) {
|
||||||
|
let inner = android_logger::AndroidLogger::new(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(max_level)
|
||||||
|
.with_tag("iris-android-app"),
|
||||||
|
);
|
||||||
|
if log_ring::install_process_logger(
|
||||||
|
Box::new(inner),
|
||||||
|
max_level,
|
||||||
|
iris::diagnostics::trace_enabled,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// Not a panic: a logger already installed means logging works,
|
||||||
|
// just without the ring, and taking the app down over a
|
||||||
|
// diagnostic would be worse than the diagnostic being missing.
|
||||||
|
// The line goes through whatever logger did win.
|
||||||
|
log::warn!("iris app log: a logger was already installed, so there is no ring");
|
||||||
|
}
|
||||||
|
install_panic_hook();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process's ring -- what `Copy report` appends, what the diagnostics
|
||||||
|
/// pane counts, and what `devlog`'s provider hands to Dev Updater.
|
||||||
|
pub fn ring() -> &'static LogRing {
|
||||||
|
log_ring::process_ring()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the bench build has a diagnostics pane to put this in; the
|
||||||
|
/// transcript build's screen is the app's own and has no room for a
|
||||||
|
/// readout. Gated rather than left dead so the build stays warning-clean.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
/// Two lines for the diagnostics pane: how much of this app's log is held,
|
||||||
|
/// and where it can be read from.
|
||||||
|
///
|
||||||
|
/// The second names the provider's authority rather than saying "logging
|
||||||
|
/// is on", so a screenshot of this pane is enough to tell whether the
|
||||||
|
/// contract is live and which package's log it is -- the bench build and
|
||||||
|
/// the ordinary one have different ones.
|
||||||
|
pub fn diagnostics_line() -> String {
|
||||||
|
let where_to_read = match crate::devlog::authority() {
|
||||||
|
Some(authority) => format!("devlog provider: content://{authority}"),
|
||||||
|
// Not "off": Android creates a provider lazily, so this is what
|
||||||
|
// "nobody has asked for it yet" looks like, and it is a different
|
||||||
|
// thing from a build that does not have one.
|
||||||
|
None => "devlog provider: declared, not created yet".to_string(),
|
||||||
|
};
|
||||||
|
format!("{}\n{where_to_read}", ring().summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the panic hook leaves its report, under the app's private
|
||||||
|
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
||||||
|
/// start.
|
||||||
|
const CRASH_FILE: &str = "last-panic.txt";
|
||||||
|
|
||||||
|
/// How many of the dying run's own log lines the panic hook saves with
|
||||||
|
/// the panic, and [`set_crash_dir`] replays.
|
||||||
|
///
|
||||||
|
/// The panic's message and location say *what* broke; these say what the
|
||||||
|
/// app was doing on the way there, which is the half that is otherwise
|
||||||
|
/// unrecoverable -- the ring is memory only, so an abort takes every line
|
||||||
|
/// before the panic with it. Bounded rather than the whole ring because
|
||||||
|
/// this is written by a hook on a process that is about to die, and
|
||||||
|
/// because the replay pushes each line into the new run's ring, where an
|
||||||
|
/// unbounded paste would evict the run that is actually being watched.
|
||||||
|
const CRASH_CONTEXT_LINES: usize = 80;
|
||||||
|
|
||||||
|
/// The target the replayed context lines carry, so a reader can tell a
|
||||||
|
/// line from the run that died from one this run wrote. They keep their
|
||||||
|
/// original timestamp and level inside the text, which is why the level
|
||||||
|
/// they are re-pushed at is not meaningful and the target has to be.
|
||||||
|
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||||
|
|
||||||
|
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Installs a `log`-level panic hook, so a panic's message and location
|
||||||
|
/// reach the ring and `logcat` rather than only the tombstone.
|
||||||
|
///
|
||||||
|
/// **Why this is needed at all**: these builds are `panic = "abort"`
|
||||||
|
/// (`Cargo.toml`), and the default hook writes to `stderr` plus
|
||||||
|
/// `android_set_abort_message` -- the crash report. Iris runs these on a
|
||||||
|
/// phone with no `adb`, so the crash report is exactly the surface she
|
||||||
|
/// cannot read, and an `assert!` that fired said nothing anywhere she
|
||||||
|
/// could see it. Routing it through `log::error!` puts it in front of
|
||||||
|
/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev
|
||||||
|
/// Updater.
|
||||||
|
///
|
||||||
|
/// The ring is memory only, so after an abort the process that holds it
|
||||||
|
/// is gone -- hence the file half. [`set_crash_dir`] replays it.
|
||||||
|
fn install_panic_hook() {
|
||||||
|
let previous = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let where_at = match info.location() {
|
||||||
|
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
|
||||||
|
None => "an unknown location".to_string(),
|
||||||
|
};
|
||||||
|
// `info`'s own `Display` repeats the location and a newline;
|
||||||
|
// the payload alone keeps this to the one line the ring wants.
|
||||||
|
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
|
||||||
|
let line = format!("iris panic at {where_at}: {message}");
|
||||||
|
log::error!("{line}");
|
||||||
|
if let Some(path) = CRASH_PATH.get() {
|
||||||
|
// The panic line first, then what the app was doing before
|
||||||
|
// it: one file, split again on that first newline by
|
||||||
|
// `set_crash_dir`.
|
||||||
|
let context = ring()
|
||||||
|
.try_tail_text(CRASH_CONTEXT_LINES)
|
||||||
|
// Said rather than left empty, so "the ring was locked as
|
||||||
|
// we died" cannot be read as "nothing had been logged".
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
"(the log ring was locked as this run died; no context)".to_string()
|
||||||
|
});
|
||||||
|
// Best effort by design: a panic is already the failure, and
|
||||||
|
// failing to record it must not become a second one.
|
||||||
|
let _ = std::fs::write(path, format!("{line}\n{context}"));
|
||||||
|
}
|
||||||
|
previous(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the panic hook where to leave its report, and replays the report
|
||||||
|
/// a previous run left there into the ring before deleting it.
|
||||||
|
///
|
||||||
|
/// Called from **both** `MainActivity.nativeSetFilesDir` and
|
||||||
|
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
|
||||||
|
/// this process, since after a crash Dev Updater's query starts the
|
||||||
|
/// process for the provider alone and no activity ever runs. Safe to call
|
||||||
|
/// twice: the file is gone after the first, so the second finds nothing
|
||||||
|
/// and says nothing. The panic itself is replayed at `error` level and
|
||||||
|
/// says it is from the previous run, so a crash loop shows the reason it
|
||||||
|
/// is looping in the Runtime tab of the run that is still up.
|
||||||
|
pub fn set_crash_dir(dir: &std::path::Path) {
|
||||||
|
let path = dir.join(CRASH_FILE);
|
||||||
|
if let Ok(previous) = std::fs::read_to_string(&path) {
|
||||||
|
// Delete before replaying rather than after: a replay that itself
|
||||||
|
// panicked would otherwise leave the file to be replayed again on
|
||||||
|
// every start, and a crash loop nothing can get out of is worse
|
||||||
|
// than one report lost.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
replay_crash(&previous);
|
||||||
|
}
|
||||||
|
let _ = CRASH_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a previous run's report back in the ring: its context lines in
|
||||||
|
/// the order they happened, then the panic itself.
|
||||||
|
///
|
||||||
|
/// Chronological, so the Runtime tab reads as one story -- the lines that
|
||||||
|
/// led to the crash, then the crash, then this run. The context goes in
|
||||||
|
/// through `LogRing::push` rather than through `log::info!` so it is not
|
||||||
|
/// stamped with this run's clock: each line already carries the time and
|
||||||
|
/// level it was written at, and [`PREVIOUS_RUN_TARGET`] is what says
|
||||||
|
/// whose run it was.
|
||||||
|
fn replay_crash(report: &str) {
|
||||||
|
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
|
||||||
|
for line in context.lines().filter(|line| !line.is_empty()) {
|
||||||
|
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
|
||||||
|
}
|
||||||
|
log::error!(
|
||||||
|
"iris app log: the previous run died -- {}",
|
||||||
|
panic_line.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,14 +6,11 @@
|
|||||||
//!
|
//!
|
||||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||||
//! network half replaced by the checked-in fixture, embedded with
|
//! network half replaced by the checked-in fixture. Reading that fixture
|
||||||
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
//! and folding it into a screen is **`transcript-fixture`'s** job, not
|
||||||
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
//! this file's -- the same crate the headless harness and the
|
||||||
//! a real transcript (that file's own README). The first 3,200 lines are
|
//! phone-shaped desktop window open, so all three measure one screen
|
||||||
//! the opening backlog, folded once through
|
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
|
||||||
//! `client_core::transcript_fold::fold_page` exactly as a real
|
|
||||||
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
|
||||||
//! same as any first load); the remaining ~400 are the streaming tail,
|
|
||||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||||
@@ -22,7 +19,7 @@
|
|||||||
|
|
||||||
use crate::bench_jni::PlatformHandle;
|
use crate::bench_jni::PlatformHandle;
|
||||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
use client_core::transcript_fold::{TranscriptItem, fold_event};
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -30,13 +27,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
|
|
||||||
/// the opening window; the rest are the streaming tail. Kept in sync with
|
|
||||||
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
|
|
||||||
/// checked-in file, so a mismatch would only mean the two apps' bench
|
|
||||||
/// builds open a different split of it, not a wrong-vs-right answer.
|
|
||||||
const BACKLOG_COUNT: usize = 3200;
|
|
||||||
|
|
||||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||||
/// implement the identical four phases -- see that box before changing any
|
/// implement the identical four phases -- see that box before changing any
|
||||||
/// constant here, since a mismatch would make the two reports stop
|
/// constant here, since a mismatch would make the two reports stop
|
||||||
@@ -82,15 +72,21 @@ const TYPE_CHAR_MS: u64 = 50;
|
|||||||
const KEYBOARD_CYCLES: usize = 5;
|
const KEYBOARD_CYCLES: usize = 5;
|
||||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||||
|
|
||||||
/// One animation step's target cadence -- close enough to 60Hz that a
|
/// How often this file *asks a question of* the running app -- polls for
|
||||||
/// fling/scroll is many small moves rather than one jump, so frames are
|
/// a `ctx.update` closure's answer, or for a fling to have settled.
|
||||||
/// actually rendered along the way, and close enough that a `ctx.update`
|
///
|
||||||
/// closure's effect (only applied once the next frame callback drains the
|
/// It is not an animation cadence and nothing on screen moves at this
|
||||||
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
|
/// rate: the frame loop advances animations once per frame at the
|
||||||
/// when a later step in the same phase needs to read state back.
|
/// display's own refresh (`UiData::tick_animations`). It used to be both,
|
||||||
const ANIM_STEP_MS: u64 = 16;
|
/// and that is the defect Iris reported on 2026-09-08 -- see
|
||||||
|
/// `wait_for_fling_settle`.
|
||||||
|
const POLL_MS: u64 = 16;
|
||||||
|
|
||||||
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
/// 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 {
|
pub struct BenchClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
@@ -152,31 +148,6 @@ impl HasAndroidUiState for BenchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the fixture once: `serde_json::Value`s for the backlog
|
|
||||||
/// (`fold_page` takes a page of raw wire JSON, same as a real
|
|
||||||
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
|
|
||||||
/// takes one live wire event at a time, same as a real SSE frame).
|
|
||||||
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
|
|
||||||
let lines: Vec<&str> = FIXTURE_JSONL
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim().is_empty())
|
|
||||||
.collect();
|
|
||||||
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
|
|
||||||
let mut stream_tail = Vec::new();
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
|
||||||
let value: serde_json::Value =
|
|
||||||
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
|
||||||
if i < BACKLOG_COUNT {
|
|
||||||
backlog.push(value);
|
|
||||||
} else {
|
|
||||||
let event: SeqEvent = serde_json::from_value(value)
|
|
||||||
.expect("bench fixture event matches event-model's SeqEvent");
|
|
||||||
stream_tail.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(backlog, stream_tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(Color::WHITE)
|
||||||
@@ -221,8 +192,15 @@ fn battery_line(samples: &[i32]) -> String {
|
|||||||
return " battery current: unavailable on this device".to_string();
|
return " battery current: unavailable on this device".to_string();
|
||||||
}
|
}
|
||||||
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
let min = samples.iter().min().unwrap();
|
// `min`/`max` are guarded by the `is_empty` check above, three lines
|
||||||
let max = samples.iter().max().unwrap();
|
// 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!(
|
format!(
|
||||||
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||||
samples.len()
|
samples.len()
|
||||||
@@ -248,10 +226,28 @@ impl AndroidAppState for BenchClient {
|
|||||||
let top_bar = WidgetPtr::new().add(rsc);
|
let top_bar = WidgetPtr::new().add(rsc);
|
||||||
let controls = bench_controls(rsc, 0.0);
|
let controls = bench_controls(rsc, 0.0);
|
||||||
top_bar(rsc).set(controls);
|
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 = (
|
let tree = (
|
||||||
top_bar,
|
top_bar,
|
||||||
content.height(rest(2)),
|
report_display
|
||||||
report_display.height(rest(1)).pad(dp(8)),
|
.pad(dp(8))
|
||||||
|
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||||
|
content.height(rest(1)),
|
||||||
)
|
)
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
@@ -265,7 +261,7 @@ impl AndroidAppState for BenchClient {
|
|||||||
let font = rsc.ui.text.font_diagnostics();
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
log::info!(
|
log::info!(
|
||||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||||
bold={:?} italic={:?} mono={:?}",
|
bold={:?} italic={:?} mono={:?}, icons={:?}",
|
||||||
font.families_found,
|
font.families_found,
|
||||||
font.default_family,
|
font.default_family,
|
||||||
font.default_mono_family,
|
font.default_mono_family,
|
||||||
@@ -273,6 +269,7 @@ impl AndroidAppState for BenchClient {
|
|||||||
font.bold_resolved,
|
font.bold_resolved,
|
||||||
font.italic_resolved,
|
font.italic_resolved,
|
||||||
font.mono_resolved,
|
font.mono_resolved,
|
||||||
|
font.icon_family,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut client = Self {
|
let mut client = Self {
|
||||||
@@ -291,12 +288,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
last_top_pad: 0.0,
|
last_top_pad: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (backlog, stream_tail) = parse_fixture();
|
match transcript_fixture::build_screen(rsc) {
|
||||||
client.stream_tail = stream_tail;
|
Ok((opened, tree)) => {
|
||||||
match fold_page(&backlog) {
|
client.items = opened.items;
|
||||||
Ok(items) => {
|
client.stream_tail = opened.stream_tail;
|
||||||
client.items = items;
|
(client.content)(rsc).set(tree);
|
||||||
client.rebuild_transcript(rsc);
|
client.screen = Some(opened.screen);
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
@@ -365,7 +362,24 @@ impl AndroidAppState for BenchClient {
|
|||||||
(self.top_bar)(rsc).set(controls);
|
(self.top_bar)(rsc).set(controls);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ime_visible = insets.ime_bottom > 0.0;
|
// 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();
|
let mut ime = self.ime_state.lock().unwrap();
|
||||||
if ime_visible && !ime.visible {
|
if ime_visible && !ime.visible {
|
||||||
@@ -404,6 +418,29 @@ const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
|||||||
|
|
||||||
type Rsc = AndroidRsc<BenchClient>;
|
type Rsc = AndroidRsc<BenchClient>;
|
||||||
|
|
||||||
|
/// What a report says about the `iris::input`/`iris::frame` trace, from
|
||||||
|
/// the flag read at the start of what is being reported and again at the
|
||||||
|
/// end.
|
||||||
|
///
|
||||||
|
/// Three answers rather than two. Those lines are default-off and the
|
||||||
|
/// switch that turns them on is on screen while a benchmark runs, so
|
||||||
|
/// "somebody moved it half way through" is a state that actually happens
|
||||||
|
/// -- and reported as either "on" or "off" it is a confident sentence
|
||||||
|
/// about a log that only covers part of the run. The "on" wording also
|
||||||
|
/// says what it costs, because a traced run fills the ring in seconds and
|
||||||
|
/// a reader looking at a log with nothing else in it should know why.
|
||||||
|
fn trace_line(at_start: bool, at_end: bool) -> String {
|
||||||
|
match (at_start, at_end) {
|
||||||
|
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
|
||||||
|
the app log, and a traced run fills the ring in seconds)"
|
||||||
|
.to_string(),
|
||||||
|
(false, false) => "input/frame trace: off".to_string(),
|
||||||
|
_ => "input/frame trace: switched during this run, so those lines cover only part \
|
||||||
|
of it"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
/// 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
|
/// 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
|
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||||
@@ -431,6 +468,25 @@ const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
|||||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||||
/// so the row's reserved height in the outer `Span::DOWN`
|
/// so the row's reserved height in the outer `Span::DOWN`
|
||||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||||
|
/// The size every label in the header row is drawn at.
|
||||||
|
///
|
||||||
|
/// One constant for all four rather than a number per button, because the
|
||||||
|
/// whole row has to be sized together. Adding the trace switch made four
|
||||||
|
/// controls too wide for one row at the size three had used (18), and an
|
||||||
|
/// earlier pass shrank this constant to 13 to make them fit -- exactly
|
||||||
|
/// what UI_RULES forbids ("never shrink text to make it fit": a label a
|
||||||
|
/// different size from its neighbours elsewhere in the app for a reason
|
||||||
|
/// the reader cannot see). The fix is [`bench_controls`]'s two rows
|
||||||
|
/// instead, which leaves room to put this back. Whoever adds a fifth
|
||||||
|
/// control reconsiders the row split, not this number.
|
||||||
|
const HEADER_TEXT: f32 = 18.0;
|
||||||
|
|
||||||
|
/// The height of one row of header controls, in dp. `bench_controls` now
|
||||||
|
/// stacks two of these, so this is the one number to change if a control's
|
||||||
|
/// own padding ever changes instead of `dp(56)` and `dp(112)` needing to
|
||||||
|
/// be kept in sync by hand.
|
||||||
|
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||||
|
|
||||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||||
.on(
|
.on(
|
||||||
@@ -442,7 +498,9 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
.label("Run benchmark");
|
.label("Run benchmark");
|
||||||
let run = (
|
let run = (
|
||||||
run_rect,
|
run_rect,
|
||||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
wtext("Run benchmark")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
@@ -451,14 +509,16 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
ctx.state.copy_report();
|
ctx.state.copy_report(rsc);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.label("Copy report");
|
.label("Copy report");
|
||||||
let copy = (
|
let copy = (
|
||||||
copy_rect,
|
copy_rect,
|
||||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
wtext("Copy report")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
@@ -474,17 +534,61 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
.label("Diagnostics");
|
.label("Diagnostics");
|
||||||
let diagnostics = (
|
let diagnostics = (
|
||||||
diag_rect,
|
diag_rect,
|
||||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
wtext("Diagnostics")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
// A switch rather than a button, so its own appearance says which
|
||||||
|
// state it is in: the two `iris::input`/`iris::frame` targets are
|
||||||
|
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
|
||||||
|
// session fills the 2000-line ring in seconds, so "is it on right
|
||||||
|
// now" is the question somebody has while looking at a log that is
|
||||||
|
// either full of trace or has none.
|
||||||
|
//
|
||||||
|
// The visible text carries the state and the accessibility label does
|
||||||
|
// not, deliberately: the label is also what `run-bench.sh` taps by
|
||||||
|
// name, and a control that renames itself when pressed is one no
|
||||||
|
// script can find twice.
|
||||||
|
let tracing = iris::diagnostics::trace_enabled();
|
||||||
|
let trace_rect = rect(if tracing {
|
||||||
|
Color::rgb(90, 70, 30)
|
||||||
|
} else {
|
||||||
|
Color::rgb(50, 50, 60)
|
||||||
|
})
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.toggle_trace(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Trace input and frames");
|
||||||
|
let trace = (
|
||||||
|
trace_rect,
|
||||||
|
wtext(if tracing { "Trace on" } else { "Trace off" })
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// Two rows rather than one: four controls at the restored `HEADER_TEXT`
|
||||||
|
// no longer fit a 1080px-wide row (that was the shrink this replaces --
|
||||||
|
// see the constant's own doc). Grouped by what they act on: the first
|
||||||
|
// row starts a benchmark and copies its result; the second is the
|
||||||
|
// diagnostics pane and the switch that decides what it will contain
|
||||||
|
// next time.
|
||||||
|
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
||||||
|
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
||||||
|
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
||||||
|
|
||||||
(rect(HEADER_SURFACE), buttons)
|
(rect(HEADER_SURFACE), buttons)
|
||||||
.stack()
|
.stack()
|
||||||
.height(dp(56))
|
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||||
.pad(Padding::top(top_pad))
|
.pad(Padding::top(top_pad))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
@@ -498,8 +602,7 @@ impl BenchClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
let rows = group_tool_runs(&self.items);
|
let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
|
||||||
(self.content)(rsc).set(tree);
|
(self.content)(rsc).set(tree);
|
||||||
self.screen = Some(screen);
|
self.screen = Some(screen);
|
||||||
}
|
}
|
||||||
@@ -511,59 +614,116 @@ impl BenchClient {
|
|||||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||||
/// so it's set here too rather than adding a second copy path.
|
/// so it's set here too rather than adding a second copy path.
|
||||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
|
let report = self.diagnostics_text(rsc);
|
||||||
|
self.report_display.edit(rsc).set(&report);
|
||||||
|
self.last_report = Some(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
||||||
|
/// switch that says so, and shows the pane that now reports it.
|
||||||
|
///
|
||||||
|
/// Showing the pane is the point rather than a convenience: this is a
|
||||||
|
/// control whose whole effect is on what a *later* report says, so
|
||||||
|
/// putting the state on screen at the moment of the press is the only
|
||||||
|
/// thing that distinguishes it from a button that did nothing.
|
||||||
|
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
||||||
|
let on = !iris::diagnostics::trace_enabled();
|
||||||
|
iris::diagnostics::set_trace(on);
|
||||||
|
log::info!(
|
||||||
|
"iris diagnostics: input/frame trace {}",
|
||||||
|
if on { "on" } else { "off" }
|
||||||
|
);
|
||||||
|
let controls = bench_controls(rsc, self.last_top_pad);
|
||||||
|
(self.top_bar)(rsc).set(controls);
|
||||||
|
self.show_diagnostics(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diagnostics report as text, with no side effect on what is on
|
||||||
|
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||||
|
/// the keyboard-open capture (which only logs it), so the two can
|
||||||
|
/// never drift into reporting different things.
|
||||||
|
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
||||||
let font = rsc.ui.text.font_diagnostics();
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
let frame_report = match self.android_state().frame_report.report() {
|
let frame_report = match self.android_state().frame_report.report() {
|
||||||
Some(stats) => format!("{stats}"),
|
Some(stats) => format!("{stats}"),
|
||||||
None => "no frames recorded yet".to_string(),
|
None => "no frames recorded yet".to_string(),
|
||||||
};
|
};
|
||||||
let report = match &self.android_state().renderer {
|
let renderer = match &self.android_state().renderer {
|
||||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||||
};
|
};
|
||||||
self.report_display.edit(rsc).set(&report);
|
// The insets line goes in the pane, not just the log: Iris has no
|
||||||
self.last_report = Some(report);
|
// 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{}\n{}\n{}\n{}",
|
||||||
|
trace_line(
|
||||||
|
iris::diagnostics::trace_enabled(),
|
||||||
|
iris::diagnostics::trace_enabled()
|
||||||
|
),
|
||||||
|
self.android_state().insets_report(),
|
||||||
|
// Which server this build talks to, and what to do when the
|
||||||
|
// answer is "none" -- the bench itself opens a checked-in
|
||||||
|
// fixture and needs no server, so this pane is the only place
|
||||||
|
// an enrolment can be seen to have taken.
|
||||||
|
crate::enrollment::status_line(),
|
||||||
|
crate::app_log::diagnostics_line()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
|
/// doc comment. **Logged only.** It used to also copy the report to
|
||||||
/// same text the on-screen `Diagnostics` button produces, plus the
|
/// the clipboard unprompted and put it in the shell's overlay view,
|
||||||
/// per-frame log `FrameReport` already keeps around the resize --
|
/// from when the keyboard-inset callback was not firing at all and a
|
||||||
/// `frame_report.report()` above covers "the frames around the
|
/// report could not be got off the phone any other way. Both are gone
|
||||||
/// resize" without a second accounting mechanism), then does three
|
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
|
||||||
/// things the button does not: logs it (so a `logcat` pull gets it
|
/// `MainActivity.java`), and the overlay covered the whole screen on
|
||||||
/// even if nothing on screen does), copies it to the clipboard
|
/// *every* keyboard open with its own Copy/Close buttons underneath
|
||||||
/// unprompted, and shows it in the shell's plain overlay view, which
|
/// the keyboard, so it could not be dismissed -- an interruption for
|
||||||
/// draws independently of iris's own renderer -- the whole point,
|
/// something nobody asked for, over an app you are trying to type
|
||||||
/// since the renderer is exactly what might be in the wiped state
|
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
|
||||||
/// this exists to report on.
|
/// 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) {
|
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
self.show_diagnostics(rsc);
|
let report = self.diagnostics_text(rsc);
|
||||||
let Some(report) = self.last_report.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("iris keyboard diagnostics:\n{report}");
|
log::info!("iris keyboard diagnostics:\n{report}");
|
||||||
let Some(platform) = &self.platform else {
|
|
||||||
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
|
|
||||||
log::info!("iris keyboard diagnostics: copied to clipboard");
|
|
||||||
} else {
|
|
||||||
log::info!("iris keyboard diagnostics: clipboard copy failed");
|
|
||||||
}
|
|
||||||
platform.show_diagnostics_overlay(&report);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_report(&mut self) {
|
/// Always copies something, and never depends on `Diagnostics` or
|
||||||
let Some(report) = &self.last_report else {
|
/// `Run benchmark` having been pressed first (docs/IRIS_TODO.md,
|
||||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
/// 2026-09-07 night: "the copy report button seemed impossible to hit
|
||||||
return;
|
/// until I hit the diagnostics one" -- it was silently declining
|
||||||
};
|
/// instead of reporting where it had failed, the UI_RULES failure "a
|
||||||
|
/// failure is reported where it happened"). With no benchmark run yet,
|
||||||
|
/// it copies the diagnostics pane's own text instead, with a first
|
||||||
|
/// line saying so -- `diagnostics_text` needs no prior button press
|
||||||
|
/// either, so this is never actually empty-handed.
|
||||||
|
fn copy_report(&mut self, rsc: &mut Rsc) {
|
||||||
let Some(platform) = &self.platform else {
|
let Some(platform) = &self.platform else {
|
||||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if platform.copy_to_clipboard("iris bench report", report) {
|
let report = match self.last_report.clone() {
|
||||||
|
Some(report) => report,
|
||||||
|
None => format!(
|
||||||
|
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
|
||||||
|
self.diagnostics_text(rsc)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// The ring's tail goes on the clipboard, not the full ring, and
|
||||||
|
// not into the on-screen pane either: the full ring can be over a
|
||||||
|
// thousand lines with tracing on, and pasting that into a phone's
|
||||||
|
// message box was Iris's own "causes a lot of lag" report. The
|
||||||
|
// full ring is still reachable through Dev Updater's Runtime tab
|
||||||
|
// (`devlog`'s provider reads the same ring) -- this only bounds
|
||||||
|
// what gets inlined here.
|
||||||
|
let report = format!(
|
||||||
|
"{report}\n\n=== app log ({}) ===\n{}",
|
||||||
|
crate::app_log::ring().summary(),
|
||||||
|
crate::app_log::ring().tail_text(client_core::log_ring::COPY_REPORT_TAIL_LINES)
|
||||||
|
);
|
||||||
|
if platform.copy_to_clipboard("iris bench report", &report) {
|
||||||
log::info!("iris bench report: copied to clipboard");
|
log::info!("iris bench report: copied to clipboard");
|
||||||
} else {
|
} else {
|
||||||
log::info!("iris bench report: clipboard copy failed");
|
log::info!("iris bench report: clipboard copy failed");
|
||||||
@@ -592,6 +752,11 @@ impl BenchClient {
|
|||||||
.and_then(|p| p.refresh_rate_hz())
|
.and_then(|p| p.refresh_rate_hz())
|
||||||
.unwrap_or(60.0);
|
.unwrap_or(60.0);
|
||||||
let cpu_start = process_cpu_ms();
|
let cpu_start = process_cpu_ms();
|
||||||
|
// Read at the start as well as the end, because the switch is on
|
||||||
|
// screen while a run is going: a report that only asked afterwards
|
||||||
|
// would say "on" about a run whose first half has no trace in it
|
||||||
|
// -- the inferred answer presented as the measured one.
|
||||||
|
let trace_at_start = iris::diagnostics::trace_enabled();
|
||||||
let run_started_at = Instant::now();
|
let run_started_at = Instant::now();
|
||||||
|
|
||||||
rsc.spawn_task(async move |mut ctx| {
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
@@ -689,9 +854,11 @@ impl BenchClient {
|
|||||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||||
TYPE_TEXT.chars().count()
|
TYPE_TEXT.chars().count()
|
||||||
);
|
);
|
||||||
|
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||||
let report = format!(
|
let report = format!(
|
||||||
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
|
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
|
||||||
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
|
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
|
||||||
|
{rss_line}\n{battery}"
|
||||||
);
|
);
|
||||||
log::info!("iris bench report: {report}");
|
log::info!("iris bench report: {report}");
|
||||||
state.report_display.edit(rsc).set(&report);
|
state.report_display.edit(rsc).set(&report);
|
||||||
@@ -716,7 +883,7 @@ impl BenchClient {
|
|||||||
/// drained everything queued before this call existed. Cost a real hang
|
/// drained everything queued before this call existed. Cost a real hang
|
||||||
/// in this file's first version of the fling phase: every loop iteration
|
/// in this file's first version of the fling phase: every loop iteration
|
||||||
/// after the first sat forever with nothing scheduled to drain it.
|
/// after the first sat forever with nothing scheduled to drain it.
|
||||||
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
|
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
|
||||||
/// slow device's frame callback can lag further than that.
|
/// slow device's frame callback can lag further than that.
|
||||||
async fn read_from_state<T, F>(
|
async fn read_from_state<T, F>(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
@@ -736,7 +903,7 @@ where
|
|||||||
if let Ok(value) = rx.try_recv() {
|
if let Ok(value) = rx.try_recv() {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,13 +931,14 @@ async fn run_fling_phase(
|
|||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||||
// `anchor = None` into a real slot before `start` is read.
|
// `anchor = None` into a real slot before `start` is read.
|
||||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
|
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
|
||||||
let start = read_anchor_position(ctx, redraw).await;
|
let start = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||||
|
animate_list(screen.list, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -783,6 +951,7 @@ async fn run_fling_phase(
|
|||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||||
|
animate_list(screen.list, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -791,7 +960,10 @@ async fn run_fling_phase(
|
|||||||
}
|
}
|
||||||
let end = read_anchor_position(ctx, redraw).await;
|
let end = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
format!("start={start} outward={outward} end={end}")
|
// Says how the fling was advanced, because that is what changed on
|
||||||
|
// 2026-09-08 and a report from before then is not comparable: the
|
||||||
|
// phase used to tick the fling itself at ~60Hz.
|
||||||
|
format!("start={start} outward={outward} end={end} ticked=frame-loop")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_anchor_position(
|
async fn read_anchor_position(
|
||||||
@@ -805,11 +977,31 @@ async fn read_anchor_position(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticks the fling forward in ~60Hz steps (the same shape
|
/// Register the list with the frame loop, exactly as a finger's own
|
||||||
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
|
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
|
||||||
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
|
/// `List::fling` sets a velocity and drives nothing by itself.
|
||||||
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
|
fn animate_list(list: iris::prelude::WeakWidget<iris::prelude::List>, rsc: &mut Rsc) {
|
||||||
/// own spline-decided `duration()` already caps how long it can run.
|
let id = list.id();
|
||||||
|
rsc.ui_mut().animate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for the fling started above to settle, or for
|
||||||
|
/// `FLING_SETTLE_CAP_MS` -- 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.
|
||||||
|
///
|
||||||
|
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
|
||||||
|
/// `List::tick_fling` itself every `POLL_MS`, which advanced the
|
||||||
|
/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame
|
||||||
|
/// redrew the list at a position it had already drawn, and the benchmark
|
||||||
|
/// looked distinctly less smooth than the same list under her finger.
|
||||||
|
/// That is what she reported that day, and it was the rig rather than the
|
||||||
|
/// renderer: a real fling is ticked once per frame by
|
||||||
|
/// `UiData::tick_animations`, from the frame callback. So the bench now
|
||||||
|
/// starts the fling the way a gesture does (`fling` + `UiData::animate`)
|
||||||
|
/// and polls `is_scrolling` to know when it is over, which makes the
|
||||||
|
/// phase measure the same path a finger takes. The poll interval is only
|
||||||
|
/// how often the *question* is asked and has no bearing on the animation.
|
||||||
async fn wait_for_fling_settle(
|
async fn wait_for_fling_settle(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
redraw: &Arc<dyn RequestRedraw>,
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
@@ -818,14 +1010,14 @@ async fn wait_for_fling_settle(
|
|||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
while started.elapsed() < cap {
|
while started.elapsed() < cap {
|
||||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||||
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
|
Some(screen) => (screen.list)(rsc).is_scrolling(),
|
||||||
None => false,
|
None => false,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if !still_scrolling {
|
if !still_scrolling {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
||||||
|
//! for a `ContentProvider` that Dev Updater queries.
|
||||||
|
//!
|
||||||
|
//! **Why**: Iris runs these builds on a phone with no `adb`, and Android
|
||||||
|
//! forbids one app reading another's `logcat`, so nothing outside this
|
||||||
|
//! process can recover what it wrote. The app already keeps a bounded copy
|
||||||
|
//! (`client_core::log_ring`); this is how the copy leaves the process. Dev
|
||||||
|
//! Updater is on the same phone, so handing it over needs no tunnel, no
|
||||||
|
//! token and no second enrolment -- and it is Dev Updater's own contract
|
||||||
|
//! rather than something invented here, so any app it delivers can do the
|
||||||
|
//! same (its `README.md`, "An app's own log").
|
||||||
|
//!
|
||||||
|
//! **Everything general stays in `client-core`** (AGENTS.md's sharing
|
||||||
|
//! rule). What is here is only what Android forces: the JNI boundary and
|
||||||
|
//! the Java class on the other side of it.
|
||||||
|
//!
|
||||||
|
//! Both entry points answer a **flat `String[]`** rather than a row of
|
||||||
|
//! typed columns. That is the whole of the JNI, and it is one array type
|
||||||
|
//! instead of three interleaved ones for a payload the provider is about
|
||||||
|
//! to hand back over binder as a `MatrixCursor` anyway; `DevLogProvider`
|
||||||
|
//! parses the two numeric fields. Kept flat rather than nested for the
|
||||||
|
//! same reason -- an array of arrays is four more JNI calls per line.
|
||||||
|
|
||||||
|
use android_view::jni::JNIEnv;
|
||||||
|
use android_view::jni::objects::{JClass, JObject, JString};
|
||||||
|
use android_view::jni::sys::{jlong, jobjectArray};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// How many `String`s each log line occupies in the flat answer:
|
||||||
|
/// `seq`, `t_ms`, `level`, `target`, `message`, in that order. The Java
|
||||||
|
/// side has the same constant, and the two are the one place the shape is
|
||||||
|
/// written down on each side.
|
||||||
|
///
|
||||||
|
/// Gated with its one reader: the tabs demo links no `client-core` and so
|
||||||
|
/// has no ring to lay out, and an ungated constant is a warning in that
|
||||||
|
/// build (`iris-android-app` without `transcript-screen`).
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
const FIELDS_PER_LINE: usize = 5;
|
||||||
|
|
||||||
|
/// The authority the provider registered itself under, once it has been
|
||||||
|
/// created. `None` until then, which is a state worth being able to say:
|
||||||
|
/// a provider Android never instantiated and one that is answering look
|
||||||
|
/// the same from inside this process otherwise.
|
||||||
|
static AUTHORITY: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Where this app's log can be read from, for the diagnostics pane.
|
||||||
|
///
|
||||||
|
/// The provider's own answer rather than one composed from the package
|
||||||
|
/// name here: what makes the line worth showing is that it names an
|
||||||
|
/// authority somebody can actually query, and only the provider knows it
|
||||||
|
/// registered.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn authority() -> Option<&'static str> {
|
||||||
|
AUTHORITY.get().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
|
||||||
|
/// it registered under and the app's private directory, from its own
|
||||||
|
/// `onCreate`.
|
||||||
|
///
|
||||||
|
/// The directory is taken here as well as in
|
||||||
|
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
|
||||||
|
/// only thing running**: once the app has died, Dev Updater's query
|
||||||
|
/// starts the process for the provider alone, so no activity ever runs
|
||||||
|
/// and the panic hook's file would never be replayed into the ring. That
|
||||||
|
/// is precisely the run whose log is being asked for. Whichever of the
|
||||||
|
/// two arrives first does the replay; `set_crash_dir` deletes the file,
|
||||||
|
/// so the second finds nothing and says nothing.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
authority: JString,
|
||||||
|
files_dir: JString,
|
||||||
|
) {
|
||||||
|
// Before the authority line, so the previous run's death is above the
|
||||||
|
// line announcing this one rather than buried under it.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
||||||
|
crate::app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
let _ = &files_dir;
|
||||||
|
let Some(authority) = string_arg(&mut env, &authority) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
log::info!("iris devlog: serving this app's log at content://{authority}");
|
||||||
|
let _ = AUTHORITY.set(authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `String` argument, or `None` for a null or unreadable one.
|
||||||
|
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
env.get_string(value).ok().map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
|
||||||
|
/// three strings.
|
||||||
|
///
|
||||||
|
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
||||||
|
/// what tells a reader holding a cursor that this process **restarted**:
|
||||||
|
/// the ring is in memory, so a new process starts again at zero and a
|
||||||
|
/// stale cursor would otherwise skip everything silently.
|
||||||
|
///
|
||||||
|
/// Exported by name rather than registered, matching this crate's other
|
||||||
|
/// activity-side natives: the mangled name is the whole of what a class
|
||||||
|
/// this app owns needs.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jobjectArray {
|
||||||
|
string_array(&mut env, &status_fields())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at
|
||||||
|
/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each.
|
||||||
|
///
|
||||||
|
/// Inclusive of `since` because [`client_core::log_ring::LogRing::since`]
|
||||||
|
/// is, and one definition of the cursor is what keeps the app's own
|
||||||
|
/// uploaded report and this provider describing the same lines.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
since: jlong,
|
||||||
|
) -> jobjectArray {
|
||||||
|
// A negative cursor is a caller asking for everything, not an error to
|
||||||
|
// take the app down over: the provider is a diagnostic.
|
||||||
|
string_array(&mut env, &line_fields(since.max(0) as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three status numbers, as the provider's row.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
fn status_fields() -> Vec<String> {
|
||||||
|
let ring = client_core::log_ring::process_ring();
|
||||||
|
vec![
|
||||||
|
ring.len().to_string(),
|
||||||
|
ring.dropped().to_string(),
|
||||||
|
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tabs demo links no `client-core` and keeps no ring, so it holds
|
||||||
|
/// nothing and has never dropped anything -- which is the truth, not a
|
||||||
|
/// stand-in. The natives are still exported there, because a `native`
|
||||||
|
/// method Java declares and the library does not is an
|
||||||
|
/// `UnsatisfiedLinkError` the moment the class loads.
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
fn status_fields() -> Vec<String> {
|
||||||
|
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
fn line_fields(since: u64) -> Vec<String> {
|
||||||
|
let (lines, _next) = client_core::log_ring::process_ring().since(since);
|
||||||
|
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
|
||||||
|
for line in lines {
|
||||||
|
fields.push(line.seq.to_string());
|
||||||
|
fields.push(line.at_ms.to_string());
|
||||||
|
fields.push(line.level.to_string());
|
||||||
|
fields.push(line.target);
|
||||||
|
fields.push(line.message);
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
fn line_fields(_since: u64) -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Java `String[]` of those, or a null array if the JVM refused one.
|
||||||
|
///
|
||||||
|
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
|
||||||
|
/// reads it as "the provider could not answer" and returns no cursor,
|
||||||
|
/// which Dev Updater already draws as a distinct state. Taking the app
|
||||||
|
/// down to report that its diagnostic is unavailable would be worse than
|
||||||
|
/// the diagnostic being unavailable.
|
||||||
|
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
|
||||||
|
let null = std::ptr::null_mut();
|
||||||
|
let Ok(class) = env.find_class("java/lang/String") else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
for (index, field) in fields.iter().enumerate() {
|
||||||
|
let Ok(value) = env.new_string(field) else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
if env
|
||||||
|
.set_object_array_element(&array, index as i32, value)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array.into_raw()
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! Which `ai-server` this app talks to, and how it was told.
|
||||||
|
//!
|
||||||
|
//! The parsing, the file and its owner-only mode are
|
||||||
|
//! `client_core::config` (`EnrolledServer`/`EnrollmentStore`), shared with
|
||||||
|
//! the desktop app. What is genuinely this platform's, and all that is
|
||||||
|
//! here, is the intent plumbing: Android hands an `aiapp://enroll?...`
|
||||||
|
//! link to `MainActivity`, which passes it and the app's private files
|
||||||
|
//! directory across JNI (see `lib.rs`'s two exported functions).
|
||||||
|
//!
|
||||||
|
//! **Why the app is told at runtime rather than at build time.** The APK
|
||||||
|
//! is cross-compiled in a VM and run against the server on the host, whose
|
||||||
|
//! CA and token are not this machine's -- so nothing about the destination
|
||||||
|
//! can be baked in, and no token or CA may sit in a repo or a delivered
|
||||||
|
//! artifact either way. The CA arrives with the link (`ca` parameter,
|
||||||
|
//! `wg_app_link::enroll::ca_param`), which is what makes an APK built
|
||||||
|
//! anywhere able to pin the server it is pointed at.
|
||||||
|
//!
|
||||||
|
//! The files directory is process-wide state, which this project otherwise
|
||||||
|
//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the
|
||||||
|
//! first thing that wants the enrollment -- has no parameter it could come
|
||||||
|
//! in through. Same shape, and the same reason, as
|
||||||
|
//! `client_core::log_ring`'s process ring.
|
||||||
|
|
||||||
|
#[cfg(not(feature = "bench"))]
|
||||||
|
use client_core::api::UreqTransport;
|
||||||
|
use client_core::config::{EnrolledServer, EnrollmentStore};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds
|
||||||
|
/// the view. Set once per process; a second call with a different path is
|
||||||
|
/// a programmer error rather than something to recover from, and a second
|
||||||
|
/// call with the same one is what a re-created activity does.
|
||||||
|
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn set_files_dir(dir: PathBuf) {
|
||||||
|
if let Err(existing) = FILES_DIR.set(dir.clone()) {
|
||||||
|
assert_eq!(
|
||||||
|
existing, dir,
|
||||||
|
"the app's files directory was set twice with different paths"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `None` before `MainActivity` has handed the directory over -- which is
|
||||||
|
/// **not** the same as "not enrolled", and is why [`status`] has a state
|
||||||
|
/// for it (UI_RULES: design the unknown state first).
|
||||||
|
fn store() -> Option<EnrollmentStore> {
|
||||||
|
FILES_DIR.get().map(EnrollmentStore::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this app has been told, or why it has not been.
|
||||||
|
pub enum Status {
|
||||||
|
Enrolled(EnrolledServer),
|
||||||
|
/// Nothing has been enrolled yet: the ordinary first-run state.
|
||||||
|
NotEnrolled,
|
||||||
|
/// The question could not be answered -- the activity never handed a
|
||||||
|
/// files directory over, or the file is there and unreadable. Kept
|
||||||
|
/// apart from `NotEnrolled` because the two want different actions
|
||||||
|
/// from whoever is looking.
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> Status {
|
||||||
|
let Some(store) = store() else {
|
||||||
|
return Status::Unknown("the activity never handed over a files directory".to_string());
|
||||||
|
};
|
||||||
|
match store.load() {
|
||||||
|
Ok(Some(server)) => Status::Enrolled(server),
|
||||||
|
Ok(None) => Status::NotEnrolled,
|
||||||
|
Err(error) => Status::Unknown(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for the diagnostics pane. The three states read differently on
|
||||||
|
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||||
|
/// must not be mistaken for it.
|
||||||
|
///
|
||||||
|
/// Only the bench build has a pane to put this in -- same gate, and the
|
||||||
|
/// same reason, as `app_log::diagnostics_line`. The transcript build says
|
||||||
|
/// the same things where they matter to it, in the message
|
||||||
|
/// [`transport`]'s error becomes on screen.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn status_line() -> String {
|
||||||
|
match status() {
|
||||||
|
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
|
||||||
|
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
|
||||||
|
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever
|
||||||
|
/// was enrolled before -- opening a link is how somebody says "this server
|
||||||
|
/// now", including after the old one's token was rotated.
|
||||||
|
///
|
||||||
|
/// The returned `Err` is the message for a person: this is called from a
|
||||||
|
/// tap on a link, and a link that did nothing with nothing said is the
|
||||||
|
/// failure the UI rules are most insistent about.
|
||||||
|
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||||
|
let server = EnrolledServer::parse_link(uri)?;
|
||||||
|
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||||
|
store
|
||||||
|
.save(&server)
|
||||||
|
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transport for the enrolled server, pinning the CA the link carried.
|
||||||
|
///
|
||||||
|
/// Gated to the same builds as `transcript_client`, its only caller: the
|
||||||
|
/// bench build opens a checked-in fixture and reaches no server, so
|
||||||
|
/// compiling this into it would be a warning about dead code that is
|
||||||
|
/// dead on purpose.
|
||||||
|
///
|
||||||
|
/// Every failure here is a sentence a screen can show, because there is
|
||||||
|
/// nowhere else for it to go: this app has no `logcat` on the phone it is
|
||||||
|
/// built for.
|
||||||
|
#[cfg(not(feature = "bench"))]
|
||||||
|
pub fn transport() -> Result<UreqTransport, String> {
|
||||||
|
let server = match status() {
|
||||||
|
Status::Enrolled(server) => server,
|
||||||
|
Status::NotEnrolled => {
|
||||||
|
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
|
||||||
|
}
|
||||||
|
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
|
||||||
|
};
|
||||||
|
let ca_pem = server.ca_pem.as_ref().ok_or(
|
||||||
|
"The enrollment link carried no CA, so there is nothing to pin. \
|
||||||
|
Enrol again with a link minted by this server.",
|
||||||
|
)?;
|
||||||
|
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
|
||||||
|
.map_err(|error| error.message)
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ use android_view::{
|
|||||||
Context, View,
|
Context, View,
|
||||||
jni::{
|
jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
|
objects::{JClass, JString},
|
||||||
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||||
},
|
},
|
||||||
register_view_class,
|
register_view_class,
|
||||||
@@ -51,10 +52,25 @@ use iris::prelude::*;
|
|||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
/// The app's own log ring and its upload -- only where `client-core` is
|
||||||
|
/// linked, which is every build that has a server to send to. The plain
|
||||||
|
/// tabs demo keeps `android_logger` alone, as it always had.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
mod app_log;
|
||||||
#[cfg(feature = "bench")]
|
#[cfg(feature = "bench")]
|
||||||
mod bench_client;
|
mod bench_client;
|
||||||
#[cfg(feature = "bench")]
|
#[cfg(feature = "bench")]
|
||||||
mod bench_jni;
|
mod bench_jni;
|
||||||
|
/// This app's log ring, handed to Dev Updater on the phone through a
|
||||||
|
/// `ContentProvider`. Declared in every build for the reason the module
|
||||||
|
/// gives: the Java class is in the manifest either way, and a `native`
|
||||||
|
/// method the library does not export fails the class load.
|
||||||
|
mod devlog;
|
||||||
|
/// Which server this app talks to, told to it at runtime by an
|
||||||
|
/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain
|
||||||
|
/// tabs demo makes no network call and has nothing to enrol against.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
mod enrollment;
|
||||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
mod transcript_client;
|
mod transcript_client;
|
||||||
|
|
||||||
@@ -119,6 +135,13 @@ extern "system" fn new_view_peer<'local>(
|
|||||||
/// mirrors android-view's own demo, which carries the same comment.
|
/// mirrors android-view's own demo, which carries the same comment.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
||||||
|
// The ring in front of `android_logger` where there is one (see
|
||||||
|
// `app_log`), and `android_logger` alone otherwise. Both install the
|
||||||
|
// same tag and level, so `logcat` cannot tell the two builds apart --
|
||||||
|
// the ring only adds a second reader.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
app_log::install(LevelFilter::Debug);
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
android_logger::init_once(
|
android_logger::init_once(
|
||||||
android_logger::Config::default()
|
android_logger::Config::default()
|
||||||
.with_max_level(LevelFilter::Debug)
|
.with_max_level(LevelFilter::Debug)
|
||||||
@@ -130,3 +153,83 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) ->
|
|||||||
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||||
JNI_VERSION_1_6
|
JNI_VERSION_1_6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
|
||||||
|
/// over before the view exists because that is where the enrollment is
|
||||||
|
/// read from and written to (`enrollment`'s module doc).
|
||||||
|
///
|
||||||
|
/// Exported by name rather than registered through `RegisterNatives`: the
|
||||||
|
/// view's methods are registered because `android-view` owns that class
|
||||||
|
/// and hands out one function pointer, whereas these two are this app's
|
||||||
|
/// own activity and the mangled name is the whole of what is needed.
|
||||||
|
///
|
||||||
|
/// Declared in every build, including the tabs demo that has no
|
||||||
|
/// `client-core` to store anything -- a `native` method Java declares and
|
||||||
|
/// the library does not export is an `UnsatisfiedLinkError` when the class
|
||||||
|
/// loads, which would take down a build that merely shares the activity.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
dir: JString,
|
||||||
|
) {
|
||||||
|
let Some(dir) = jstring(&mut env, dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
{
|
||||||
|
app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||||
|
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
|
||||||
|
}
|
||||||
|
log::debug!("iris app: files directory is {dir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the
|
||||||
|
/// VIEW intent that started or resumed the activity.
|
||||||
|
///
|
||||||
|
/// Logged either way rather than answered: the activity has nothing to do
|
||||||
|
/// with the result, and where the enrollment shows up is the diagnostics
|
||||||
|
/// pane (`enrollment::status_line`), which reads the stored answer rather
|
||||||
|
/// than being told it.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
uri: JString,
|
||||||
|
) {
|
||||||
|
let Some(uri) = jstring(&mut env, uri) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
match enrollment::apply_link(&uri) {
|
||||||
|
// Never the token: `wg-app-link`'s enroll module forbids logging
|
||||||
|
// it, and this line would otherwise be the one place it leaked.
|
||||||
|
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
|
||||||
|
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one --
|
||||||
|
/// neither is worth taking the app down for, and both are logged where
|
||||||
|
/// they happen.
|
||||||
|
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
log::warn!("iris app: the activity passed a null string across JNI");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match env.get_string(&value) {
|
||||||
|
Ok(value) => Some(value.into()),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("iris app: couldn't read a string from the activity -- {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@
|
|||||||
//!
|
//!
|
||||||
//! **Deliberate simplification, recorded rather than left to be
|
//! **Deliberate simplification, recorded rather than left to be
|
||||||
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
|
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
|
||||||
//! session list and no enrollment UI here. The server, port, token and
|
//! session list here -- the first session `ApiClient::fetch_sessions`
|
||||||
//! pinned CA are baked in at build time (`build.rs`'s
|
//! returns is opened automatically, since there is nothing to tap to get
|
||||||
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
|
//! there, which is what `transcript-bench.sh` and `ui-trace` need to land
|
||||||
//! session `ApiClient::fetch_sessions` returns is opened automatically --
|
//! straight on the screen under test.
|
||||||
//! there is nothing to tap to get there, which is what `transcript-bench.sh`
|
//!
|
||||||
//! and `ui-trace` need to land straight on the screen under test. A real
|
//! Which server it opens it against is no longer baked in: it is the
|
||||||
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
|
//! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`,
|
||||||
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
|
//! and `desktop-app`'s identical `--link`), because an APK
|
||||||
//! not this pass's job.
|
//! cross-compiled here cannot pin the CA of a server on the host.
|
||||||
//!
|
//!
|
||||||
//! **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
|
||||||
@@ -46,10 +46,6 @@ use iris::prelude::*;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
mod pinned {
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TranscriptClient {
|
pub struct TranscriptClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
/// The screen's own content -- everything under the fixed
|
/// The screen's own content -- everything under the fixed
|
||||||
@@ -85,18 +81,16 @@ impl HasAndroidUiState for TranscriptClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
|
/// Builds one `UreqTransport` from the stored enrollment. Called twice per
|
||||||
/// twice per session load, same as `desktop-app`'s `build_transport`
|
/// session load, same as `desktop-app`'s `build_transport` closure --
|
||||||
/// closure -- `ApiClient` and the live-stream follow each need their own,
|
/// `ApiClient` and the live-stream follow each need their own, since
|
||||||
/// since `UreqTransport` holds its own `ureq::Agent`.
|
/// `UreqTransport` holds its own `ureq::Agent`.
|
||||||
|
///
|
||||||
|
/// Read afresh each time rather than held: opening a new enrolment link
|
||||||
|
/// while the app is running is how somebody points it at another server,
|
||||||
|
/// and a cached transport would keep talking to the old one.
|
||||||
fn build_transport() -> Result<UreqTransport, String> {
|
fn build_transport() -> Result<UreqTransport, String> {
|
||||||
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
|
crate::enrollment::transport()
|
||||||
UreqTransport::new(
|
|
||||||
base_url,
|
|
||||||
pinned::TOKEN.to_string(),
|
|
||||||
pinned::CA_PEM.as_bytes(),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/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):
|
||||||
|
# 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own
|
||||||
|
# release velocity (velocity_reference.py), so `phone_screen.rs`
|
||||||
|
# can bound the fling it produces from *here* rather than from the
|
||||||
|
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
|
||||||
|
for velocity in (5000.0, 11064.0, 15250.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;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
|
||||||
|
from a report the layer-1 harness produced with tracing on
|
||||||
|
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
|
||||||
|
`iris::harness::Harness::replay` can play back at layer 1.
|
||||||
|
|
||||||
|
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
|
||||||
|
layer that can answer a question wins, and a gesture that misbehaves on
|
||||||
|
Iris's phone is otherwise only describable in words. `iris::sense::
|
||||||
|
log_input_event`'s one line per platform event (Android's on_touch_event
|
||||||
|
once per `MotionEvent`, with historical samples inline; winit's once per
|
||||||
|
pointer `WindowEvent`; the harness's `touch`, once per script line) already
|
||||||
|
carries everything a `.touch` file's `t_ms action x y` needs -- this just
|
||||||
|
reads it back out and reconstructs the samples in order, expanding each
|
||||||
|
event's inline historical samples into their own `move` lines first (they
|
||||||
|
are always intermediate positions of a move, and Android documents them as
|
||||||
|
oldest first, which is also the order they appear in the line).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
report_to_touch.py < report.txt > replay.touch
|
||||||
|
report_to_touch.py report.txt > replay.touch
|
||||||
|
|
||||||
|
Only lines containing "iris input: action=..." are read; everything else in
|
||||||
|
the report (insets, frame timings, drag-release summaries) is ignored, so
|
||||||
|
this can be pointed at Copy report's whole clipboard text directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# The message half of `sense::log_input_event`'s format string, prefix-
|
||||||
|
# agnostic: a real report line also carries the ring's own
|
||||||
|
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
|
||||||
|
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
|
||||||
|
# of that -- neither of which this needs to understand, since `search`
|
||||||
|
# (not `match`) finds the marker wherever it starts.
|
||||||
|
LINE_RE = re.compile(
|
||||||
|
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
|
||||||
|
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
|
||||||
|
)
|
||||||
|
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
|
||||||
|
# -- see `log_input_event`'s own doc for why order matters.
|
||||||
|
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: float) -> str:
|
||||||
|
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
|
||||||
|
it -- an integer without a trailing `.0` where the source was one
|
||||||
|
(every coordinate here is a physical pixel), `{:g}` otherwise so a
|
||||||
|
fractional value from a real device is not silently truncated."""
|
||||||
|
if value == int(value):
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def convert(lines):
|
||||||
|
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
|
||||||
|
action, x, y)` tuple per touch sample -- a historical sample is always
|
||||||
|
an intermediate `move`, and the event's own sample keeps its real
|
||||||
|
action (`down`/`move`/`up`/`cancel`)."""
|
||||||
|
rows = []
|
||||||
|
for line in lines:
|
||||||
|
m = LINE_RE.search(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
hist_count = int(m.group("hist"))
|
||||||
|
hist_matches = list(HIST_RE.finditer(m.group("rest")))
|
||||||
|
if len(hist_matches) != hist_count:
|
||||||
|
print(
|
||||||
|
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
|
||||||
|
f"holds {len(hist_matches)} samples -- skipped",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for hm in hist_matches:
|
||||||
|
rows.append(
|
||||||
|
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
|
||||||
|
for t_ms, action, x, y in convert(text):
|
||||||
|
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
|
||||||
|
|
||||||
|
Same reason `fling_spline_reference.py` exists: the numbers checked into
|
||||||
|
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
|
||||||
|
estimator -- total motion over the sample span, an average -- passed every test
|
||||||
|
it had, because every one of those tests asserted the average's own definition
|
||||||
|
back at it. An average cannot tell an accelerating flick from a steady drag, and
|
||||||
|
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
|
||||||
|
actually works but is slower than Compose's immediately after releasing the
|
||||||
|
flick".
|
||||||
|
|
||||||
|
Transcribed by hand from, and only from, the `-sources.jar` of
|
||||||
|
**androidx.compose.ui:ui-android:1.12.0** and
|
||||||
|
**androidx.compose.foundation:foundation-android:1.12.0**
|
||||||
|
(dl.google.com/dl/android/maven2), read 2026-09-07:
|
||||||
|
|
||||||
|
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
|
||||||
|
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
|
||||||
|
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
|
||||||
|
`HistorySize = 20`, `HorizonMilliseconds = 100`,
|
||||||
|
`AssumePointerMoveStoppedMilliseconds = 40`.
|
||||||
|
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
|
||||||
|
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
|
||||||
|
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
|
||||||
|
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
|
||||||
|
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
|
||||||
|
default, which is `false`.
|
||||||
|
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
|
||||||
|
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
|
||||||
|
feeds the tracker and where the maximum-velocity clamp is applied.
|
||||||
|
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
|
||||||
|
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
|
||||||
|
* `androidx/compose/foundation/gestures/Scrollable.kt` --
|
||||||
|
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
|
||||||
|
|
||||||
|
**Which strategy a touch fling actually uses, since this was the surprise.**
|
||||||
|
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
|
||||||
|
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
|
||||||
|
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
|
||||||
|
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
|
||||||
|
least-squares fit over **absolute positions**, whose velocity is the fitted
|
||||||
|
polynomial's derivative at the newest sample. Impulse is reached only through
|
||||||
|
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
|
||||||
|
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
|
||||||
|
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
|
||||||
|
printed points, because ruling it out by reading is cheaper than ruling it out
|
||||||
|
again next time somebody remembers "Compose uses impulse".
|
||||||
|
|
||||||
|
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
|
||||||
|
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
|
||||||
|
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
|
||||||
|
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
|
||||||
|
and all the UP branch does is reset the tracker when more than 40ms have passed
|
||||||
|
since the last MOVE (b/238654963). So a finger that stops before lifting reads
|
||||||
|
as a stop, not as a decelerating tail. Positions are the raw event positions,
|
||||||
|
so the touch slop is inside the motion the tracker sees even though the list
|
||||||
|
never scrolled by it.
|
||||||
|
|
||||||
|
Two of Compose's samples iris does *not* reproduce, both noted rather than
|
||||||
|
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
|
||||||
|
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
|
||||||
|
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
|
||||||
|
one, since it is a real measured position and dropping it would be copying a
|
||||||
|
quirk of where Compose happens to split its state machine.
|
||||||
|
|
||||||
|
**The clamps.** Maximum: `sendDragStopped` passes
|
||||||
|
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
|
||||||
|
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
|
||||||
|
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
|
||||||
|
exists in Compose's `ViewConfiguration` interface but its only use in either
|
||||||
|
artifact is `NestedScrollInteropConnection`, for View interop.
|
||||||
|
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
|
||||||
|
and says why in its own comment: "we need it since spline curve gives us
|
||||||
|
NaNs". 1 px/s, not 50 dp/s.
|
||||||
|
|
||||||
|
Run it with no arguments; it prints the sample sets and the velocities the
|
||||||
|
Rust tests assert on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
HISTORY_SIZE = 20
|
||||||
|
HORIZON_MILLISECONDS = 100.0
|
||||||
|
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
|
||||||
|
MIN_SAMPLE_SIZE_LSQ2 = 3
|
||||||
|
|
||||||
|
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
|
||||||
|
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
|
||||||
|
# DefaultFlingBehavior.performFling's own threshold, in the units of the
|
||||||
|
# positions fed to the tracker -- pixels per second here.
|
||||||
|
FLING_MINIMUM_PX_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def poly_fit_least_squares(x, y, sample_count, degree):
|
||||||
|
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
|
||||||
|
if degree < 1:
|
||||||
|
raise ValueError("The degree must be at positive integer")
|
||||||
|
if sample_count == 0:
|
||||||
|
raise ValueError("At least one point must be provided")
|
||||||
|
|
||||||
|
truncated_degree = sample_count - 1 if degree >= sample_count else degree
|
||||||
|
m = sample_count
|
||||||
|
n = truncated_degree + 1
|
||||||
|
|
||||||
|
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
|
||||||
|
a = [[0.0] * m for _ in range(n)]
|
||||||
|
for h in range(m):
|
||||||
|
a[0][h] = 1.0
|
||||||
|
for i in range(1, n):
|
||||||
|
a[i][h] = a[i - 1][h] * x[h]
|
||||||
|
|
||||||
|
q = [[0.0] * m for _ in range(n)]
|
||||||
|
r = [[0.0] * n for _ in range(n)]
|
||||||
|
for j in range(n):
|
||||||
|
w = q[j]
|
||||||
|
w[:] = a[j][:m]
|
||||||
|
for i in range(j):
|
||||||
|
z = q[i]
|
||||||
|
dot = sum(w[h] * z[h] for h in range(m))
|
||||||
|
for h in range(m):
|
||||||
|
w[h] -= dot * z[h]
|
||||||
|
norm = math.sqrt(sum(v * v for v in w))
|
||||||
|
inverse_norm = 1.0 / max(norm, 1e-6)
|
||||||
|
for h in range(m):
|
||||||
|
w[h] *= inverse_norm
|
||||||
|
for i in range(n):
|
||||||
|
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
|
||||||
|
|
||||||
|
coefficients = [0.0] * n
|
||||||
|
for i in range(n - 1, -1, -1):
|
||||||
|
c = sum(q[i][h] * y[h] for h in range(m))
|
||||||
|
for j in range(n - 1, i, -1):
|
||||||
|
c -= r[i][j] * coefficients[j]
|
||||||
|
coefficients[i] = c / r[i][i]
|
||||||
|
return coefficients
|
||||||
|
|
||||||
|
|
||||||
|
def kinetic_energy_to_velocity(kinetic_energy):
|
||||||
|
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
|
||||||
|
return sign * math.sqrt(2 * abs(kinetic_energy))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
|
||||||
|
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
|
||||||
|
work = 0.0
|
||||||
|
start = sample_count - 1
|
||||||
|
next_time = time[start]
|
||||||
|
for i in range(start, 0, -1):
|
||||||
|
current_time = next_time
|
||||||
|
next_time = time[i - 1]
|
||||||
|
if current_time == next_time:
|
||||||
|
continue
|
||||||
|
if is_data_differential:
|
||||||
|
delta = -data_points[i - 1]
|
||||||
|
else:
|
||||||
|
delta = data_points[i] - data_points[i - 1]
|
||||||
|
v_curr = delta / (current_time - next_time)
|
||||||
|
v_prev = kinetic_energy_to_velocity(work)
|
||||||
|
work += (v_curr - v_prev) * abs(v_curr)
|
||||||
|
if i == start:
|
||||||
|
work = work * 0.5
|
||||||
|
return kinetic_energy_to_velocity(work)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_velocity(samples):
|
||||||
|
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
|
||||||
|
|
||||||
|
`samples` is `(time_millis, position)` oldest first, at most the last
|
||||||
|
`HISTORY_SIZE` of which the circular buffer would still be holding.
|
||||||
|
Returns units per second.
|
||||||
|
"""
|
||||||
|
held = samples[-HISTORY_SIZE:]
|
||||||
|
if not held:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
data_points = []
|
||||||
|
time = []
|
||||||
|
newest_time, _ = held[-1]
|
||||||
|
previous_time = newest_time
|
||||||
|
for sample_time, sample_position in reversed(held):
|
||||||
|
age = float(newest_time - sample_time)
|
||||||
|
delta = abs(float(sample_time - previous_time))
|
||||||
|
# Lsq2 walks back sample to sample; only the non-differential
|
||||||
|
# Impulse branch compares every sample against the newest one.
|
||||||
|
previous_time = sample_time
|
||||||
|
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
|
||||||
|
break
|
||||||
|
data_points.append(sample_position)
|
||||||
|
time.append(-age)
|
||||||
|
if len(data_points) == HISTORY_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
|
||||||
|
# which is the newest sample's timestamp. units/ms -> units/s.
|
||||||
|
return coefficients[1] * 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def clamped(velocity, maximum):
|
||||||
|
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
|
||||||
|
if velocity == 0.0 or math.isnan(velocity):
|
||||||
|
return 0.0
|
||||||
|
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def average(samples):
|
||||||
|
"""The estimator being replaced: total motion over the span."""
|
||||||
|
if len(samples) < 2:
|
||||||
|
return 0.0
|
||||||
|
span = (samples[-1][0] - samples[0][0]) / 1000.0
|
||||||
|
if span <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
return (samples[-1][1] - samples[0][1]) / span
|
||||||
|
|
||||||
|
|
||||||
|
# --- The three recorded sample sets the Rust tests assert on. ----------------
|
||||||
|
|
||||||
|
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
|
||||||
|
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
|
||||||
|
# sample (see the module doc), which is why the finger sitting still for its
|
||||||
|
# last 4ms does not drag the estimate down. y only; the flick is vertical.
|
||||||
|
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
|
||||||
|
|
||||||
|
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
|
||||||
|
# average must agree here -- this is the case that cannot tell the two
|
||||||
|
# estimators apart, which is why it is not the only one.
|
||||||
|
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
|
||||||
|
|
||||||
|
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
|
||||||
|
# This is the case the average gets wrong, and the negative control for
|
||||||
|
# the port -- reverting to the average must fail this test and only this
|
||||||
|
# kind of test.
|
||||||
|
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
|
||||||
|
|
||||||
|
# 4. The two edges of the sample walk, checked here so the Rust asserts
|
||||||
|
# Compose's answer rather than iris's own reading of the rule.
|
||||||
|
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
|
||||||
|
# drag: the burst must not leak into the estimate.
|
||||||
|
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
|
||||||
|
# (b) The finger stops for 48ms and then lifts. The gap exceeds
|
||||||
|
# AssumePointerMoveStopped, so the walk breaks after one sample and
|
||||||
|
# there is no fling -- what stops a "park it and let go" from
|
||||||
|
# flinging at whatever speed the finger arrived with.
|
||||||
|
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
||||||
|
|
||||||
|
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
|
||||||
|
# press and two move frames, which is the fewest a fit can use.
|
||||||
|
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
|
||||||
|
# ... and one move frame, which Compose cannot fit either.
|
||||||
|
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
|
||||||
|
|
||||||
|
# The phone: 1080x2424 at content_scale 2.55.
|
||||||
|
PHONE_DENSITY = 2.55
|
||||||
|
|
||||||
|
|
||||||
|
def report(name, samples):
|
||||||
|
v = calculate_velocity(samples)
|
||||||
|
print(f"{name}:")
|
||||||
|
print(f" samples (t_ms, position): {samples}")
|
||||||
|
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
|
||||||
|
print(f" average (the old estimator): {average(samples):.4f} px/s")
|
||||||
|
print(f" impulse (non-touch, for ref): ", end="")
|
||||||
|
held = list(reversed(samples[-HISTORY_SIZE:]))
|
||||||
|
newest = held[0][0]
|
||||||
|
print(
|
||||||
|
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
|
||||||
|
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
|
||||||
|
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
|
||||||
|
report("flick-120hz.touch", FLICK_120HZ)
|
||||||
|
report("steady drag (5px/10ms)", STEADY_DRAG)
|
||||||
|
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
|
||||||
|
|
||||||
|
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
|
||||||
|
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
|
||||||
|
report("press and two move frames", TWO_MOVE_FRAMES)
|
||||||
|
report("press and one move frame", ONE_MOVE_FRAME)
|
||||||
|
|
||||||
|
print("Clamps:")
|
||||||
|
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
|
||||||
|
print(
|
||||||
|
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
|
||||||
|
)
|
||||||
|
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
|
||||||
|
print()
|
||||||
|
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
|
||||||
|
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Ryan L McIntyre
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuilds iris/core/assets/fonts/nerd_icons.ttf.
|
||||||
|
#
|
||||||
|
# iris draws its icons as glyphs in a Nerd Fonts subset it ships, rather
|
||||||
|
# than as ordinary Unicode out of whatever the platform resolved. Unicode's
|
||||||
|
# own geometric shapes are what this replaced: `tool.rs` set its disclosure
|
||||||
|
# mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts
|
||||||
|
# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a
|
||||||
|
# dot. UI_RULES: "don't rely on characters the platform might not have --
|
||||||
|
# ship the glyph or the asset rather than hoping."
|
||||||
|
#
|
||||||
|
# The whole symbols font is 3 MB for the handful below, so what is
|
||||||
|
# committed is a subset. Add a codepoint to GLYPHS *and* to
|
||||||
|
# `iris/core/src/icon.rs` (the two lists have to agree -- a codepoint in
|
||||||
|
# the Rust that this script did not subset is a glyph that silently isn't
|
||||||
|
# there), then run this and commit the result.
|
||||||
|
#
|
||||||
|
# Needs python3 and network access; fontTools is fetched into a temporary
|
||||||
|
# venv, so nothing has to be installed on the machine.
|
||||||
|
#
|
||||||
|
# The same arrangement as the Compose app's `app/build-icon-font.sh`, which
|
||||||
|
# this is copied from -- including the Mono face and the Material Design
|
||||||
|
# family, so an icon means the same thing in both apps. Copied rather than
|
||||||
|
# shared because most of it is the GLYPHS list, which has to differ: the
|
||||||
|
# point of subsetting is to ship only the codepoints one app draws.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
|
||||||
|
# Icons, as in the Compose app.
|
||||||
|
GLYPHS=(
|
||||||
|
U+F035D # md-menu_down -- a card that is open
|
||||||
|
U+F035F # md-menu_right -- a card that opens
|
||||||
|
U+F0360 # md-menu_up -- collapse this group again
|
||||||
|
)
|
||||||
|
|
||||||
|
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
out="$here/assets/fonts/nerd_icons.ttf"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
echo "Fetching $url"
|
||||||
|
curl -fsSL -o "$work/nf.zip" "$url"
|
||||||
|
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
|
||||||
|
|
||||||
|
python3 -m venv "$work/venv"
|
||||||
|
"$work/venv/bin/pip" -q install fonttools
|
||||||
|
|
||||||
|
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
# The Mono face, where every glyph is one em wide and one em tall, so two
|
||||||
|
# icons at the same font size are the same size without either being given
|
||||||
|
# one -- the same reason the Compose app's script takes it. It is also what
|
||||||
|
# makes an icon's box predictable beside a line of text.
|
||||||
|
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
|
||||||
|
--unicodes="$unicodes" \
|
||||||
|
--layout-features= \
|
||||||
|
--drop-tables+=DSIG \
|
||||||
|
--output-file="$out"
|
||||||
|
cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt"
|
||||||
|
|
||||||
|
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
|
||||||
@@ -79,6 +79,8 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
|
|||||||
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||||
// TODO: reduce visiblity!!
|
// TODO: reduce visiblity!!
|
||||||
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
||||||
|
/// This event's own input-wide state -- see [`Event::Global`].
|
||||||
|
pub global: E::Global,
|
||||||
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,7 @@ impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
|
global: Default::default(),
|
||||||
map: Default::default(),
|
map: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ pub use rsc::*;
|
|||||||
pub trait Event: Sized + 'static + Clone {
|
pub trait Event: Sized + 'static + Clone {
|
||||||
type Data<'a>: Clone = ();
|
type Data<'a>: Clone = ();
|
||||||
type State: Default = ();
|
type State: Default = ();
|
||||||
|
/// State this event owns that belongs to no single widget -- what the
|
||||||
|
/// thing dispatching the event knows about the *input*, rather than
|
||||||
|
/// about a listener. `()` for almost every event; the cursor's is
|
||||||
|
/// `iris::sense::PointerInput` (which widget holds pointer capture,
|
||||||
|
/// and who is tracking the press in flight).
|
||||||
|
///
|
||||||
|
/// It lives here so that such state has one owner, reached by `&mut`
|
||||||
|
/// through the event manager, instead of being parked on whatever
|
||||||
|
/// structure a handler happens to be able to reach and guarded with a
|
||||||
|
/// lock. Iris asked for that on 2026-09-08, of the pointer capture
|
||||||
|
/// that used to sit in a `Mutex` on `UiRenderState`: "everything
|
||||||
|
/// global should be stored in the general input handler, not in
|
||||||
|
/// specific senses with locking stuff."
|
||||||
|
type Global: Default = ();
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||||
Some(data.clone())
|
Some(data.clone())
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships.
|
||||||
|
//!
|
||||||
|
//! **Why a bundled font rather than ordinary Unicode**: the disclosure
|
||||||
|
//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform
|
||||||
|
//! resolved, and once iris stopped bundling fonts (DECISIONS.md,
|
||||||
|
//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a
|
||||||
|
//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is
|
||||||
|
//! also what the Compose app has always done for its icons
|
||||||
|
//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design
|
||||||
|
//! family, so an icon means the same thing in both apps.
|
||||||
|
//!
|
||||||
|
//! **Why not vector assets or drawn shapes**: an icon beside a line of
|
||||||
|
//! text wants that line's size, colour and baseline, and text gets all
|
||||||
|
//! three for free. This replaced `iris::widget::mark`, which drew the
|
||||||
|
//! triangle into a texture: correct, but one shape, and every further icon
|
||||||
|
//! would have been another bespoke rasteriser.
|
||||||
|
//!
|
||||||
|
//! Each constant here has to have a matching codepoint in
|
||||||
|
//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the
|
||||||
|
//! script did not subset is a glyph that silently isn't there. The subset
|
||||||
|
//! is the font's **Mono** face, where every glyph is one em wide and one
|
||||||
|
//! em tall, so two icons at one font size are one size without either
|
||||||
|
//! being given one -- and why an icon looks smaller than text at the same
|
||||||
|
//! size, since the glyph is drawn inside that em rather than filling it.
|
||||||
|
//!
|
||||||
|
//! Draw one with [`crate::Family::Icons`]:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
/// `md-menu_down` -- a filled triangle pointing down: this card is open.
|
||||||
|
pub const OPEN: &str = "\u{F035D}";
|
||||||
|
|
||||||
|
/// `md-menu_right` -- pointing right: this card opens.
|
||||||
|
pub const CLOSED: &str = "\u{F035F}";
|
||||||
|
|
||||||
|
/// `md-menu_up` -- pointing up: fold this group of cards away again.
|
||||||
|
pub const COLLAPSE: &str = "\u{F0360}";
|
||||||
@@ -19,6 +19,7 @@ mod render;
|
|||||||
mod ui;
|
mod ui;
|
||||||
mod widget;
|
mod widget;
|
||||||
|
|
||||||
|
pub mod icon;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use attr::*;
|
pub use attr::*;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||||
pub enum Axis {
|
pub enum Axis {
|
||||||
X,
|
X,
|
||||||
Y,
|
Y,
|
||||||
|
|||||||
@@ -147,6 +147,29 @@ impl Len {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
use std::ops::{Index, IndexMut};
|
use std::ops::{Index, IndexMut};
|
||||||
|
|
||||||
use crate::{
|
use crate::{render::LayerOrder, util::to_mut};
|
||||||
UiRegion, WidgetId,
|
|
||||||
render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
|
|
||||||
util::to_mut,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type LayerId = usize;
|
pub type LayerId = usize;
|
||||||
|
|
||||||
@@ -40,7 +36,10 @@ struct Child {
|
|||||||
tail: usize,
|
tail: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type PrimitiveLayers = Layers<Primitives>;
|
/// The draw order of every layer. The primitives themselves live in one
|
||||||
|
/// arena beside this (`UiRenderState::primitives`); a layer names the
|
||||||
|
/// slots it draws, which is what its vertex buffer is.
|
||||||
|
pub type PrimitiveLayers = Layers<LayerOrder>;
|
||||||
|
|
||||||
impl<T: Default> Layers<T> {
|
impl<T: Default> Layers<T> {
|
||||||
pub fn new() -> Layers<T> {
|
pub fn new() -> Layers<T> {
|
||||||
@@ -120,32 +119,6 @@ impl<T: Default> Layers<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveLayers {
|
|
||||||
pub fn write<P: Primitive>(
|
|
||||||
&mut self,
|
|
||||||
layer: LayerId,
|
|
||||||
info: PrimitiveInst<P>,
|
|
||||||
) -> PrimitiveHandle {
|
|
||||||
self[layer].write(layer, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
|
||||||
self[h.layer].free(h)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_image(
|
|
||||||
&mut self,
|
|
||||||
layer: LayerId,
|
|
||||||
id: WidgetId,
|
|
||||||
texture_idx: u32,
|
|
||||||
region: UiRegion,
|
|
||||||
mask_idx: MaskIdx,
|
|
||||||
move_idx: MoveIdx,
|
|
||||||
) -> PrimitiveHandle {
|
|
||||||
self[layer].write_image(layer, id, texture_idx, region, mask_idx, move_idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Default> Default for Layers<T> {
|
impl<T: Default> Default for Layers<T> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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},
|
fontique::Blob,
|
||||||
};
|
};
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -12,23 +12,16 @@ use swash::{
|
|||||||
zeno::{Format, Vector},
|
zeno::{Format, Vector},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bundled fonts, registered over the system collection rather than relied
|
/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
|
||||||
/// on alone -- see `TextData::register_bundled_fonts`'s doc comment for
|
/// by `iris/core/build-icon-font.sh`, holding only the codepoints
|
||||||
/// why. Static weight/style cuts, not a variable font: parley/fontique
|
/// `crate::icon` names (992 bytes for three glyphs today).
|
||||||
/// resolve a variable font's weight axis by picking normalized coordinates
|
///
|
||||||
/// on whatever single face registers for the family, and a phone whose
|
/// This is the one font bundled here, and it is not a text font: body and
|
||||||
/// system "Roboto" is actually the variable "Roboto Flex" is exactly the
|
/// monospace text still come from the platform's own collection
|
||||||
/// device class this sidesteps, rather than depends on working correctly.
|
/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small,
|
||||||
/// Noto Sans, OFL-licensed (`assets/fonts/OFL.txt`), chosen for coverage
|
/// closed set of codepoints no system font is guaranteed to have -- which
|
||||||
/// breadth (a transcript's content is not known in advance) over a
|
/// is the same division the Compose app makes.
|
||||||
/// smaller-footprint alternative -- see the doc comment for the size this
|
const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf");
|
||||||
/// 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
|
/// 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
|
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
|
||||||
@@ -57,6 +50,11 @@ pub struct FontDiagnostics {
|
|||||||
pub bold_resolved: Option<String>,
|
pub bold_resolved: Option<String>,
|
||||||
pub italic_resolved: Option<String>,
|
pub italic_resolved: Option<String>,
|
||||||
pub mono_resolved: Option<String>,
|
pub mono_resolved: Option<String>,
|
||||||
|
/// The family the bundled icon font registered under, or `None` if
|
||||||
|
/// registering it failed. Reported rather than assumed: it is the one
|
||||||
|
/// font iris ships, so `None` is a broken build and must not look
|
||||||
|
/// like a device that happens to lack a face.
|
||||||
|
pub icon_family: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything text needs that outlives one string: the font collection, the
|
/// Everything text needs that outlives one string: the font collection, the
|
||||||
@@ -77,91 +75,175 @@ pub struct TextData {
|
|||||||
/// truth would mean carrying a `Painter` (or output size) into every
|
/// truth would mean carrying a `Painter` (or output size) into every
|
||||||
/// input handler for the sake of one field.
|
/// input handler for the sake of one field.
|
||||||
pub density: f32,
|
pub density: f32,
|
||||||
|
/// The family name [`NERD_ICONS`] registered under, which is what
|
||||||
|
/// [`Family::Icons`] resolves to. `None` only if registering the
|
||||||
|
/// bundled font failed, which is a broken build rather than a
|
||||||
|
/// platform difference -- said in the startup diagnostics rather than
|
||||||
|
/// silently drawn as tofu.
|
||||||
|
pub icon_family: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TextData {
|
impl Default for TextData {
|
||||||
|
/// Text comes entirely from the platform's own font collection --
|
||||||
|
/// `FontContext::new()` builds a `fontique::Collection` with
|
||||||
|
/// `CollectionOptions::system_fonts` on by default, which is real
|
||||||
|
/// discovery on both targets this crate ships on: Android's backend
|
||||||
|
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
|
||||||
|
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
|
||||||
|
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
|
||||||
|
/// build's backend is fontconfig. No font is bundled or registered
|
||||||
|
/// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what
|
||||||
|
/// the Compose app does: it takes body/monospace text from
|
||||||
|
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
|
||||||
|
/// and its platform monospace face, and ships no text font of its own,
|
||||||
|
/// only its committed Nerd Fonts icon subset for fixed glyphs).
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let mut data = Self {
|
let mut font_cx = FontContext::new();
|
||||||
font_cx: FontContext::new(),
|
patch_android_monospace(&mut font_cx);
|
||||||
|
let icon_family = register_icon_font(&mut font_cx);
|
||||||
|
Self {
|
||||||
|
font_cx,
|
||||||
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,
|
density: 1.0,
|
||||||
};
|
icon_family,
|
||||||
data.register_bundled_fonts();
|
}
|
||||||
data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextData {
|
/// Registers the bundled icon font as an ordinary named family and
|
||||||
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
|
/// answers the name it registered under -- read back from the collection
|
||||||
/// Mono (regular/bold) as static faces, and puts them **first** in the
|
/// rather than written down here, so the name cannot drift from the file
|
||||||
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
|
/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release
|
||||||
/// not instead of, whatever the platform already found, so a script
|
/// ships).
|
||||||
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
|
///
|
||||||
/// font the same as before this existed.
|
/// A *named* family rather than a generic one: nothing should fall back
|
||||||
///
|
/// to it for ordinary text, and nothing should fall back out of it for an
|
||||||
/// Exists because text rendering must not depend on the platform's own
|
/// icon -- a system face that happens to have one of these codepoints
|
||||||
/// font enumeration succeeding or resolving weight/style the way this
|
/// would draw somebody else's picture.
|
||||||
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
|
fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
|
||||||
/// rendering as blank gaps of the correct advance width (the glyph
|
let blob = Blob::new(Arc::new(NERD_ICONS));
|
||||||
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
|
let id = font_cx
|
||||||
/// the emulator's system fonts happened to resolve every style. A
|
.collection
|
||||||
/// bundled, static-per-style family removes fontique's Android font
|
.register_fonts(blob, None)
|
||||||
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
|
.into_iter()
|
||||||
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
|
.map(|(id, _)| id)
|
||||||
/// has to survive to reach the screen at all.
|
.next()?;
|
||||||
///
|
font_cx.collection.family_name(id).map(str::to_string)
|
||||||
/// 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 {
|
/// Works around `fontique` 0.11.1's Android backend never resolving
|
||||||
let existing: Vec<_> = self
|
/// `GenericFamily::Monospace` (confirmed against
|
||||||
.font_cx
|
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
|
||||||
|
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
|
||||||
|
/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry,
|
||||||
|
/// "Platform fonts," for the full account). Two bugs stack, not one:
|
||||||
|
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
|
||||||
|
/// `fonts.xml` is parsed into that same name map, and even after parsing,
|
||||||
|
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
|
||||||
|
/// (not an `<alias>`) whose `<font>` children the backend's own parser
|
||||||
|
/// does not read (a `TODO` in that match arm) -- so the name gets a
|
||||||
|
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
|
||||||
|
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
|
||||||
|
/// /system/etc/fonts.xml` shows
|
||||||
|
/// `<family name="monospace"><font weight="400"
|
||||||
|
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
|
||||||
|
/// alias.
|
||||||
|
///
|
||||||
|
/// So this reads `fonts.xml` itself (already on-device, already the
|
||||||
|
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
|
||||||
|
/// filename that declaration names, then finds which of fontique's
|
||||||
|
/// *actually* scanned families (from `/system/fonts`, which do carry real
|
||||||
|
/// font data, just under whatever name the font's own metadata gives it --
|
||||||
|
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
|
||||||
|
/// file with that name, and registers that family as the `Monospace`
|
||||||
|
/// generic the way the backend itself would have if its parser had reified
|
||||||
|
/// the declaration. A no-op if the family is somehow already resolved
|
||||||
|
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
|
||||||
|
/// test, or a device that names it some other way).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn patch_android_monospace(font_cx: &mut FontContext) {
|
||||||
|
use parley::fontique::SourceKind;
|
||||||
|
|
||||||
|
let already_resolved = font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::Monospace)
|
||||||
|
.next()
|
||||||
|
.is_some();
|
||||||
|
if already_resolved {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(target_file) = android_monospace_font_filename() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let names: Vec<String> = font_cx
|
||||||
|
.collection
|
||||||
|
.family_names()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
for name in names {
|
||||||
|
let Some(id) = font_cx.collection.family_id(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(info) = font_cx.collection.family(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(font) = info.default_font() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let SourceKind::Path(path) = font.source().kind() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) {
|
||||||
|
font_cx
|
||||||
.collection
|
.collection
|
||||||
.generic_families(GenericFamily::SansSerif)
|
.append_generic_families(GenericFamily::Monospace, std::iter::once(id));
|
||||||
.collect();
|
return;
|
||||||
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
|
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
|
||||||
.generic_families(GenericFamily::Monospace)
|
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
|
||||||
.collect();
|
/// real XML parser -- a new dependency for one well-known, stable AOSP file
|
||||||
self.font_cx.collection.set_generic_families(
|
/// whose structure fontique itself already parses with a full parser one
|
||||||
GenericFamily::Monospace,
|
/// module over. Not a general XML reader; assumes the file has exactly one
|
||||||
std::iter::once(mono_id).chain(existing),
|
/// `<family name="monospace">` element with at least one `<font>` child,
|
||||||
);
|
/// which is the format on every AOSP `fonts.xml` this was checked against.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn android_monospace_font_filename() -> Option<String> {
|
||||||
|
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
|
||||||
|
let xml =
|
||||||
|
std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?;
|
||||||
|
let family_start = xml.find("<family name=\"monospace\">")?;
|
||||||
|
let block = &xml[family_start..];
|
||||||
|
let block = &block[..block.find("</family>")?];
|
||||||
|
let font_tag = block.find("<font")?;
|
||||||
|
let after_tag = &block[font_tag..];
|
||||||
|
let content_start = after_tag.find('>')? + 1;
|
||||||
|
let content = &after_tag[content_start..];
|
||||||
|
let filename = content[..content.find('<')?].trim();
|
||||||
|
(!filename.is_empty()).then(|| filename.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn patch_android_monospace(_font_cx: &mut FontContext) {}
|
||||||
|
|
||||||
|
impl TextData {
|
||||||
|
/// [`Family::Icons`] as the name the bundled font actually registered
|
||||||
|
/// under; everything else unchanged.
|
||||||
|
///
|
||||||
|
/// Cloned rather than borrowed because the caller needs it while the
|
||||||
|
/// layout builder holds `&mut self` -- a `String` per shaped icon run,
|
||||||
|
/// paid only when the layout is rebuilt.
|
||||||
|
pub fn resolve_family(&self, family: &Family) -> Family {
|
||||||
|
match family {
|
||||||
|
Family::Icons => self
|
||||||
|
.icon_family
|
||||||
|
.clone()
|
||||||
|
.map_or(Family::Icons, Family::Named),
|
||||||
|
other => other.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +324,7 @@ impl TextData {
|
|||||||
bold_resolved,
|
bold_resolved,
|
||||||
italic_resolved,
|
italic_resolved,
|
||||||
mono_resolved,
|
mono_resolved,
|
||||||
|
icon_family: self.icon_family.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +336,11 @@ pub enum Family {
|
|||||||
SansSerif,
|
SansSerif,
|
||||||
Serif,
|
Serif,
|
||||||
Monospace,
|
Monospace,
|
||||||
|
/// The bundled icon font -- see [`crate::icon`] for what is in it.
|
||||||
|
/// Named as an intention rather than as a font name because only
|
||||||
|
/// [`TextData`] knows what the file registered as; it resolves this
|
||||||
|
/// during shaping ([`TextData::resolve_family`]).
|
||||||
|
Icons,
|
||||||
Named(String),
|
Named(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +350,11 @@ impl Family {
|
|||||||
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||||
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
||||||
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
||||||
|
// Only reachable if `resolve_family` did not run, which no
|
||||||
|
// shaping path allows -- and sans-serif is the honest answer
|
||||||
|
// for a build whose icon font failed to register: the reader
|
||||||
|
// gets the platform's own tofu rather than a wrong picture.
|
||||||
|
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||||
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
||||||
};
|
};
|
||||||
FontFamily::Single(name)
|
FontFamily::Single(name)
|
||||||
@@ -460,21 +553,29 @@ impl TextBuffer {
|
|||||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Resolved before the builder borrows `data`: `Family::Icons`
|
||||||
|
// names an intention, and the name behind it lives on `TextData`.
|
||||||
|
let base_family = data.resolve_family(&attrs.family);
|
||||||
|
let span_families: Vec<Option<Family>> = self
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
|
||||||
|
.collect();
|
||||||
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(base_family.family()));
|
||||||
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
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 * density,
|
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, family) in self.spans.iter().zip(&span_families) {
|
||||||
let range = span.range.clone();
|
let range = span.range.clone();
|
||||||
if let Some(color) = span.color {
|
if let Some(color) = span.color {
|
||||||
builder.push(StyleProperty::Brush(color), range.clone());
|
builder.push(StyleProperty::Brush(color), range.clone());
|
||||||
}
|
}
|
||||||
if let Some(family) = &span.family {
|
if let Some(family) = family {
|
||||||
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 {
|
||||||
@@ -601,6 +702,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 {
|
||||||
@@ -619,6 +725,53 @@ impl TextData {
|
|||||||
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::icon;
|
||||||
|
|
||||||
|
/// Every codepoint `icon` names is actually in the subset the script
|
||||||
|
/// built. This is the failure `build-icon-font.sh`'s own comment warns
|
||||||
|
/// about -- a constant added on one side and not the other is a glyph
|
||||||
|
/// that silently isn't there -- and it is invisible at runtime,
|
||||||
|
/// because a missing glyph draws as nothing rather than as an error.
|
||||||
|
#[test]
|
||||||
|
fn every_icon_is_in_the_bundled_font() {
|
||||||
|
let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses");
|
||||||
|
let charmap = font.charmap();
|
||||||
|
for (name, glyph) in [
|
||||||
|
("OPEN", icon::OPEN),
|
||||||
|
("CLOSED", icon::CLOSED),
|
||||||
|
("COLLAPSE", icon::COLLAPSE),
|
||||||
|
] {
|
||||||
|
let mut chars = glyph.chars();
|
||||||
|
let ch = chars.next().expect("an icon is one character");
|
||||||
|
assert!(chars.next().is_none(), "{name} is more than one character");
|
||||||
|
assert_ne!(
|
||||||
|
charmap.map(ch),
|
||||||
|
0,
|
||||||
|
"{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \
|
||||||
|
build-icon-font.sh's GLYPHS and rerun the script",
|
||||||
|
ch as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The font registers, so `Family::Icons` resolves to a real family
|
||||||
|
/// rather than falling through to sans-serif and drawing tofu.
|
||||||
|
#[test]
|
||||||
|
fn the_icon_family_registers_and_resolves() {
|
||||||
|
let data = TextData::default();
|
||||||
|
let family = data.resolve_family(&Family::Icons);
|
||||||
|
assert!(
|
||||||
|
matches!(family, Family::Named(_)),
|
||||||
|
"the bundled icon font did not register: {:?}",
|
||||||
|
data.icon_family
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::util::{RefCounter, Vec2};
|
use crate::util::{RefCounter, Vec2};
|
||||||
use image::{DynamicImage, GenericImageView};
|
use image::{DynamicImage, GenericImageView};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
ops::Index,
|
ops::Index,
|
||||||
sync::mpsc::{Receiver, Sender, channel},
|
sync::mpsc::{Receiver, Sender, channel},
|
||||||
};
|
};
|
||||||
@@ -21,6 +22,16 @@ pub enum TextureKind {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
|
||||||
|
/// hash: `owner` names the widget kind whose description it is, and `id`
|
||||||
|
/// packs that description's own fields, so two owners cannot collide and
|
||||||
|
/// a debugger shows which picture a slot holds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SharedTextureKey {
|
||||||
|
pub owner: &'static str,
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TextureHandle {
|
pub struct TextureHandle {
|
||||||
slot: u32,
|
slot: u32,
|
||||||
@@ -35,6 +46,14 @@ pub struct TextureHandle {
|
|||||||
pub struct Textures {
|
pub struct Textures {
|
||||||
free: Vec<u32>,
|
free: Vec<u32>,
|
||||||
images: Vec<Option<DynamicImage>>,
|
images: Vec<Option<DynamicImage>>,
|
||||||
|
/// What each slot is, kept beside the image so a slot can be pushed
|
||||||
|
/// again without the handle that knows -- see [`Textures::reupload`].
|
||||||
|
kinds: Vec<TextureKind>,
|
||||||
|
/// Textures built from a description rather than from a file, one per
|
||||||
|
/// distinct description: see [`Textures::shared`]. The map holds a
|
||||||
|
/// reference of its own, so a shared texture outlives every widget
|
||||||
|
/// drawing it and its slot is never recycled underneath one.
|
||||||
|
shared: HashMap<SharedTextureKey, TextureHandle>,
|
||||||
/// Next layer to hand out to an atlas page. Pages are never freed (no
|
/// Next layer to hand out to an atlas page. Pages are never freed (no
|
||||||
/// atlas eviction), so this only grows and `free` never holds one.
|
/// atlas eviction), so this only grows and `free` never holds one.
|
||||||
next_page_layer: u32,
|
next_page_layer: u32,
|
||||||
@@ -77,6 +96,8 @@ impl Textures {
|
|||||||
Self {
|
Self {
|
||||||
free: Vec::new(),
|
free: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
|
kinds: Vec::new(),
|
||||||
|
shared: HashMap::new(),
|
||||||
next_page_layer: 0,
|
next_page_layer: 0,
|
||||||
updates: Vec::new(),
|
updates: Vec::new(),
|
||||||
send,
|
send,
|
||||||
@@ -119,16 +140,46 @@ impl Textures {
|
|||||||
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
|
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
|
||||||
if let Some(i) = self.free.pop() {
|
if let Some(i) = self.free.pop() {
|
||||||
self.images[i as usize] = Some(image);
|
self.images[i as usize] = Some(image);
|
||||||
|
self.kinds[i as usize] = kind;
|
||||||
self.updates.push(Update::Set(kind, i));
|
self.updates.push(Update::Set(kind, i));
|
||||||
i
|
i
|
||||||
} else {
|
} else {
|
||||||
let i = self.images.len() as u32;
|
let i = self.images.len() as u32;
|
||||||
self.images.push(Some(image));
|
self.images.push(Some(image));
|
||||||
|
self.kinds.push(kind);
|
||||||
self.updates.push(Update::Push(kind, i));
|
self.updates.push(Update::Push(kind, i));
|
||||||
i
|
i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one texture for `key`, building it on the first ask and handing
|
||||||
|
/// out a further reference to it every time after.
|
||||||
|
///
|
||||||
|
/// **Why this exists**: a texture rasterised from a *description* --
|
||||||
|
/// `widget::mark`'s triangle, from a direction and a colour -- has as
|
||||||
|
/// many copies as there are widgets asking for it, and each copy is
|
||||||
|
/// its own GPU texture, its own bind group and its own draw call. A
|
||||||
|
/// transcript screen with a folded card per tool call built one per
|
||||||
|
/// card: hundreds of 48x48 textures of three distinct pictures,
|
||||||
|
/// created and freed again as rows recycled. `make` is not called when
|
||||||
|
/// the key is already known, so the rasterising is paid once too.
|
||||||
|
///
|
||||||
|
/// The map keeps its own reference for the life of the `Textures`, so
|
||||||
|
/// a shared slot is never freed and never reused for something else --
|
||||||
|
/// which is what makes a handle held by a long-lived widget safe.
|
||||||
|
pub fn shared(
|
||||||
|
&mut self,
|
||||||
|
key: SharedTextureKey,
|
||||||
|
make: impl FnOnce() -> DynamicImage,
|
||||||
|
) -> TextureHandle {
|
||||||
|
if let Some(handle) = self.shared.get(&key) {
|
||||||
|
return handle.clone();
|
||||||
|
}
|
||||||
|
let handle = self.add(make());
|
||||||
|
self.shared.insert(key, handle.clone());
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
/// The stored image for a handle, to be written into before `patch`.
|
/// The stored image for a handle, to be written into before `patch`.
|
||||||
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
||||||
self.images[handle.slot as usize]
|
self.images[handle.slot as usize]
|
||||||
@@ -141,6 +192,37 @@ impl Textures {
|
|||||||
self.updates.push(Update::Patch(handle.slot, rect));
|
self.updates.push(Update::Patch(handle.slot, rect));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queue every live slot for upload again, in slot order -- what a
|
||||||
|
/// genuinely new GPU device needs, in place of forgetting everything.
|
||||||
|
///
|
||||||
|
/// A new device starts with no textures, and the renderer-side mirror
|
||||||
|
/// of these slots (`render::texture::GpuTextures`) starts empty with
|
||||||
|
/// it. What it must not do is start empty while the handles widgets
|
||||||
|
/// are still holding name slots by *index*: `Textures::reset` used to
|
||||||
|
/// throw this bookkeeping away, which left every live `TextureHandle`
|
||||||
|
/// -- one per `widget::mark`, hundreds on a transcript screen --
|
||||||
|
/// pointing at a slot nothing recognised, and the first frame after an
|
||||||
|
/// Android surface rebuild panicked in `image_bind_group` ("texture
|
||||||
|
/// slot 89 is not a live standalone image: None"). Re-uploading
|
||||||
|
/// instead keeps every index meaning what it meant, because this side
|
||||||
|
/// still holds the images: the slot list is rebuilt identically,
|
||||||
|
/// including the empty slots, which go across as `PushFree` so the
|
||||||
|
/// ones after them still land where they were.
|
||||||
|
///
|
||||||
|
/// The glyph atlas comes back with it and is deliberately *not*
|
||||||
|
/// cleared any more: its pages are slots here, this side holds their
|
||||||
|
/// pixels, and re-uploading them restores exactly the atlas that was
|
||||||
|
/// there -- so an app switch no longer costs a re-rasterisation of
|
||||||
|
/// every glyph on screen either.
|
||||||
|
///
|
||||||
|
/// Pending updates are dropped rather than kept: each is either a push
|
||||||
|
/// or a patch of a slot this replays in full.
|
||||||
|
pub fn reupload(&mut self) {
|
||||||
|
self.updates.clear();
|
||||||
|
self.updates
|
||||||
|
.extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i)));
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -224,3 +306,90 @@ impl Default for Textures {
|
|||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use image::RgbaImage;
|
||||||
|
|
||||||
|
fn image(n: u32) -> DynamicImage {
|
||||||
|
RgbaImage::new(n, n).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: u64) -> SharedTextureKey {
|
||||||
|
SharedTextureKey { owner: "test", id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `widget::mark` needs: one texture per description, however
|
||||||
|
/// many widgets ask for it, and a different description is a
|
||||||
|
/// different texture.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_texture_is_built_once_and_handed_out_again() {
|
||||||
|
let mut textures = Textures::new();
|
||||||
|
let built = std::cell::Cell::new(0);
|
||||||
|
let make = |textures: &mut Textures, id: u64| {
|
||||||
|
textures.shared(key(id), || {
|
||||||
|
built.set(built.get() + 1);
|
||||||
|
image(4)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let first = make(&mut textures, 1);
|
||||||
|
let again = make(&mut textures, 1);
|
||||||
|
let other = make(&mut textures, 2);
|
||||||
|
assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again");
|
||||||
|
assert_eq!(first.image_index(), again.image_index());
|
||||||
|
assert_ne!(first.image_index(), other.image_index());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map's own reference is what keeps a shared slot alive: every
|
||||||
|
/// widget holding one can go away and the slot must not be recycled,
|
||||||
|
/// because the next widget to ask gets that same index back.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
|
||||||
|
let mut textures = Textures::new();
|
||||||
|
let slot = textures.shared(key(1), || image(4)).image_index();
|
||||||
|
textures.free();
|
||||||
|
let plain = textures.add(image(4));
|
||||||
|
assert_ne!(
|
||||||
|
plain.image_index(),
|
||||||
|
slot,
|
||||||
|
"an ordinary texture was handed the shared mark's slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new GPU device gets the same slot numbering back, so a handle a
|
||||||
|
/// widget has been holding all along still names its own texture --
|
||||||
|
/// the crash `reupload` replaced `reset` to fix.
|
||||||
|
#[test]
|
||||||
|
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
|
||||||
|
let mut textures = Textures::new();
|
||||||
|
let keep_a = textures.add(image(4));
|
||||||
|
let dropped = textures.add(image(4));
|
||||||
|
let keep_b = textures.add(image(4));
|
||||||
|
let (a, gone, b) = (
|
||||||
|
keep_a.image_index(),
|
||||||
|
dropped.image_index(),
|
||||||
|
keep_b.image_index(),
|
||||||
|
);
|
||||||
|
drop(dropped);
|
||||||
|
textures.free();
|
||||||
|
// Drain the updates so far, the way a frame does.
|
||||||
|
assert!(textures.updates().count() > 0);
|
||||||
|
|
||||||
|
textures.reupload();
|
||||||
|
let kinds: Vec<String> = textures
|
||||||
|
.updates()
|
||||||
|
.map(|u| match u {
|
||||||
|
TextureUpdate::Push(..) => "push".to_string(),
|
||||||
|
TextureUpdate::PushFree(..) => "push-free".to_string(),
|
||||||
|
_ => "other".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
["push", "push-free", "push"],
|
||||||
|
"slots {a}, {gone} (freed) and {b} must replay in order, so the \
|
||||||
|
indices after a hole still land where they were"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ pub struct WindowUniform {
|
|||||||
pub height: f32,
|
pub height: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One primitive's placement and what to draw there, in the one arena
|
||||||
|
/// every layer shares (`Primitives`). Read from a storage buffer by
|
||||||
|
/// **both** shader stages: the vertex stage for the corners of the
|
||||||
|
/// primitive it is drawing, the fragment stage for the corners of a
|
||||||
|
/// *mask's* primitive, which is generally a different one and often in
|
||||||
|
/// another layer. A layer's vertex buffer carries only the slot
|
||||||
|
/// ([`instance_slot_layout`]), so there is exactly one copy of a
|
||||||
|
/// placement and a mask cannot disagree with what was drawn. See
|
||||||
|
/// LAYOUT.md's "Masks with a shape".
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct PrimitiveInstance {
|
pub struct PrimitiveInstance {
|
||||||
@@ -18,24 +27,17 @@ pub struct PrimitiveInstance {
|
|||||||
pub move_idx: MoveIdx,
|
pub move_idx: MoveIdx,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveInstance {
|
/// The vertex layout of a layer's draw order: one `u32` slot into the
|
||||||
const ATTRIBS: [VertexAttribute; 8] = vertex_attr_array![
|
/// global instance arena per instance, stepped per instance. Everything a
|
||||||
0 => Float32x2,
|
/// primitive is made of used to be here as eight vertex attributes; it
|
||||||
1 => Float32x2,
|
/// moved into the storage buffer above so the fragment stage can read it
|
||||||
2 => Float32x2,
|
/// too.
|
||||||
3 => Float32x2,
|
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
|
||||||
4 => Uint32,
|
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
|
||||||
5 => Uint32,
|
VertexBufferLayout {
|
||||||
6 => Uint32,
|
array_stride: std::mem::size_of::<u32>() as BufferAddress,
|
||||||
7 => Uint32,
|
step_mode: VertexStepMode::Instance,
|
||||||
];
|
attributes: &ATTRIBS,
|
||||||
|
|
||||||
pub fn desc() -> VertexBufferLayout<'static> {
|
|
||||||
VertexBufferLayout {
|
|
||||||
array_stride: std::mem::size_of::<Self>() as BufferAddress,
|
|
||||||
step_mode: VertexStepMode::Instance,
|
|
||||||
attributes: &Self::ATTRIBS,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,15 +49,53 @@ impl MaskIdx {
|
|||||||
|
|
||||||
pub type MoveIdx = Id<u32>;
|
pub type MoveIdx = Id<u32>;
|
||||||
|
|
||||||
|
/// A clip, as a reference to a primitive already written plus the mask it
|
||||||
|
/// nests inside. The fragment stage evaluates that primitive's coverage
|
||||||
|
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
|
||||||
|
/// from the same SDF the rect itself is drawn with -- and multiplies it
|
||||||
|
/// into the pixel's alpha, so a rounded container's corner and its
|
||||||
|
/// children's clipped corner are the same arithmetic and cannot disagree.
|
||||||
|
/// See LAYOUT.md's "Masks with a shape".
|
||||||
|
///
|
||||||
|
/// **No `kind` and no `flags`**, which the design sketched: the referenced
|
||||||
|
/// instance already carries its own `binding`, and a copy of it here is a
|
||||||
|
/// second thing to keep in step; alpha-only is the only mode there is, so
|
||||||
|
/// there is nothing to select. Both are a field away if a second mode
|
||||||
|
/// appears.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct Mask {
|
pub struct Mask {
|
||||||
pub region: UiRegion,
|
/// The slot in `UiRenderState::primitives` of the primitive whose
|
||||||
/// The mask-owning widget's own move slot -- resolved in the fragment
|
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
|
||||||
/// shader against the same chain the vertex shader walks for a
|
/// a standalone image would need, respectively, a CPU-side alpha
|
||||||
/// primitive's own corners, so a mask and the content clipped by it
|
/// plane for the hit test to agree with the shader, and a bind-group
|
||||||
/// can move independently. See LAYOUT.md section 2b.
|
/// switch the fragment stage cannot make -- `Painter::set_mask`
|
||||||
pub move_idx: MoveIdx,
|
/// rejects both by name rather than leaving the shader to read a rect
|
||||||
|
/// that is not there.
|
||||||
|
///
|
||||||
|
/// Who owns it depends on which way the mask was set. A plain
|
||||||
|
/// `.masked()` writes its own undrawn rect, so the primitive is in
|
||||||
|
/// the masking widget's `ActiveData::primitives` and lives exactly as
|
||||||
|
/// long as the mask. `.masked_by(shape)` points at a *child's*
|
||||||
|
/// primitive, which that child can free on any redraw of its own --
|
||||||
|
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
|
||||||
|
/// redraw whenever a referenced slot is freed, since that widget's
|
||||||
|
/// own `set_mask` is the only thing that resolves the slot again.
|
||||||
|
pub primitive: u32,
|
||||||
|
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
|
||||||
|
/// clipping nests: the fragment stage walks the chain and multiplies
|
||||||
|
/// every coverage on it, which is what makes a pixel inside two
|
||||||
|
/// feathered corners dimmed by both. Chained rather than intersected
|
||||||
|
/// on the CPU because each mask moves with its own widget -- a code
|
||||||
|
/// fence inside a transcript row carries the row's scroll, the list's
|
||||||
|
/// own box does not, and one region resolved when the fence was last
|
||||||
|
/// drawn gets the second of those wrong as soon as the row moves.
|
||||||
|
///
|
||||||
|
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
|
||||||
|
/// released when the child's own slot goes
|
||||||
|
/// (`UiRenderState::remove`), so the chain cannot outlive what it
|
||||||
|
/// points at.
|
||||||
|
pub parent: MaskIdx,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One widget's cumulative on-screen translation, and the slot of the
|
/// One widget's cumulative on-screen translation, and the slot of the
|
||||||
|
|||||||
@@ -245,6 +245,15 @@ impl FrameReport {
|
|||||||
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||||
/// can slice one whole run's frames by what was happening during each.
|
/// can slice one whole run's frames by what was happening during each.
|
||||||
pub fn mark_phase(&mut self, name: &str) {
|
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 {
|
self.phases.push(PhaseMark {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
start_index: self.total_frames,
|
start_index: self.total_frames,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
UiData, UiRenderState,
|
UiData, UiRenderState,
|
||||||
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
|
render::{
|
||||||
|
data::{PrimitiveInstance, instance_slot_layout},
|
||||||
|
texture::GpuTextures,
|
||||||
|
util::ArrBuf,
|
||||||
|
},
|
||||||
util::{HashMap, Vec2},
|
util::{HashMap, Vec2},
|
||||||
};
|
};
|
||||||
use data::WindowUniform;
|
use data::WindowUniform;
|
||||||
@@ -14,6 +18,7 @@ mod atlas;
|
|||||||
mod data;
|
mod data;
|
||||||
mod frame_report;
|
mod frame_report;
|
||||||
mod primitive;
|
mod primitive;
|
||||||
|
mod sdf;
|
||||||
mod texture;
|
mod texture;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
@@ -21,8 +26,14 @@ pub use atlas::*;
|
|||||||
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
|
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
|
||||||
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
|
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
|
||||||
pub use primitive::*;
|
pub use primitive::*;
|
||||||
|
pub use sdf::{distance_from_rect, rounded_rect_coverage};
|
||||||
|
|
||||||
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
/// The one shader every primitive is drawn with. Public so a test can run
|
||||||
|
/// a function out of it against the CPU transliteration in [`sdf`] --
|
||||||
|
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
|
||||||
|
/// on: a masked corner that cannot be tapped and a masked corner that is
|
||||||
|
/// not drawn are only the same corner while the two agree.
|
||||||
|
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
||||||
|
|
||||||
/// The `wgpu::Limits` both platform backends (`android::render::
|
/// The `wgpu::Limits` both platform backends (`android::render::
|
||||||
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
|
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
|
||||||
@@ -120,6 +131,11 @@ impl WgpuErrorLog {
|
|||||||
pub struct UiRenderNode {
|
pub struct UiRenderNode {
|
||||||
uniform_group: BindGroup,
|
uniform_group: BindGroup,
|
||||||
primitive_layout: BindGroupLayout,
|
primitive_layout: BindGroupLayout,
|
||||||
|
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
|
||||||
|
/// not per layer -- a mask referencing a rect drawn in another layer
|
||||||
|
/// has to be able to read it (see `Primitives`).
|
||||||
|
primitives: PrimitiveBuffers,
|
||||||
|
primitive_group: BindGroup,
|
||||||
rsc_layout: BindGroupLayout,
|
rsc_layout: BindGroupLayout,
|
||||||
rsc_group: BindGroup,
|
rsc_group: BindGroup,
|
||||||
|
|
||||||
@@ -129,6 +145,9 @@ pub struct UiRenderNode {
|
|||||||
active: Vec<usize>,
|
active: Vec<usize>,
|
||||||
window_buffer: Buffer,
|
window_buffer: Buffer,
|
||||||
textures: GpuTextures,
|
textures: GpuTextures,
|
||||||
|
/// Every primitive's placement, read by the vertex stage for the
|
||||||
|
/// primitive being drawn and by the fragment stage for a mask's.
|
||||||
|
instances: ArrBuf<PrimitiveInstance>,
|
||||||
masks: ArrBuf<Mask>,
|
masks: ArrBuf<Mask>,
|
||||||
move_offsets: ArrBuf<MoveOffset>,
|
move_offsets: ArrBuf<MoveOffset>,
|
||||||
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
||||||
@@ -146,16 +165,16 @@ pub struct UiRenderNode {
|
|||||||
masks_group: BindGroup,
|
masks_group: BindGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One layer's vertex buffers: the slots it draws, in order. The
|
||||||
|
/// primitives themselves are in `UiRenderNode::instances`.
|
||||||
struct RenderLayer {
|
struct RenderLayer {
|
||||||
instance: ArrBuf<PrimitiveInstance>,
|
order: ArrBuf<u32>,
|
||||||
primitives: PrimitiveBuffers,
|
/// A standalone image's slots, kept apart from `order` because each
|
||||||
primitive_group: BindGroup,
|
/// one draws with its own bind group -- see `UiRenderNode::draw`.
|
||||||
/// A standalone image's instances, kept apart from `instance` because
|
images: ArrBuf<u32>,
|
||||||
/// each one draws with its own bind group -- see `UiRenderNode::draw`.
|
/// The texture slot each entry of `images` draws with, in the same
|
||||||
image_instance: ArrBuf<PrimitiveInstance>,
|
/// order, refreshed alongside it. Not in the vertex buffer itself
|
||||||
/// The texture slot each entry of `image_instance` draws with, in the
|
/// because it names a bind group, not shader data.
|
||||||
/// same order, refreshed alongside it. Not stored in the vertex buffer
|
|
||||||
/// itself because it names a bind group, not shader data.
|
|
||||||
image_tex_indices: Vec<u32>,
|
image_tex_indices: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +182,8 @@ impl UiRenderNode {
|
|||||||
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
||||||
pass.set_pipeline(&self.pipeline);
|
pass.set_pipeline(&self.pipeline);
|
||||||
pass.set_bind_group(0, &self.uniform_group, &[]);
|
pass.set_bind_group(0, &self.uniform_group, &[]);
|
||||||
|
// Group 1 is global now, so it is set here rather than per layer.
|
||||||
|
pass.set_bind_group(1, &self.primitive_group, &[]);
|
||||||
// Set once, not per layer or per image: masks/move_offsets are read
|
// Set once, not per layer or per image: masks/move_offsets are read
|
||||||
// by every primitive and every standalone image alike, and living
|
// by every primitive and every standalone image alike, and living
|
||||||
// in their own group (rather than folded into group 2 alongside the
|
// in their own group (rather than folded into group 2 alongside the
|
||||||
@@ -172,14 +193,13 @@ impl UiRenderNode {
|
|||||||
pass.set_bind_group(3, &self.masks_group, &[]);
|
pass.set_bind_group(3, &self.masks_group, &[]);
|
||||||
for i in &self.active {
|
for i in &self.active {
|
||||||
let layer = &self.layers[i];
|
let layer = &self.layers[i];
|
||||||
if layer.instance.len() == 0 && layer.image_instance.len() == 0 {
|
if layer.order.len() == 0 && layer.images.len() == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pass.set_bind_group(1, &layer.primitive_group, &[]);
|
if layer.order.len() > 0 {
|
||||||
if layer.instance.len() > 0 {
|
|
||||||
pass.set_bind_group(2, &self.rsc_group, &[]);
|
pass.set_bind_group(2, &self.rsc_group, &[]);
|
||||||
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
|
pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
|
||||||
pass.draw(0..4, 0..layer.instance.len() as u32);
|
pass.draw(0..4, 0..layer.order.len() as u32);
|
||||||
}
|
}
|
||||||
// Images draw after this layer's rects and glyphs, one draw call
|
// Images draw after this layer's rects and glyphs, one draw call
|
||||||
// each with its own bind group. That draws every image "on top"
|
// each with its own bind group. That draws every image "on top"
|
||||||
@@ -188,8 +208,8 @@ impl UiRenderNode {
|
|||||||
// draw order was already undefined before images had their own
|
// draw order was already undefined before images had their own
|
||||||
// list -- nothing before this relied on interleaving a rect
|
// list -- nothing before this relied on interleaving a rect
|
||||||
// between two images at a particular position.
|
// between two images at a particular position.
|
||||||
if layer.image_instance.len() > 0 {
|
if layer.images.len() > 0 {
|
||||||
pass.set_vertex_buffer(0, layer.image_instance.buffer.slice(..));
|
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
|
||||||
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
|
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
|
||||||
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
|
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
|
||||||
pass.draw(0..4, k as u32..k as u32 + 1);
|
pass.draw(0..4, k as u32..k as u32 + 1);
|
||||||
@@ -206,67 +226,45 @@ impl UiRenderNode {
|
|||||||
ui_render: &mut UiRenderState,
|
ui_render: &mut UiRenderState,
|
||||||
) -> FrameUpdateStats {
|
) -> FrameUpdateStats {
|
||||||
self.active.clear();
|
self.active.clear();
|
||||||
for (i, primitives) in ui_render.layers.iter_mut() {
|
for (i, order) in ui_render.layers.iter_mut() {
|
||||||
self.active.push(i);
|
self.active.push(i);
|
||||||
for change in primitives.apply_free() {
|
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
|
||||||
if let Some(inst) = ui_render.active.get_mut(&change.id) {
|
order: ArrBuf::new(
|
||||||
for h in &mut inst.primitives {
|
|
||||||
// `is_image` disambiguates: `instances` and `images`
|
|
||||||
// are separate lists with independent indices, so
|
|
||||||
// without it a rect's renumbering could be applied to
|
|
||||||
// an image handle that happened to share the same
|
|
||||||
// (layer, inst_idx).
|
|
||||||
if h.layer == i
|
|
||||||
&& h.inst_idx == change.old
|
|
||||||
&& (h.binding == IMAGE_BINDING) == change.is_image
|
|
||||||
{
|
|
||||||
h.inst_idx = change.new;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let rlayer = self.layers.entry(i).or_insert_with(|| {
|
|
||||||
let primitives = PrimitiveBuffers::new(device);
|
|
||||||
let primitive_group =
|
|
||||||
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
|
|
||||||
RenderLayer {
|
|
||||||
instance: ArrBuf::new(
|
|
||||||
device,
|
|
||||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
|
||||||
"instance",
|
|
||||||
),
|
|
||||||
primitives,
|
|
||||||
primitive_group,
|
|
||||||
image_instance: ArrBuf::new(
|
|
||||||
device,
|
|
||||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
|
||||||
"image instance",
|
|
||||||
),
|
|
||||||
image_tex_indices: Vec::new(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if primitives.updated {
|
|
||||||
rlayer
|
|
||||||
.instance
|
|
||||||
.update(device, queue, primitives.instances());
|
|
||||||
rlayer.primitives.update(device, queue, primitives.data());
|
|
||||||
rlayer.primitive_group = Self::primitive_group(
|
|
||||||
device,
|
device,
|
||||||
&self.primitive_layout,
|
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||||
rlayer.primitives.buffers(),
|
"layer order",
|
||||||
);
|
),
|
||||||
rlayer
|
images: ArrBuf::new(
|
||||||
.image_instance
|
device,
|
||||||
.update(device, queue, primitives.image_instances());
|
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||||
rlayer.image_tex_indices = primitives
|
"layer image order",
|
||||||
.image_instances()
|
),
|
||||||
|
image_tex_indices: Vec::new(),
|
||||||
|
});
|
||||||
|
if order.updated {
|
||||||
|
rlayer.order.update(device, queue, order.order());
|
||||||
|
rlayer.images.update(device, queue, order.images());
|
||||||
|
rlayer.image_tex_indices = order
|
||||||
|
.images()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|inst| inst.idx)
|
.map(|&slot| ui_render.primitives.instance(slot).idx)
|
||||||
.collect();
|
.collect();
|
||||||
primitives.updated = false;
|
order.updated = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let instances_resized = if ui_render.primitives.updated {
|
||||||
|
ui_render.primitives.updated = false;
|
||||||
|
let resized = self
|
||||||
|
.instances
|
||||||
|
.update(device, queue, ui_render.primitives.instances());
|
||||||
|
self.primitives
|
||||||
|
.update(device, queue, ui_render.primitives.data());
|
||||||
|
self.primitive_group =
|
||||||
|
Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers());
|
||||||
|
resized
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
let masks_resized = if ui.masks.changed {
|
let masks_resized = if ui.masks.changed {
|
||||||
ui.masks.changed = false;
|
ui.masks.changed = false;
|
||||||
self.masks.update(device, queue, &ui.masks[..])
|
self.masks.update(device, queue, &ui.masks[..])
|
||||||
@@ -280,9 +278,14 @@ impl UiRenderNode {
|
|||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
if masks_resized || moves_resized {
|
if masks_resized || moves_resized || instances_resized {
|
||||||
self.masks_group =
|
self.masks_group = Self::masks_group(
|
||||||
Self::masks_group(device, &self.masks_layout, &self.masks, &self.move_offsets);
|
device,
|
||||||
|
&self.masks_layout,
|
||||||
|
&self.masks,
|
||||||
|
&self.move_offsets,
|
||||||
|
&self.instances,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
|
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
|
||||||
if rebuild_main {
|
if rebuild_main {
|
||||||
@@ -408,6 +411,14 @@ impl UiRenderNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let tex_manager = GpuTextures::new(device, queue);
|
let tex_manager = GpuTextures::new(device, queue);
|
||||||
|
let primitives = PrimitiveBuffers::new(device);
|
||||||
|
let primitive_group =
|
||||||
|
Self::primitive_group(device, &primitive_layout, primitives.buffers());
|
||||||
|
let instances = ArrBuf::new(
|
||||||
|
device,
|
||||||
|
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||||
|
"ui instances",
|
||||||
|
);
|
||||||
let masks = ArrBuf::new(
|
let masks = ArrBuf::new(
|
||||||
device,
|
device,
|
||||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||||
@@ -422,15 +433,16 @@ impl UiRenderNode {
|
|||||||
let rsc_layout = Self::rsc_layout(device);
|
let rsc_layout = Self::rsc_layout(device);
|
||||||
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
|
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
|
||||||
let masks_layout = Self::masks_layout(device);
|
let masks_layout = Self::masks_layout(device);
|
||||||
let masks_group = Self::masks_group(device, &masks_layout, &masks, &move_offsets);
|
let masks_group =
|
||||||
|
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
|
||||||
|
|
||||||
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
||||||
label: Some("UI Shape Pipeline Layout"),
|
label: Some("UI Shape Pipeline Layout"),
|
||||||
bind_group_layouts: &[
|
bind_group_layouts: &[
|
||||||
&uniform_layout,
|
Some(&uniform_layout),
|
||||||
&primitive_layout,
|
Some(&primitive_layout),
|
||||||
&rsc_layout,
|
Some(&rsc_layout),
|
||||||
&masks_layout,
|
Some(&masks_layout),
|
||||||
],
|
],
|
||||||
immediate_size: 0,
|
immediate_size: 0,
|
||||||
});
|
});
|
||||||
@@ -440,7 +452,7 @@ impl UiRenderNode {
|
|||||||
vertex: VertexState {
|
vertex: VertexState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("vs_main"),
|
entry_point: Some("vs_main"),
|
||||||
buffers: &[PrimitiveInstance::desc()],
|
buffers: &[Some(instance_slot_layout())],
|
||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
},
|
},
|
||||||
fragment: Some(FragmentState {
|
fragment: Some(FragmentState {
|
||||||
@@ -486,6 +498,8 @@ impl UiRenderNode {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
uniform_group,
|
uniform_group,
|
||||||
primitive_layout,
|
primitive_layout,
|
||||||
|
primitives,
|
||||||
|
primitive_group,
|
||||||
rsc_layout,
|
rsc_layout,
|
||||||
rsc_group,
|
rsc_group,
|
||||||
pipeline,
|
pipeline,
|
||||||
@@ -493,6 +507,7 @@ impl UiRenderNode {
|
|||||||
layers: HashMap::default(),
|
layers: HashMap::default(),
|
||||||
active: Vec::new(),
|
active: Vec::new(),
|
||||||
textures: tex_manager,
|
textures: tex_manager,
|
||||||
|
instances,
|
||||||
masks,
|
masks,
|
||||||
move_offsets,
|
move_offsets,
|
||||||
masks_layout,
|
masks_layout,
|
||||||
@@ -627,6 +642,16 @@ impl UiRenderNode {
|
|||||||
},
|
},
|
||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
|
BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
|
||||||
|
ty: BindingType::Buffer {
|
||||||
|
ty: BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
label: Some("ui masks"),
|
label: Some("ui masks"),
|
||||||
})
|
})
|
||||||
@@ -637,6 +662,7 @@ impl UiRenderNode {
|
|||||||
layout: &BindGroupLayout,
|
layout: &BindGroupLayout,
|
||||||
masks: &ArrBuf<Mask>,
|
masks: &ArrBuf<Mask>,
|
||||||
move_offsets: &ArrBuf<MoveOffset>,
|
move_offsets: &ArrBuf<MoveOffset>,
|
||||||
|
instances: &ArrBuf<PrimitiveInstance>,
|
||||||
) -> BindGroup {
|
) -> BindGroup {
|
||||||
device.create_bind_group(&BindGroupDescriptor {
|
device.create_bind_group(&BindGroupDescriptor {
|
||||||
layout,
|
layout,
|
||||||
@@ -649,6 +675,10 @@ impl UiRenderNode {
|
|||||||
binding: 1,
|
binding: 1,
|
||||||
resource: move_offsets.buffer.as_entire_binding(),
|
resource: move_offsets.buffer.as_entire_binding(),
|
||||||
},
|
},
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: instances.buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
label: Some("ui masks"),
|
label: Some("ui masks"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,55 +6,25 @@ use crate::{
|
|||||||
ArrBuf,
|
ArrBuf,
|
||||||
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
||||||
},
|
},
|
||||||
|
util::HashSet,
|
||||||
};
|
};
|
||||||
use bytemuck::Pod;
|
use bytemuck::Pod;
|
||||||
use wgpu::*;
|
use wgpu::*;
|
||||||
|
|
||||||
pub struct Primitives {
|
|
||||||
instances: Vec<PrimitiveInstance>,
|
|
||||||
assoc: Vec<WidgetId>,
|
|
||||||
data: PrimitiveData,
|
|
||||||
free: Vec<usize>,
|
|
||||||
|
|
||||||
/// Standalone images, kept apart from `instances` because each one draws
|
|
||||||
/// with its own bind group rather than sharing the layer's one instanced
|
|
||||||
/// draw -- see TEXTURES.md's "Recommended shape". `idx` on each
|
|
||||||
/// `PrimitiveInstance` here is the texture's slot in `Textures`/
|
|
||||||
/// `GpuTextures`, not an index into `data`; there is no per-image entry
|
|
||||||
/// in `data` because a bind group already picks the texture; nothing
|
|
||||||
/// left to look up per-instance.
|
|
||||||
images: Vec<PrimitiveInstance>,
|
|
||||||
image_assoc: Vec<WidgetId>,
|
|
||||||
image_free: Vec<usize>,
|
|
||||||
|
|
||||||
pub updated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Primitives {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
instances: Default::default(),
|
|
||||||
assoc: Default::default(),
|
|
||||||
data: Default::default(),
|
|
||||||
free: Vec::new(),
|
|
||||||
images: Default::default(),
|
|
||||||
image_assoc: Default::default(),
|
|
||||||
image_free: Vec::new(),
|
|
||||||
updated: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
|
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
|
||||||
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
|
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
|
||||||
/// one from -- a bind group already selects the texture -- so this only ever
|
/// one from -- a bind group already selects the texture -- so this only ever
|
||||||
/// has to match the shader's `TEXTURE` constant and flag "this instance lives
|
/// has to match the shader's `TEXTURE` constant and flag "this instance is
|
||||||
/// in `Primitives::images`, not `Primitives::instances`" to the code below.
|
/// drawn with its own bind group" to the code below.
|
||||||
pub const IMAGE_BINDING: u32 = 1;
|
pub const IMAGE_BINDING: u32 = 1;
|
||||||
|
|
||||||
pub trait Primitive: Pod {
|
pub trait Primitive: Pod {
|
||||||
const BINDING: u32;
|
const BINDING: u32;
|
||||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
||||||
|
/// The read-only half of [`Self::vec`], for a caller that wants to
|
||||||
|
/// look one entry up rather than write one -- a mask reading the
|
||||||
|
/// radius of the rect it clips to ([`Primitives::data`]).
|
||||||
|
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! primitives {
|
macro_rules! primitives {
|
||||||
@@ -120,6 +90,9 @@ macro_rules! primitives {
|
|||||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
|
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
|
||||||
&mut data.$name
|
&mut data.$name
|
||||||
}
|
}
|
||||||
|
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
|
||||||
|
&data.$name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)*
|
)*
|
||||||
};
|
};
|
||||||
@@ -133,18 +106,61 @@ macro_rules! primitives {
|
|||||||
(@count $t:tt) => { 1 };
|
(@count $t:tt) => { 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveInst<P> {
|
/// Every primitive instance in the tree, in one arena that all layers
|
||||||
pub id: WidgetId,
|
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
|
||||||
pub primitive: P,
|
///
|
||||||
pub region: UiRegion,
|
/// **Why one arena rather than one per layer**, which is what this was:
|
||||||
pub mask_idx: MaskIdx,
|
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
|
||||||
pub move_idx: MoveIdx,
|
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
|
||||||
|
/// routinely in a different layer from the content it clips -- a rounded
|
||||||
|
/// container in one layer, a `Stack`'s child content in the layer below.
|
||||||
|
/// A per-layer buffer cannot answer that lookup at all: only one layer's
|
||||||
|
/// group is bound at a time, so the mask would silently read another
|
||||||
|
/// layer's rect. Both buffers are therefore global and bound once per
|
||||||
|
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
|
||||||
|
///
|
||||||
|
/// Slots are stable for a primitive's whole life: nothing here is
|
||||||
|
/// compacted, so a `Mask` can hold a slot across frames.
|
||||||
|
pub struct Primitives {
|
||||||
|
instances: Vec<PrimitiveInstance>,
|
||||||
|
assoc: Vec<WidgetId>,
|
||||||
|
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
|
||||||
|
/// reusable yet: the layer that drew one still names it in its draw
|
||||||
|
/// order until that call compacts the order, so handing it out again
|
||||||
|
/// first would draw the new primitive twice -- once through the stale
|
||||||
|
/// order entry and once through the new one.
|
||||||
|
freed: Vec<usize>,
|
||||||
|
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
|
||||||
|
/// hands out.
|
||||||
|
reusable: Vec<usize>,
|
||||||
|
data: PrimitiveData,
|
||||||
|
/// Whether the instance arena or the per-primitive data changed since
|
||||||
|
/// the last upload -- one flag for both, since they are uploaded
|
||||||
|
/// together.
|
||||||
|
pub updated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Primitives {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
instances: Default::default(),
|
||||||
|
assoc: Default::default(),
|
||||||
|
freed: Vec::new(),
|
||||||
|
reusable: Vec::new(),
|
||||||
|
data: Default::default(),
|
||||||
|
updated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Primitives {
|
impl Primitives {
|
||||||
pub fn write<P: Primitive>(
|
/// Writes a primitive into the arena and hands back its slot and its
|
||||||
|
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
|
||||||
|
/// the slot into a layer's draw order -- an instance that no layer
|
||||||
|
/// names is never rasterized, which is what a mask shape drawn only to
|
||||||
|
/// be *referenced* uses.
|
||||||
|
pub fn alloc<P: Primitive>(
|
||||||
&mut self,
|
&mut self,
|
||||||
layer: usize,
|
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
id,
|
id,
|
||||||
primitive,
|
primitive,
|
||||||
@@ -152,129 +168,118 @@ impl Primitives {
|
|||||||
mask_idx,
|
mask_idx,
|
||||||
move_idx,
|
move_idx,
|
||||||
}: PrimitiveInst<P>,
|
}: PrimitiveInst<P>,
|
||||||
) -> PrimitiveHandle {
|
) -> (u32, usize) {
|
||||||
self.updated = true;
|
let data_idx = P::vec(&mut self.data).add(primitive);
|
||||||
let vec = P::vec(&mut self.data);
|
let slot = self.push(
|
||||||
let i = vec.add(primitive);
|
PrimitiveInstance {
|
||||||
let inst = PrimitiveInstance {
|
region,
|
||||||
region,
|
idx: data_idx as u32,
|
||||||
idx: i as u32,
|
mask_idx,
|
||||||
mask_idx,
|
move_idx,
|
||||||
move_idx,
|
binding: P::BINDING,
|
||||||
binding: P::BINDING,
|
},
|
||||||
};
|
id,
|
||||||
let inst_i = if let Some(i) = self.free.pop() {
|
);
|
||||||
self.instances[i] = inst;
|
(slot, data_idx)
|
||||||
self.assoc[i] = id;
|
|
||||||
i
|
|
||||||
} else {
|
|
||||||
let i = self.instances.len();
|
|
||||||
self.instances.push(inst);
|
|
||||||
self.assoc.push(id);
|
|
||||||
i
|
|
||||||
};
|
|
||||||
PrimitiveHandle::new::<P>(layer, inst_i, i)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes an image instance directly -- there is no `Primitive` impl for
|
/// A standalone image, which has no `PrimitiveData` entry to allocate
|
||||||
/// it to go through `write`, since it has nowhere in `PrimitiveData` to
|
/// -- its bind group already picks the texture, so `texture_idx` rides
|
||||||
/// put a per-instance entry. `texture_idx` is the slot the bind group at
|
/// in the otherwise-unused `idx` field and names the bind group the
|
||||||
/// draw time is chosen from, carried in the otherwise-unused `idx` field.
|
/// draw call selects.
|
||||||
pub fn write_image(
|
pub fn alloc_image(
|
||||||
&mut self,
|
&mut self,
|
||||||
layer: usize,
|
|
||||||
id: WidgetId,
|
id: WidgetId,
|
||||||
texture_idx: u32,
|
texture_idx: u32,
|
||||||
region: UiRegion,
|
region: UiRegion,
|
||||||
mask_idx: MaskIdx,
|
mask_idx: MaskIdx,
|
||||||
move_idx: MoveIdx,
|
move_idx: MoveIdx,
|
||||||
) -> PrimitiveHandle {
|
) -> u32 {
|
||||||
|
self.push(
|
||||||
|
PrimitiveInstance {
|
||||||
|
region,
|
||||||
|
idx: texture_idx,
|
||||||
|
mask_idx,
|
||||||
|
move_idx,
|
||||||
|
binding: IMAGE_BINDING,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
let inst = PrimitiveInstance {
|
let slot = if let Some(i) = self.reusable.pop() {
|
||||||
region,
|
self.instances[i] = inst;
|
||||||
idx: texture_idx,
|
self.assoc[i] = id;
|
||||||
mask_idx,
|
|
||||||
move_idx,
|
|
||||||
binding: IMAGE_BINDING,
|
|
||||||
};
|
|
||||||
let inst_i = if let Some(i) = self.image_free.pop() {
|
|
||||||
self.images[i] = inst;
|
|
||||||
self.image_assoc[i] = id;
|
|
||||||
i
|
i
|
||||||
} else {
|
} else {
|
||||||
let i = self.images.len();
|
self.instances.push(inst);
|
||||||
self.images.push(inst);
|
self.assoc.push(id);
|
||||||
self.image_assoc.push(id);
|
self.instances.len() - 1
|
||||||
i
|
|
||||||
};
|
};
|
||||||
PrimitiveHandle {
|
slot as u32
|
||||||
layer,
|
|
||||||
inst_idx: inst_i,
|
|
||||||
data_idx: 0,
|
|
||||||
binding: IMAGE_BINDING,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn image_instances(&self) -> &Vec<PrimitiveInstance> {
|
|
||||||
&self.images
|
|
||||||
}
|
|
||||||
|
|
||||||
/// returns (old index, new index) for both lists this layer keeps --
|
|
||||||
/// `PrimitiveChange::is_image` says which, since the two have separate
|
|
||||||
/// index spaces and `old`/`new` alone would collide between them.
|
|
||||||
///
|
|
||||||
/// Both lists free with `swap_remove`, so a layer's draw order was
|
|
||||||
/// already undefined before images existed: nothing here may assume one
|
|
||||||
/// primitive stays adjacent to another once anything in the layer has
|
|
||||||
/// been freed.
|
|
||||||
pub fn apply_free(&mut self) -> Vec<PrimitiveChange> {
|
|
||||||
let mut changes =
|
|
||||||
Self::apply_free_list(&mut self.free, &mut self.instances, &mut self.assoc, false);
|
|
||||||
changes.extend(Self::apply_free_list(
|
|
||||||
&mut self.image_free,
|
|
||||||
&mut self.images,
|
|
||||||
&mut self.image_assoc,
|
|
||||||
true,
|
|
||||||
));
|
|
||||||
changes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_free_list(
|
|
||||||
free: &mut Vec<usize>,
|
|
||||||
instances: &mut Vec<PrimitiveInstance>,
|
|
||||||
assoc: &mut Vec<WidgetId>,
|
|
||||||
is_image: bool,
|
|
||||||
) -> Vec<PrimitiveChange> {
|
|
||||||
free.sort_by(|a, b| b.cmp(a));
|
|
||||||
free.drain(..)
|
|
||||||
.filter_map(|i| {
|
|
||||||
instances.swap_remove(i);
|
|
||||||
assoc.swap_remove(i);
|
|
||||||
if i == instances.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = assoc[i];
|
|
||||||
let old = instances.len();
|
|
||||||
Some(PrimitiveChange {
|
|
||||||
id,
|
|
||||||
is_image,
|
|
||||||
old,
|
|
||||||
new: i,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retires a slot, answering the mask it was drawn under so the caller
|
||||||
|
/// can drop that mask's ref. The slot itself only becomes reusable at
|
||||||
|
/// the next [`Self::apply_free`] -- see `freed`.
|
||||||
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
if h.binding == IMAGE_BINDING {
|
let slot = h.slot as usize;
|
||||||
self.image_free.push(h.inst_idx);
|
if h.binding != IMAGE_BINDING {
|
||||||
self.images[h.inst_idx].mask_idx
|
|
||||||
} else {
|
|
||||||
self.data.free(h.binding, h.data_idx);
|
self.data.free(h.binding, h.data_idx);
|
||||||
self.free.push(h.inst_idx);
|
|
||||||
self.instances[h.inst_idx].mask_idx
|
|
||||||
}
|
}
|
||||||
|
self.freed.push(slot);
|
||||||
|
self.instances[slot].mask_idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands this frame's freed slots back for reuse. Called once per
|
||||||
|
/// frame from `UiRenderState::update`, **after** every layer has
|
||||||
|
/// compacted its draw order, since that order is the only thing still
|
||||||
|
/// naming them.
|
||||||
|
pub fn release_freed(&mut self) {
|
||||||
|
self.reusable.append(&mut self.freed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which widget drew the primitive in `slot` -- how a draw-order
|
||||||
|
/// change finds the handle it has to renumber.
|
||||||
|
pub fn owner(&self, slot: u32) -> WidgetId {
|
||||||
|
self.assoc[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.updated = true;
|
||||||
|
self.instances.clear();
|
||||||
|
self.assoc.clear();
|
||||||
|
self.freed.clear();
|
||||||
|
self.reusable.clear();
|
||||||
|
self.data.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many instances are still live -- 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.freed.len() - self.reusable.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every live instance as `(slot, owner, is_image)` -- everything
|
||||||
|
/// except the freed and the reusable. Only
|
||||||
|
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
|
||||||
|
/// that every live primitive still belongs to a live widget.
|
||||||
|
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
|
||||||
|
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
|
||||||
|
(0..self.instances.len())
|
||||||
|
.filter(move |i| !dead.contains(i))
|
||||||
|
.map(|i| {
|
||||||
|
(
|
||||||
|
i as u32,
|
||||||
|
self.assoc[i],
|
||||||
|
self.instances[i].binding == IMAGE_BINDING,
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data(&self) -> &PrimitiveData {
|
pub fn data(&self) -> &PrimitiveData {
|
||||||
@@ -285,46 +290,163 @@ impl Primitives {
|
|||||||
&self.instances
|
&self.instances
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
|
||||||
|
&self.instances[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-primitive data behind `slot`, or `None` if that slot holds
|
||||||
|
/// a different kind of primitive -- the `binding` check is the same
|
||||||
|
/// one the shader's dispatch switch makes, and it is what stops a
|
||||||
|
/// caller reading a glyph's index into the rect table.
|
||||||
|
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
|
||||||
|
let inst = self.instance(slot);
|
||||||
|
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
if h.binding == IMAGE_BINDING {
|
&mut self.instances[h.slot as usize].region
|
||||||
&mut self.images[h.inst_idx].region
|
|
||||||
} else {
|
|
||||||
&mut self.instances[h.inst_idx].region
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveChange {
|
/// One layer's draw order: the slots of the global arena it draws, in the
|
||||||
pub id: WidgetId,
|
/// order they were written. The vertex buffer of a layer is exactly this.
|
||||||
/// Which of `Primitives::instances`/`Primitives::images` this change
|
///
|
||||||
/// belongs to -- their `old`/`new` indices are independent, so a
|
/// Both lists free with `swap_remove`, so a layer's draw order was already
|
||||||
/// consumer matching only on `(layer, inst_idx)` could apply an image's
|
/// undefined before this split: nothing here may assume one primitive
|
||||||
/// renumbering to a rect's handle that happens to share the same index.
|
/// stays adjacent to another once anything in the layer has been freed.
|
||||||
pub is_image: bool,
|
#[derive(Default)]
|
||||||
pub old: usize,
|
pub struct LayerOrder {
|
||||||
pub new: usize,
|
order: Vec<u32>,
|
||||||
|
/// Standalone images, kept apart because each draws with its own bind
|
||||||
|
/// group rather than sharing the layer's one instanced draw -- see
|
||||||
|
/// `UiRenderNode::draw`.
|
||||||
|
images: Vec<u32>,
|
||||||
|
free: Vec<usize>,
|
||||||
|
image_free: Vec<usize>,
|
||||||
|
pub updated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl LayerOrder {
|
||||||
|
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
|
||||||
|
self.updated = true;
|
||||||
|
let list = if is_image {
|
||||||
|
&mut self.images
|
||||||
|
} else {
|
||||||
|
&mut self.order
|
||||||
|
};
|
||||||
|
list.push(slot);
|
||||||
|
list.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
|
||||||
|
/// the arena's own, so that a position is only renumbered once per
|
||||||
|
/// frame however many were dropped.
|
||||||
|
pub fn free(&mut self, pos: usize, is_image: bool) {
|
||||||
|
self.updated = true;
|
||||||
|
if is_image {
|
||||||
|
self.image_free.push(pos);
|
||||||
|
} else {
|
||||||
|
self.free.push(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compacts both lists, answering every primitive whose position
|
||||||
|
/// moved so its handle can be corrected.
|
||||||
|
pub fn apply_free(&mut self) -> Vec<OrderChange> {
|
||||||
|
let mut changes = Self::apply_free_list(&mut self.free, &mut self.order, false);
|
||||||
|
changes.extend(Self::apply_free_list(
|
||||||
|
&mut self.image_free,
|
||||||
|
&mut self.images,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_free_list(
|
||||||
|
free: &mut Vec<usize>,
|
||||||
|
list: &mut Vec<u32>,
|
||||||
|
is_image: bool,
|
||||||
|
) -> Vec<OrderChange> {
|
||||||
|
// Descending, so removing a contiguous tail costs no renumbering
|
||||||
|
// at all -- which is what freeing one widget's primitives is.
|
||||||
|
free.sort_by(|a, b| b.cmp(a));
|
||||||
|
free.drain(..)
|
||||||
|
.filter_map(|pos| {
|
||||||
|
list.swap_remove(pos);
|
||||||
|
if pos == list.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(OrderChange {
|
||||||
|
slot: list[pos],
|
||||||
|
is_image,
|
||||||
|
pos,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn order(&self) -> &Vec<u32> {
|
||||||
|
&self.order
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn images(&self) -> &Vec<u32> {
|
||||||
|
&self.images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A primitive whose position in a layer's draw order moved when
|
||||||
|
/// something before it was freed -- `slot` names which primitive, so its
|
||||||
|
/// owner's handle can be found and pointed at `pos`.
|
||||||
|
pub struct OrderChange {
|
||||||
|
pub slot: u32,
|
||||||
|
/// Which of the layer's two lists moved: their positions are
|
||||||
|
/// independent index spaces, so a handle matching on position alone
|
||||||
|
/// could take an image's renumbering for a rect's.
|
||||||
|
pub is_image: bool,
|
||||||
|
pub pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is
|
||||||
|
/// a primitive written only to be *referenced* -- a mask's shape
|
||||||
|
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
|
||||||
|
/// freed exactly like any other; it is simply never rasterized.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Drawn {
|
||||||
|
Yes,
|
||||||
|
No,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
|
||||||
|
/// there is no position to renumber or free.
|
||||||
|
pub const NOT_DRAWN: usize = usize::MAX;
|
||||||
|
|
||||||
|
/// Where one primitive lives: its stable slot in the global arena, and
|
||||||
|
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
|
||||||
|
/// only referenced).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PrimitiveHandle {
|
pub struct PrimitiveHandle {
|
||||||
pub layer: usize,
|
pub layer: usize,
|
||||||
pub inst_idx: usize,
|
pub pos: usize,
|
||||||
|
pub slot: u32,
|
||||||
pub data_idx: usize,
|
pub data_idx: usize,
|
||||||
pub binding: u32,
|
pub binding: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveHandle {
|
impl PrimitiveHandle {
|
||||||
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
|
pub fn is_image(&self) -> bool {
|
||||||
Self {
|
self.binding == IMAGE_BINDING
|
||||||
layer,
|
|
||||||
inst_idx,
|
|
||||||
data_idx,
|
|
||||||
binding: P::BINDING,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PrimitiveInst<P> {
|
||||||
|
pub id: WidgetId,
|
||||||
|
pub primitive: P,
|
||||||
|
pub region: UiRegion,
|
||||||
|
pub mask_idx: MaskIdx,
|
||||||
|
pub move_idx: MoveIdx,
|
||||||
|
}
|
||||||
|
|
||||||
primitives!(
|
primitives!(
|
||||||
rects: RectPrimitive => 0,
|
rects: RectPrimitive => 0,
|
||||||
glyphs: GlyphPrimitive => 2,
|
glyphs: GlyphPrimitive => 2,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! The rounded-rect coverage function, on the CPU.
|
||||||
|
//!
|
||||||
|
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
|
||||||
|
//! transliteration of these two, line for line, and
|
||||||
|
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
|
||||||
|
//! at a grid of points against values the shader itself produced. They are
|
||||||
|
//! kept together here, in the crate both a renderer and a hit test can
|
||||||
|
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
|
||||||
|
//! agreeing: a masked corner that cannot be tapped and a masked corner
|
||||||
|
//! that is not drawn have to be the same corner, and they are only the
|
||||||
|
//! same corner while one function decides both.
|
||||||
|
//!
|
||||||
|
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
|
||||||
|
//! units, which the shader has already resolved by the time it evaluates
|
||||||
|
//! this.
|
||||||
|
|
||||||
|
use crate::util::Vec2;
|
||||||
|
|
||||||
|
/// The signed distance from `pos` to a rounded rect given by its centre,
|
||||||
|
/// its corner offset (half its size) and its corner `radius`. Negative
|
||||||
|
/// inside.
|
||||||
|
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
|
||||||
|
// vec from center to pixel
|
||||||
|
let p = pos - center;
|
||||||
|
// vec from inner rect corner to pixel
|
||||||
|
let q = Vec2::new(
|
||||||
|
p.x.abs() - (corner.x - radius),
|
||||||
|
p.y.abs() - (corner.y - radius),
|
||||||
|
);
|
||||||
|
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
|
||||||
|
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
|
||||||
|
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
|
||||||
|
///
|
||||||
|
/// The half-pixel feather is why a hit test asks for **more than a half**
|
||||||
|
/// rather than "any coverage at all": half is where the geometric edge is,
|
||||||
|
/// so the two answer the same question the drawn shape does.
|
||||||
|
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
|
||||||
|
let edge: f32 = 0.5;
|
||||||
|
let corner = (bot_right - top_left) / 2.0;
|
||||||
|
let center = top_left + corner;
|
||||||
|
let dist = distance_from_rect(pos, center, corner, radius);
|
||||||
|
1.0 - smoothstep(-edge.min(radius), edge, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
|
||||||
|
/// when `low == high`, which is why the caller above never passes a zero
|
||||||
|
/// radius into the low edge without `edge` bounding it.
|
||||||
|
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
|
||||||
|
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
|
}
|
||||||
@@ -30,10 +30,12 @@ struct GlyphInfo {
|
|||||||
flags: u32,
|
flags: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
|
||||||
|
/// clips this mask's subtree, and the mask it nests inside
|
||||||
|
/// (`4294967295u` at the top).
|
||||||
struct Mask {
|
struct Mask {
|
||||||
x: UiSpan,
|
primitive: u32,
|
||||||
y: UiSpan,
|
parent: u32,
|
||||||
move_idx: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One widget's cumulative on-screen translation and the slot of the
|
/// One widget's cumulative on-screen translation and the slot of the
|
||||||
@@ -53,11 +55,6 @@ struct UiScalar {
|
|||||||
abs: f32,
|
abs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UiVec2 {
|
|
||||||
rel: vec2<f32>,
|
|
||||||
abs: vec2<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
||||||
// texture with headroom and copies the old layers across -- see
|
// texture with headroom and copies the old layers across -- see
|
||||||
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
|
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
|
||||||
@@ -79,12 +76,25 @@ var samp: sampler;
|
|||||||
var<storage> masks: array<Mask>;
|
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>;
|
||||||
|
// Every primitive's placement, in one arena all layers share. The vertex
|
||||||
|
// stage reads the primitive it is drawing (its slot arrives as the only
|
||||||
|
// vertex attribute); the fragment stage reads a *mask's* primitive, which
|
||||||
|
// is generally a different one in a different layer. See LAYOUT.md's
|
||||||
|
// "Masks with a shape" and `Primitives` in primitive.rs.
|
||||||
|
@group(3) @binding(2)
|
||||||
|
var<storage> instances: array<PrimitiveInstance>;
|
||||||
|
|
||||||
// A move chain more than this deep means something else is wrong (an
|
// The bound on the parent walk, kept in step with `PARENT_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 PARENT_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
|
||||||
@@ -92,7 +102,7 @@ const MOVE_CHAIN_LIMIT: u32 = 16u;
|
|||||||
fn resolve_move(idx: u32) -> vec2<f32> {
|
fn resolve_move(idx: u32) -> vec2<f32> {
|
||||||
var total = vec2<f32>(0.0, 0.0);
|
var total = vec2<f32>(0.0, 0.0);
|
||||||
var i = idx;
|
var i = idx;
|
||||||
for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) {
|
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||||
let entry = move_offsets[i];
|
let entry = move_offsets[i];
|
||||||
total += entry.delta;
|
total += entry.delta;
|
||||||
if entry.parent == 4294967295u {
|
if entry.parent == 4294967295u {
|
||||||
@@ -107,24 +117,31 @@ struct WindowUniform {
|
|||||||
dim: vec2<f32>,
|
dim: vec2<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
|
||||||
|
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
|
||||||
|
struct PrimitiveInstance {
|
||||||
|
x: UiSpan,
|
||||||
|
y: UiSpan,
|
||||||
|
binding: u32,
|
||||||
|
idx: u32,
|
||||||
|
mask_idx: u32,
|
||||||
|
move_idx: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layer's draw order: one slot into `instances` per instance drawn.
|
||||||
struct InstanceInput {
|
struct InstanceInput {
|
||||||
@location(0) x_start: vec2<f32>,
|
@location(0) slot: u32,
|
||||||
@location(1) x_end: vec2<f32>,
|
|
||||||
@location(2) y_start: vec2<f32>,
|
|
||||||
@location(3) y_end: vec2<f32>,
|
|
||||||
@location(4) binding: u32,
|
|
||||||
@location(5) idx: u32,
|
|
||||||
@location(6) mask_idx: u32,
|
|
||||||
@location(7) move_idx: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@location(0) top_left: vec2<f32>,
|
@location(0) top_left: vec2<f32>,
|
||||||
@location(1) bot_right: vec2<f32>,
|
@location(1) bot_right: vec2<f32>,
|
||||||
@location(2) uv: vec2<f32>,
|
@location(2) uv: vec2<f32>,
|
||||||
@location(3) binding: u32,
|
// `flat` is the only interpolation an integer can have, and naga
|
||||||
@location(4) idx: u32,
|
// (wgpu 30) now requires saying so rather than inferring it.
|
||||||
@location(5) mask_idx: u32,
|
@location(3) @interpolate(flat) binding: u32,
|
||||||
|
@location(4) @interpolate(flat) idx: u32,
|
||||||
|
@location(5) @interpolate(flat) mask_idx: u32,
|
||||||
@builtin(position) clip_position: vec4<f32>,
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,21 +152,38 @@ struct Region {
|
|||||||
bot_right: vec2<f32>,
|
bot_right: vec2<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One primitive's on-screen corners in window pixels. Written once and
|
||||||
|
/// used by both stages: the vertex stage for the primitive it is drawing,
|
||||||
|
/// the fragment stage for a mask's -- so the shape a mask clips to and the
|
||||||
|
/// shape that was drawn cannot be computed two different ways.
|
||||||
|
struct Corners {
|
||||||
|
top_left: vec2<f32>,
|
||||||
|
bot_right: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corners_of(inst: PrimitiveInstance) -> Corners {
|
||||||
|
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
|
||||||
|
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
|
||||||
|
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
|
||||||
|
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
|
||||||
|
let move_delta = resolve_move(inst.move_idx);
|
||||||
|
return Corners(
|
||||||
|
floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta,
|
||||||
|
floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_main(
|
fn vs_main(
|
||||||
@builtin(vertex_index) vi: u32,
|
@builtin(vertex_index) vi: u32,
|
||||||
in: InstanceInput,
|
in: InstanceInput,
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
var out: VertexOutput;
|
var out: VertexOutput;
|
||||||
|
let inst = instances[in.slot];
|
||||||
|
|
||||||
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
|
let c = corners_of(inst);
|
||||||
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
|
let top_left = c.top_left;
|
||||||
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
|
let bot_right = c.bot_right;
|
||||||
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
|
|
||||||
|
|
||||||
let move_delta = resolve_move(in.move_idx);
|
|
||||||
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta;
|
|
||||||
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta;
|
|
||||||
let size = bot_right - top_left;
|
let size = bot_right - top_left;
|
||||||
|
|
||||||
let uv = vec2<f32>(
|
let uv = vec2<f32>(
|
||||||
@@ -159,11 +193,11 @@ fn vs_main(
|
|||||||
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
|
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
|
||||||
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
|
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
|
||||||
out.uv = uv;
|
out.uv = uv;
|
||||||
out.binding = in.binding;
|
out.binding = inst.binding;
|
||||||
out.idx = in.idx;
|
out.idx = inst.idx;
|
||||||
out.top_left = top_left;
|
out.top_left = top_left;
|
||||||
out.bot_right = bot_right;
|
out.bot_right = bot_right;
|
||||||
out.mask_idx = in.mask_idx;
|
out.mask_idx = inst.mask_idx;
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -190,21 +224,39 @@ fn fs_main(
|
|||||||
color = vec4(1.0, 0.0, 1.0, 1.0);
|
color = vec4(1.0, 0.0, 1.0, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if in.mask_idx != 4294967295u {
|
// Every mask on the chain, not just the innermost: a widget that set
|
||||||
let mask = masks[in.mask_idx];
|
// its own mask inside another is clipped by both, and the coverages
|
||||||
let mask_delta = resolve_move(mask.move_idx);
|
// multiply -- so a pixel inside two feathered corners is dimmed by
|
||||||
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
|
// both, which is what a compositor does (`Mask::parent` in data.rs).
|
||||||
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
|
var mask_idx = in.mask_idx;
|
||||||
|
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||||
let top_left = floor(tl.rel * window.dim) + floor(tl.abs) + mask_delta;
|
if mask_idx == 4294967295u {
|
||||||
let bot_right = floor(br.rel * window.dim) + floor(br.abs) + mask_delta;
|
break;
|
||||||
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
|
|
||||||
color *= 0.0;
|
|
||||||
}
|
}
|
||||||
|
let mask = masks[mask_idx];
|
||||||
|
color.a *= mask_coverage(pos, mask);
|
||||||
|
mask_idx = mask.parent;
|
||||||
}
|
}
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much of `pos` one mask lets through: the referenced primitive's
|
||||||
|
/// own coverage at that pixel, from the same SDF the primitive is drawn
|
||||||
|
/// with. Nothing about the shape is copied into the mask, so a rounded
|
||||||
|
/// container's corner and its children's clipped corner are the same
|
||||||
|
/// arithmetic.
|
||||||
|
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
|
||||||
|
let inst = instances[mask.primitive];
|
||||||
|
if inst.binding != RECT {
|
||||||
|
// Unreachable: `Painter::set_mask` rejects a glyph or an image
|
||||||
|
// shape by name (see `Mask::primitive`). Letting the pixel
|
||||||
|
// through rather than reading a `rects` entry that is not there.
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let c = corners_of(inst);
|
||||||
|
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_texture(region: Region) -> vec4<f32> {
|
fn draw_texture(region: Region) -> vec4<f32> {
|
||||||
return textureSample(image_texture, samp, region.uv);
|
return textureSample(image_texture, samp, region.uv);
|
||||||
}
|
}
|
||||||
@@ -220,19 +272,35 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
|
||||||
|
/// function both a drawn rect and a mask go through, and the
|
||||||
|
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
|
||||||
|
/// which the hit test uses so a corner that cannot be tapped and a corner
|
||||||
|
/// that is not drawn are the same corner.
|
||||||
|
fn rounded_rect_coverage(
|
||||||
|
pos: vec2<f32>,
|
||||||
|
top_left: vec2<f32>,
|
||||||
|
bot_right: vec2<f32>,
|
||||||
|
radius: f32,
|
||||||
|
) -> f32 {
|
||||||
|
let edge = 0.5;
|
||||||
|
let corner = (bot_right - top_left) / 2.0;
|
||||||
|
let center = top_left + corner;
|
||||||
|
let dist = distance_from_rect(pos, center, corner, radius);
|
||||||
|
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
||||||
var color = unpack4x8unorm(rect.color);
|
var color = unpack4x8unorm(rect.color);
|
||||||
|
|
||||||
let edge = 0.5;
|
let edge = 0.5;
|
||||||
|
|
||||||
let size = region.bot_right - region.top_left;
|
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
|
||||||
let corner = size / 2.0;
|
|
||||||
let center = region.top_left + corner;
|
|
||||||
|
|
||||||
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
|
|
||||||
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
|
|
||||||
|
|
||||||
if rect.thickness > 0.0 {
|
if rect.thickness > 0.0 {
|
||||||
|
let size = region.bot_right - region.top_left;
|
||||||
|
let corner = size / 2.0;
|
||||||
|
let center = region.top_left + corner;
|
||||||
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
|
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
|
||||||
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
|
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -360,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 {
|
||||||
@@ -382,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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion,
|
Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
|
||||||
UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
||||||
render::{GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst},
|
render::{
|
||||||
|
Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
|
||||||
|
RectPrimitive,
|
||||||
|
},
|
||||||
util::Vec2,
|
util::Vec2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,6 +16,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>,
|
||||||
@@ -22,8 +29,20 @@ pub struct Painter<'a> {
|
|||||||
|
|
||||||
impl<'a> Painter<'a> {
|
impl<'a> Painter<'a> {
|
||||||
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
||||||
let h = self.state.layers.write(
|
self.write_primitive(primitive, region, Drawn::Yes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one path every primitive this widget owns goes through --
|
||||||
|
/// drawn or, for a mask's shape, only referenced.
|
||||||
|
fn write_primitive<P: Primitive>(
|
||||||
|
&mut self,
|
||||||
|
primitive: P,
|
||||||
|
region: UiRegion,
|
||||||
|
drawn: Drawn,
|
||||||
|
) -> u32 {
|
||||||
|
let h = self.state.write_primitive(
|
||||||
self.layer,
|
self.layer,
|
||||||
|
drawn,
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
primitive,
|
primitive,
|
||||||
@@ -36,7 +55,9 @@ impl<'a> Painter<'a> {
|
|||||||
// TODO: I have no clue if this works at all :joy:
|
// TODO: I have no clue if this works at all :joy:
|
||||||
self.rsc.ui_mut().masks.push_ref(self.mask);
|
self.rsc.ui_mut().masks.push_ref(self.mask);
|
||||||
}
|
}
|
||||||
|
let slot = h.slot;
|
||||||
self.primitives.push(h);
|
self.primitives.push(h);
|
||||||
|
slot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a primitive to be rendered
|
/// Writes a primitive to be rendered
|
||||||
@@ -48,12 +69,110 @@ 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 call per widget; a widget drawn inside another
|
||||||
|
/// widget's mask nests instead -- the new mask chains to the inherited
|
||||||
|
/// one (`Mask::parent`) and the fragment stage multiplies both
|
||||||
|
/// coverages, which is what lets a transcript row's code fence clip
|
||||||
|
/// to itself *and* to the list it scrolls inside.
|
||||||
|
///
|
||||||
|
/// The clip is a **primitive**, not a rectangle copied into the mask:
|
||||||
|
/// this writes an undrawn `RectPrimitive` at `region` and points the
|
||||||
|
/// mask at it, so the fragment stage evaluates the same rounded-rect
|
||||||
|
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
|
||||||
|
///
|
||||||
|
/// 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);
|
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
|
||||||
self.mask = self.rsc.ui_mut().masks.push(Mask {
|
self.set_mask_to(shape);
|
||||||
region,
|
}
|
||||||
move_idx: self.move_slot,
|
|
||||||
|
/// Clip everything this widget draws after this call to `shape`'s
|
||||||
|
/// own shape -- the first primitive `shape`'s subtree drew, which
|
||||||
|
/// must already have been drawn this frame
|
||||||
|
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
|
||||||
|
/// clip a container's content to the rounded background it draws,
|
||||||
|
/// with no radius argument anywhere that could fall out of step with
|
||||||
|
/// the one being drawn.
|
||||||
|
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
|
||||||
|
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
|
||||||
|
clip to",
|
||||||
|
self.rsc.widgets().label(shape.id()),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
self.set_mask_to(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points this widget's mask at a primitive that has already been
|
||||||
|
/// written -- the shared half of [`Self::set_mask`].
|
||||||
|
fn set_mask_to(&mut self, shape: u32) {
|
||||||
|
// `assert!`, not `debug_assert!`: one comparison per widget draw,
|
||||||
|
// and the second call silently *replacing* the first is a widget
|
||||||
|
// drawn unclipped -- which reaches the screen and nothing says so.
|
||||||
|
// Every build anybody runs here is release
|
||||||
|
// (docs/REVIEW-2026-09-07.md's R1).
|
||||||
|
assert!(
|
||||||
|
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
|
||||||
|
"set_mask called twice while drawing one widget: the second would replace the first \
|
||||||
|
rather than nest inside it",
|
||||||
|
);
|
||||||
|
// A glyph would need a CPU-side alpha plane for the hit test to
|
||||||
|
// agree with the shader, and a standalone image a bind-group
|
||||||
|
// switch the fragment stage cannot make -- see `Mask::primitive`.
|
||||||
|
// Named here rather than left to the shader, which would read a
|
||||||
|
// rect that is not there and clip to nothing.
|
||||||
|
let binding = self.state.primitives.instance(shape).binding;
|
||||||
|
assert_eq!(
|
||||||
|
binding,
|
||||||
|
RectPrimitive::BINDING,
|
||||||
|
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
|
||||||
|
);
|
||||||
|
let parent = self.mask;
|
||||||
|
let mask = Mask {
|
||||||
|
primitive: shape,
|
||||||
|
parent,
|
||||||
|
};
|
||||||
|
let old_parent = 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;
|
||||||
|
MaskIdx::NONE
|
||||||
|
} else {
|
||||||
|
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
|
||||||
|
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||||
|
old
|
||||||
|
};
|
||||||
|
// The chain link's own ref, taken before the old one is dropped so
|
||||||
|
// that re-chaining to the same slot cannot free it in between.
|
||||||
|
// Released here when the link changes, and in
|
||||||
|
// `UiRenderState::remove` when this widget's slot goes.
|
||||||
|
if old_parent != parent {
|
||||||
|
if parent != MaskIdx::NONE {
|
||||||
|
self.rsc.ui_mut().masks.push_ref(parent);
|
||||||
|
}
|
||||||
|
if old_parent != MaskIdx::NONE {
|
||||||
|
self.rsc.ui_mut().masks.remove(old_parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.mask = self.own_mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether anything is clipping what this widget draws -- its own
|
||||||
|
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
||||||
|
/// a widget whose contents may legitimately extend past its own box
|
||||||
|
/// (`iris::widget::List`, which draws a row straddling an edge in
|
||||||
|
/// full) asserts before relying on being cut off there.
|
||||||
|
pub fn is_masked(&self) -> bool {
|
||||||
|
self.mask != MaskIdx::NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 +205,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
|
||||||
@@ -145,7 +265,7 @@ impl<'a> Painter<'a> {
|
|||||||
/// the layer's one instanced draw, so it goes through
|
/// the layer's one instanced draw, so it goes through
|
||||||
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
||||||
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
||||||
let h = self.state.layers.write_image(
|
let h = self.state.write_image(
|
||||||
self.layer,
|
self.layer,
|
||||||
self.id,
|
self.id,
|
||||||
texture_idx,
|
texture_idx,
|
||||||
@@ -166,17 +286,42 @@ impl<'a> Painter<'a> {
|
|||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
let density = self.state.density;
|
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
|
ui.text
|
||||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
.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.
|
||||||
///
|
///
|
||||||
/// `origin` is where the text's top-left goes; every glyph is placed at an
|
/// `origin` is where the text's top-left goes; every glyph is placed at an
|
||||||
/// 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. `assert_eq!`
|
||||||
|
// for R1's reason: two integers per laid-out string, not per
|
||||||
|
// glyph, and the failure is unreadable text on a release build.
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
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::{
|
||||||
|
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
|
||||||
|
RectPrimitive, rounded_rect_coverage,
|
||||||
|
},
|
||||||
util::{HashMap, HashSet, Id, Vec2},
|
util::{HashMap, HashSet, Id, Vec2},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// What [`UiRenderState::update`] did on its last call -- read back by the
|
||||||
|
/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris`
|
||||||
|
/// crate) so a report can tell a full relayout from a frame that only
|
||||||
|
/// redrew a handful of dirty widgets from one that drew nothing at all.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RedrawKind {
|
||||||
|
/// Neither the root nor any widget changed -- `update` did nothing.
|
||||||
|
None,
|
||||||
|
/// [`UiRenderState::redraw_all`]: a new root, or a resize.
|
||||||
|
All,
|
||||||
|
/// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw`
|
||||||
|
/// named.
|
||||||
|
Updates,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UiRenderState {
|
pub struct UiRenderState {
|
||||||
pub active: HashMap<WidgetId, ActiveData>,
|
pub active: HashMap<WidgetId, ActiveData>,
|
||||||
|
/// Every primitive in the tree, in one arena -- see [`Primitives`] for
|
||||||
|
/// why it is not per layer.
|
||||||
|
pub primitives: Primitives,
|
||||||
|
/// What each layer draws, in order: slots into `primitives`.
|
||||||
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
|
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||||
@@ -18,6 +43,18 @@ pub struct UiRenderState {
|
|||||||
|
|
||||||
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>,
|
||||||
|
|
||||||
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
||||||
@@ -28,17 +65,57 @@ 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,
|
||||||
|
|
||||||
|
/// `Instant::now()` at construction -- the zero every `iris::frame` line
|
||||||
|
/// dates itself from, so a report's `now=` is comparable to a harness's
|
||||||
|
/// own `t_ms` (`Harness::new` builds its `base` the same way, in the
|
||||||
|
/// same constructor call) without either side needing the wall clock.
|
||||||
|
epoch: Instant,
|
||||||
|
/// How many times [`Self::update`] has run -- the `iris::frame` line's
|
||||||
|
/// frame number. Counts every call, including one that found nothing to
|
||||||
|
/// redraw, so a gap in the sequence in a report is a frame this state
|
||||||
|
/// was never asked to run at all (a stalled event loop), not one that
|
||||||
|
/// ran and did nothing.
|
||||||
|
frame_no: u64,
|
||||||
|
/// How long the redraw phase of the last [`Self::update`] took --
|
||||||
|
/// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or
|
||||||
|
/// zero if neither did. Read back by `iris::diagnostics::log_frame`.
|
||||||
|
last_layout: Duration,
|
||||||
|
last_redraw_kind: RedrawKind,
|
||||||
|
/// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris`
|
||||||
|
/// crate) last saw an input sample, dated by the sample's own clock
|
||||||
|
/// (`CursorState::time`) rather than when the dispatch ran -- same
|
||||||
|
/// reasoning as that field's own doc. A `Mutex` because `run_sensors`
|
||||||
|
/// takes `&self` and this is the one render state both backends
|
||||||
|
/// already share across frames.
|
||||||
|
last_input_at: Mutex<Option<Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
|
///
|
||||||
|
/// Named for the walk rather than for one of its two subjects: it bounds
|
||||||
|
/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in
|
||||||
|
/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first
|
||||||
|
/// (docs/REVIEW-2026-09-07.md).
|
||||||
|
pub const PARENT_CHAIN_LIMIT: usize = 64;
|
||||||
|
|
||||||
impl UiRenderState {
|
impl UiRenderState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
|
primitives: Default::default(),
|
||||||
layers: Default::default(),
|
layers: Default::default(),
|
||||||
output_size: Vec2::ZERO,
|
output_size: Vec2::ZERO,
|
||||||
density: 1.0,
|
density: 1.0,
|
||||||
@@ -48,20 +125,102 @@ impl UiRenderState {
|
|||||||
draw_count: 0,
|
draw_count: 0,
|
||||||
region_mut_count: 0,
|
region_mut_count: 0,
|
||||||
mov_count: 0,
|
mov_count: 0,
|
||||||
|
shape_count: 0,
|
||||||
|
epoch: Instant::now(),
|
||||||
|
frame_no: 0,
|
||||||
|
last_layout: Duration::ZERO,
|
||||||
|
last_redraw_kind: RedrawKind::None,
|
||||||
|
last_input_at: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes a primitive into the arena and, unless it is
|
||||||
|
/// [`Drawn::No`], into `layer`'s draw order.
|
||||||
|
pub(super) fn write_primitive<P: Primitive>(
|
||||||
|
&mut self,
|
||||||
|
layer: usize,
|
||||||
|
drawn: Drawn,
|
||||||
|
inst: PrimitiveInst<P>,
|
||||||
|
) -> PrimitiveHandle {
|
||||||
|
let (slot, data_idx) = self.primitives.alloc(inst);
|
||||||
|
let pos = match drawn {
|
||||||
|
Drawn::Yes => self.layers[layer].push(slot, false),
|
||||||
|
Drawn::No => NOT_DRAWN,
|
||||||
|
};
|
||||||
|
PrimitiveHandle {
|
||||||
|
layer,
|
||||||
|
pos,
|
||||||
|
slot,
|
||||||
|
data_idx,
|
||||||
|
binding: P::BINDING,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A standalone image, which draws with its own bind group rather
|
||||||
|
/// than sharing the layer's one instanced draw.
|
||||||
|
pub(super) fn write_image(
|
||||||
|
&mut self,
|
||||||
|
layer: usize,
|
||||||
|
id: WidgetId,
|
||||||
|
texture_idx: u32,
|
||||||
|
region: UiRegion,
|
||||||
|
mask_idx: MaskIdx,
|
||||||
|
move_idx: MoveIdx,
|
||||||
|
) -> PrimitiveHandle {
|
||||||
|
let slot = self
|
||||||
|
.primitives
|
||||||
|
.alloc_image(id, texture_idx, region, mask_idx, move_idx);
|
||||||
|
let pos = self.layers[layer].push(slot, true);
|
||||||
|
PrimitiveHandle {
|
||||||
|
layer,
|
||||||
|
pos,
|
||||||
|
slot,
|
||||||
|
data_idx: 0,
|
||||||
|
binding: crate::render::IMAGE_BINDING,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compacts every layer's draw order around the primitives freed
|
||||||
|
/// this frame, corrects the handles that moved, and only then hands
|
||||||
|
/// the arena slots back for reuse -- that order is the whole reason
|
||||||
|
/// `Primitives::freed` exists. Once per frame, at the end of
|
||||||
|
/// [`Self::update`], so the harness (which has no renderer) applies
|
||||||
|
/// it exactly as a real backend does.
|
||||||
|
fn apply_free(&mut self) {
|
||||||
|
for (layer, order) in self.layers.iter_mut() {
|
||||||
|
for change in order.apply_free() {
|
||||||
|
let owner = self.primitives.owner(change.slot);
|
||||||
|
if let Some(active) = self.active.get_mut(&owner) {
|
||||||
|
for h in &mut active.primitives {
|
||||||
|
if h.layer == layer && h.slot == change.slot {
|
||||||
|
h.pos = change.pos;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.primitives.release_freed();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
||||||
self.output_size = size.into();
|
self.output_size = size.into();
|
||||||
self.resized = true;
|
self.resized = true;
|
||||||
@@ -73,7 +232,16 @@ impl UiRenderState {
|
|||||||
/// different triggers (a surface resize on every rotation or keyboard
|
/// different triggers (a surface resize on every rotation or keyboard
|
||||||
/// open; a density change only if the app follows the display to a
|
/// open; a density change only if the app follows the display to a
|
||||||
/// different screen, which Android surfaces separately).
|
/// different screen, which Android surfaces separately).
|
||||||
|
///
|
||||||
|
/// Marks the tree for a full redraw when the value actually changes:
|
||||||
|
/// every `Len::dp` already resolved and every glyph already shaped
|
||||||
|
/// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs
|
||||||
|
/// to the old one, and nothing else would ask for them again
|
||||||
|
/// (docs/REVIEW-2026-09-07.md's R5).
|
||||||
pub fn set_density(&mut self, density: f32) {
|
pub fn set_density(&mut self, density: f32) {
|
||||||
|
if density != self.density {
|
||||||
|
self.resized = true;
|
||||||
|
}
|
||||||
self.density = density;
|
self.density = density;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,15 +267,99 @@ impl UiRenderState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
if self.needs_redraw_all(root) {
|
debug_assert!(
|
||||||
|
self.draw_started.is_empty(),
|
||||||
|
"a previous frame left {} widget(s) marked as mid-draw",
|
||||||
|
self.draw_started.len(),
|
||||||
|
);
|
||||||
|
// Timed unconditionally -- an `Instant::now()` pair is cheap enough
|
||||||
|
// not to move the `--phone` bench's frame time (checked when this
|
||||||
|
// was added), and gating it behind the trace toggle would leave
|
||||||
|
// `iris::frame` with nothing to report the one frame somebody just
|
||||||
|
// turned tracing on to look at.
|
||||||
|
let layout_start = Instant::now();
|
||||||
|
let kind = 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());
|
||||||
self.resized = false;
|
self.resized = false;
|
||||||
|
RedrawKind::All
|
||||||
} else if rsc.widgets().has_updates() {
|
} else if rsc.widgets().has_updates() {
|
||||||
self.redraw_updates(rsc);
|
self.redraw_updates(rsc);
|
||||||
|
RedrawKind::Updates
|
||||||
|
} else {
|
||||||
|
RedrawKind::None
|
||||||
|
};
|
||||||
|
self.last_layout = layout_start.elapsed();
|
||||||
|
self.last_redraw_kind = kind;
|
||||||
|
self.frame_no += 1;
|
||||||
|
// After the redraw and before anything reads the frame: every
|
||||||
|
// slot freed above is still named by its layer's draw order until
|
||||||
|
// this runs.
|
||||||
|
self.apply_free();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Instant::now()` at construction -- see the field's own doc.
|
||||||
|
pub fn epoch(&self) -> Instant {
|
||||||
|
self.epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times [`Self::update`] has run, counting from 1.
|
||||||
|
pub fn frame_number(&self) -> u64 {
|
||||||
|
self.frame_no
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the last [`Self::update`]'s redraw phase took.
|
||||||
|
pub fn last_layout_duration(&self) -> Duration {
|
||||||
|
self.last_layout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the last [`Self::update`] did -- see [`RedrawKind`].
|
||||||
|
pub fn last_redraw_kind(&self) -> RedrawKind {
|
||||||
|
self.last_redraw_kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a real input sample was just dispatched, dated by the
|
||||||
|
/// sample's own clock -- called once per sensor pass, so `iris::frame`'s
|
||||||
|
/// `since_input` can answer "how stale was the input
|
||||||
|
/// this frame drew" instead of a caller guessing from the frame
|
||||||
|
/// interval. `&self` because `run_sensors` only ever has that -- see
|
||||||
|
/// `last_input_at`'s field doc.
|
||||||
|
pub fn note_input(&self, at: Instant) {
|
||||||
|
if let Ok(mut guard) = self.last_input_at.lock() {
|
||||||
|
*guard = Some(at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `now - ` the last input sample's own timestamp, or `None` if no
|
||||||
|
/// input has ever reached this render state (a cold start, or a screen
|
||||||
|
/// that only ever animates on its own). Saturates to zero rather than
|
||||||
|
/// panicking if `now` is earlier than the input sample somehow was --
|
||||||
|
/// a diagnostic reading wrong is not worth a crash over.
|
||||||
|
pub fn time_since_input(&self, now: Instant) -> Option<Duration> {
|
||||||
|
let at = *self.last_input_at.lock().ok()?;
|
||||||
|
at.map(|at| now.saturating_duration_since(at))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primitive instances every currently-active widget owns, summed --
|
||||||
|
/// what `iris::frame`'s `primitives=` reports. Not a per-frame delta:
|
||||||
|
/// `redraw_updates` only rewrites what changed, so this is "how much is
|
||||||
|
/// on screen", which is what a report reads as "did this frame have
|
||||||
|
/// more to draw than the last one", not "how much work did this frame
|
||||||
|
/// do" (`take_counters` answers that).
|
||||||
|
///
|
||||||
|
/// A mask's shape does not count: it is a [`Drawn::No`] primitive
|
||||||
|
/// that is never rasterized, so including it would put one extra on
|
||||||
|
/// the line for every masked widget and make a number Iris reads off
|
||||||
|
/// a phone report disagree with what is drawn.
|
||||||
|
pub fn active_primitive_count(&self) -> usize {
|
||||||
|
self.active
|
||||||
|
.values()
|
||||||
|
.map(|a| a.primitives.iter().filter(|h| h.pos != NOT_DRAWN).count())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||||
self.clear(rsc);
|
self.clear(rsc);
|
||||||
// free all resources & cache
|
// free all resources & cache
|
||||||
@@ -121,6 +373,7 @@ impl UiRenderState {
|
|||||||
MaskIdx::NONE,
|
MaskIdx::NONE,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
MaskIdx::NONE,
|
||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,12 +408,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 {
|
||||||
@@ -183,10 +451,19 @@ impl UiRenderState {
|
|||||||
// instead of redrawing. See LAYOUT.md section 3.
|
// instead of redrawing. See LAYOUT.md section 3.
|
||||||
let from = active.region;
|
let from = active.region;
|
||||||
for h in &active.primitives {
|
for h in &active.primitives {
|
||||||
let r = self.layers[h.layer].region_mut(h);
|
let r = self.primitives.region_mut(h);
|
||||||
*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;
|
||||||
}
|
}
|
||||||
@@ -194,10 +471,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
|
||||||
@@ -226,11 +518,21 @@ 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 -- an abort the first time the composer's scroll
|
||||||
|
// area was redrawn on the emulator, and now (masks nest) a mask
|
||||||
|
// whose parent is itself, which `set_mask`'s own assert names.
|
||||||
|
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(),
|
||||||
@@ -242,14 +544,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,
|
||||||
@@ -265,10 +579,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
|
||||||
@@ -296,6 +613,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;
|
||||||
@@ -330,6 +648,8 @@ 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(self.density)
|
.to_uivec2(self.density)
|
||||||
@@ -339,8 +659,27 @@ 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;
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,14 +688,26 @@ impl UiRenderState {
|
|||||||
let mut active = self.active.remove(&id);
|
let mut active = self.active.remove(&id);
|
||||||
if let Some(active) = &mut active {
|
if let Some(active) = &mut active {
|
||||||
for h in &active.primitives {
|
for h in &active.primitives {
|
||||||
let mask = self.layers.free(h);
|
let mask = self.primitives.free(h);
|
||||||
|
if h.pos != NOT_DRAWN {
|
||||||
|
self.layers[h.layer].free(h.pos, h.is_image());
|
||||||
|
}
|
||||||
if mask != MaskIdx::NONE {
|
if mask != MaskIdx::NONE {
|
||||||
rsc.ui_mut().masks.remove(mask);
|
rsc.ui_mut().masks.remove(mask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc);
|
||||||
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 captured by an id nothing will ever draw
|
||||||
|
// again. That path out is the sensor pass's, not this
|
||||||
|
// one's: `iris::sense::SensorUi::run_sensors` releases a
|
||||||
|
// capture whose widget no longer resolves to a region,
|
||||||
|
// which covers this case and every other way an id can
|
||||||
|
// stop being drawn.
|
||||||
// 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
|
||||||
@@ -364,6 +715,18 @@ 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, and the
|
||||||
|
// chain link's ref on the mask this one nests inside
|
||||||
|
// -- read from the arena entry, for the same reason
|
||||||
|
// the move slot's parent is.
|
||||||
|
let outer = rsc.ui().masks[active.own_mask.idx()].parent;
|
||||||
|
rsc.ui_mut().masks.remove(active.own_mask);
|
||||||
|
if outer != MaskIdx::NONE {
|
||||||
|
rsc.ui_mut().masks.remove(outer);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {
|
||||||
@@ -375,6 +738,54 @@ impl UiRenderState {
|
|||||||
active
|
active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A mask whose shape primitive was just freed clips to a slot that
|
||||||
|
/// now holds something else, so the widget that owns it is marked for
|
||||||
|
/// redraw -- its own `set_mask` is the only thing that resolves the
|
||||||
|
/// slot, and it is the same mechanism a dirty widget already goes
|
||||||
|
/// through.
|
||||||
|
///
|
||||||
|
/// `own` is the mask belonging to the widget being removed and is
|
||||||
|
/// skipped: this runs in the middle of that widget's own redraw,
|
||||||
|
/// which sets its mask again on the way out, and a mark left on
|
||||||
|
/// itself would redraw it every frame from then on. Skipping it is
|
||||||
|
/// also what keeps the O(active) scan off the ordinary path -- a
|
||||||
|
/// plain `.masked()` frees exactly its own shape, so `stale` is empty
|
||||||
|
/// and this returns before touching `active`.
|
||||||
|
///
|
||||||
|
/// Both `Vec`s start empty and stay unallocated in that case, and
|
||||||
|
/// membership is a linear scan of two lists that are a handful long
|
||||||
|
/// (a widget's own primitives, and the live masks): this runs once
|
||||||
|
/// per widget removed, which is once per dirty widget per frame, and
|
||||||
|
/// a set built there would be an allocation on the phone's frame
|
||||||
|
/// path in exchange for nothing at these sizes.
|
||||||
|
fn remask_shape_users(
|
||||||
|
active: &HashMap<WidgetId, ActiveData>,
|
||||||
|
id: WidgetId,
|
||||||
|
own: MaskIdx,
|
||||||
|
freed: &[PrimitiveHandle],
|
||||||
|
rsc: &mut dyn UiRsc,
|
||||||
|
) {
|
||||||
|
let mut stale: Vec<MaskIdx> = Vec::new();
|
||||||
|
for (i, mask) in rsc.ui().masks.iter().enumerate() {
|
||||||
|
let idx = Id::preset(i as u32);
|
||||||
|
if idx != own && freed.iter().any(|h| h.slot == mask.primitive) {
|
||||||
|
stale.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stale.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut owners: Vec<WidgetId> = Vec::new();
|
||||||
|
for (widget, data) in active {
|
||||||
|
if *widget != id && stale.contains(&data.own_mask) {
|
||||||
|
owners.push(*widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner in owners {
|
||||||
|
rsc.widgets_mut().needs_redraw.insert(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
|
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
|
||||||
let inst = self.remove(id, true, rsc);
|
let inst = self.remove(id, true, rsc);
|
||||||
if let Some(inst) = &inst {
|
if let Some(inst) = &inst {
|
||||||
@@ -390,6 +801,7 @@ impl UiRenderState {
|
|||||||
rsc.on_undraw(&active);
|
rsc.on_undraw(&active);
|
||||||
}
|
}
|
||||||
self.layers.clear();
|
self.layers.clear();
|
||||||
|
self.primitives.clear();
|
||||||
rsc.widgets_mut().needs_redraw.clear();
|
rsc.widgets_mut().needs_redraw.clear();
|
||||||
rsc.free();
|
rsc.free();
|
||||||
}
|
}
|
||||||
@@ -429,6 +841,75 @@ 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. `(slot, owner)` each --
|
||||||
|
/// the arena knows which primitive, not which layer's draw order still
|
||||||
|
/// names it.
|
||||||
|
///
|
||||||
|
/// 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<(u32, WidgetId)> {
|
||||||
|
let mut orphans = Vec::new();
|
||||||
|
for (slot, owner, _) in self.primitives.live_instances() {
|
||||||
|
let owned = self
|
||||||
|
.active
|
||||||
|
.get(&owner)
|
||||||
|
.is_some_and(|a| a.primitives.iter().any(|h| h.slot == slot));
|
||||||
|
if !owned {
|
||||||
|
orphans.push((slot, 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.primitives.live_count();
|
||||||
|
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(|(slot, owner)| {
|
||||||
|
let alive = self.active.contains_key(owner);
|
||||||
|
format!(
|
||||||
|
" instance {slot}: 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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -437,12 +918,12 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn debug_layers(&self) {
|
pub fn debug_layers(&self) {
|
||||||
for ((idx, depth), primitives) in self.layers.iter_depth() {
|
for ((idx, depth), order) in self.layers.iter_depth() {
|
||||||
let indent = " ".repeat(depth * 2);
|
let indent = " ".repeat(depth * 2);
|
||||||
let len = primitives.instances().len();
|
let len = order.order().len();
|
||||||
print!("{indent}{idx}: {len} primitives");
|
print!("{indent}{idx}: {len} primitives");
|
||||||
if len >= 1 {
|
if len >= 1 {
|
||||||
print!(" ({})", primitives.instances()[0].binding);
|
print!(" ({})", self.primitives.instance(order.order()[0]).binding);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
@@ -455,34 +936,156 @@ 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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
||||||
/// 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 `PARENT_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;
|
||||||
for i in 0..MOVE_CHAIN_LIMIT {
|
let mut at = slot;
|
||||||
let entry = &offsets[slot.idx()];
|
for i in 0..PARENT_CHAIN_LIMIT {
|
||||||
|
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 < PARENT_CHAIN_LIMIT,
|
||||||
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
|
"move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \
|
||||||
probably cyclic"
|
-- a \
|
||||||
|
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
|
||||||
|
/// `PARENT_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..PARENT_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(" -> ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One primitive's corners in window pixels -- the transliteration of
|
||||||
|
/// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is
|
||||||
|
/// the whole reason this is not `region.to_px()`: the shader floors
|
||||||
|
/// each half separately before adding the move delta, and a hit test
|
||||||
|
/// that skipped it would disagree with the pixels by up to one along
|
||||||
|
/// each edge -- invisible in every test written against a whole-pixel
|
||||||
|
/// layout and wrong on the phone, whose 2.55 density makes nothing
|
||||||
|
/// land on a whole pixel.
|
||||||
|
pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion {
|
||||||
|
let inst = self.primitives.instance(slot);
|
||||||
|
let delta = self.resolve_move_chain(inst.move_idx, rsc);
|
||||||
|
let size = self.output_size;
|
||||||
|
let corner = |c: UiVec2| (c.get_rel() * size).floor() + c.get_abs().floor() + delta;
|
||||||
|
PixelRegion {
|
||||||
|
top_left: corner(inst.region.top_left()),
|
||||||
|
bot_right: corner(inst.region.bot_right()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a mask's clip actually is on screen: the box of the
|
||||||
|
/// primitive it references. Its *shape* within that box is
|
||||||
|
/// [`Self::mask_coverage`]'s -- this is the bounding box, which is
|
||||||
|
/// what a test asking "is the clip over the right part of the screen"
|
||||||
|
/// wants and all a square-cornered mask has ever had.
|
||||||
|
pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion {
|
||||||
|
self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the pixel at `pos` (window pixels) survives `mask` and
|
||||||
|
/// every mask it nests inside: the referenced primitives' own
|
||||||
|
/// coverage, multiplied along the chain. The CPU half of
|
||||||
|
/// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same
|
||||||
|
/// `rounded_rect_coverage` -- so a corner that cannot be tapped and a
|
||||||
|
/// corner that is not drawn are the same corner (LAYOUT.md's "Masks
|
||||||
|
/// with a shape", point 4).
|
||||||
|
///
|
||||||
|
/// A mask whose shape is not a rect covers everything, exactly as the
|
||||||
|
/// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects
|
||||||
|
/// those by name, so this is the unreachable half of the same
|
||||||
|
/// agreement rather than a second policy.
|
||||||
|
pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 {
|
||||||
|
let mut coverage = 1.0;
|
||||||
|
let mut at = mask;
|
||||||
|
for i in 0..PARENT_CHAIN_LIMIT {
|
||||||
|
if at == MaskIdx::NONE {
|
||||||
|
return coverage;
|
||||||
|
}
|
||||||
|
let m = rsc.ui().masks[at.idx()];
|
||||||
|
if let Some(rect) = self.primitives.primitive_data::<RectPrimitive>(m.primitive) {
|
||||||
|
let c = self.primitive_corners(m.primitive, rsc);
|
||||||
|
coverage *= rounded_rect_coverage(pos, c.top_left, c.bot_right, rect.radius);
|
||||||
|
}
|
||||||
|
at = m.parent;
|
||||||
|
debug_assert!(
|
||||||
|
i + 1 < PARENT_CHAIN_LIMIT || at == MaskIdx::NONE,
|
||||||
|
"mask chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}) from {mask:?} -- a \
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
coverage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `pos` is inside `mask` at all -- more than half covered,
|
||||||
|
/// which is where the drawn edge is (`rounded_rect_coverage`'s doc).
|
||||||
|
/// What a hit test asks.
|
||||||
|
pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool {
|
||||||
|
self.mask_coverage(mask, pos, rsc) > 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first primitive `id`'s subtree wrote this frame, depth first
|
||||||
|
/// in draw order -- what a mask pointed at a widget clips to
|
||||||
|
/// (`Painter::set_mask_to_widget`). A widget that draws more than one
|
||||||
|
/// (a bordered rect is one primitive; a card with a stripe is two)
|
||||||
|
/// gives its first; a widget that wants another names it.
|
||||||
|
pub fn first_primitive(&self, id: WidgetId) -> Option<u32> {
|
||||||
|
let active = self.active.get(&id)?;
|
||||||
|
if let Some(h) = active.primitives.first() {
|
||||||
|
return Some(h.slot);
|
||||||
|
}
|
||||||
|
active
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.find_map(|child| self.first_primitive(*child))
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -491,7 +1094,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;
|
||||||
}
|
}
|
||||||
@@ -515,9 +1121,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 () {
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
//! Where the desktop app keeps the enrollment it should not have to be
|
//! Where the desktop app keeps its enrollment: `client_core::config`'s
|
||||||
//! told about a second time: `client_core::config::EnrolledServer`,
|
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
|
||||||
//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`,
|
|
||||||
//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer
|
|
||||||
//! token, and the reason `client_core::config`'s own doc comment leaves
|
|
||||||
//! persistence and file mode to the caller.
|
|
||||||
//!
|
//!
|
||||||
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
//! Only the directory is this app's -- the file's name, its JSON, and its
|
||||||
//! rules (`format`) are for configs a person hand-edits, and this file
|
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
|
||||||
//! never is one -- only this program ever writes or reads it, and
|
//! are the store's, shared with the Android client so the two cannot come
|
||||||
//! `serde_json` is already in the dependency graph through `client-core`,
|
//! to disagree about them.
|
||||||
//! so nothing new is added to reach for it.
|
|
||||||
|
|
||||||
use client_core::config::EnrolledServer;
|
use client_core::config::EnrollmentStore;
|
||||||
use std::io;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
|
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
|
||||||
/// the XDG basedir spec says to when the variable is unset -- the same
|
/// the XDG basedir spec says to when the variable is unset -- the same
|
||||||
@@ -32,103 +26,6 @@ pub fn config_dir() -> PathBuf {
|
|||||||
base.join("ai-app-desktop")
|
base.join("ai-app-desktop")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enrollment_file(dir: &Path) -> PathBuf {
|
pub fn store() -> EnrollmentStore {
|
||||||
dir.join("enrollment.json")
|
EnrollmentStore::new(config_dir())
|
||||||
}
|
|
||||||
|
|
||||||
/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in
|
|
||||||
/// the tests below), creating it if needed, and sets the file owner-only --
|
|
||||||
/// it carries a bearer token, the same reason `server/`'s own token store
|
|
||||||
/// is 0600.
|
|
||||||
pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> {
|
|
||||||
std::fs::create_dir_all(dir)?;
|
|
||||||
let path = enrollment_file(dir);
|
|
||||||
let json = serde_json::to_vec_pretty(server)
|
|
||||||
.expect("EnrolledServer holds nothing that fails to serialise");
|
|
||||||
std::fs::write(&path, json)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Ok(None)` when nothing has been enrolled yet, rather than an error --
|
|
||||||
/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES'
|
|
||||||
/// "a deliberate choice is not a problem to report" applies just as well
|
|
||||||
/// to a file that simply hasn't been written yet).
|
|
||||||
pub fn load_enrollment_in(dir: &Path) -> io::Result<Option<EnrolledServer>> {
|
|
||||||
let path = enrollment_file(dir);
|
|
||||||
match std::fs::read(&path) {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
format!("{} is not a valid enrollment ({e})", path.display()),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Some(server))
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> {
|
|
||||||
save_enrollment_in(&config_dir(), server)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_enrollment() -> io::Result<Option<EnrolledServer>> {
|
|
||||||
load_enrollment_in(&config_dir())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_saved_enrollment_reads_back_the_same() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let server = EnrolledServer {
|
|
||||||
host: "127.0.0.1".to_string(),
|
|
||||||
port: 8547,
|
|
||||||
token: "tok".to_string(),
|
|
||||||
};
|
|
||||||
save_enrollment_in(dir.path(), &server).unwrap();
|
|
||||||
let read_back = load_enrollment_in(dir.path()).unwrap();
|
|
||||||
assert_eq!(read_back, Some(server));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nothing_saved_yet_is_none_not_an_error() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
assert_eq!(load_enrollment_in(dir.path()).unwrap(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn the_saved_file_is_owner_only() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let server = EnrolledServer {
|
|
||||||
host: "h".to_string(),
|
|
||||||
port: 1,
|
|
||||||
token: "t".to_string(),
|
|
||||||
};
|
|
||||||
save_enrollment_in(dir.path(), &server).unwrap();
|
|
||||||
let mode = std::fs::metadata(enrollment_file(dir.path()))
|
|
||||||
.unwrap()
|
|
||||||
.permissions()
|
|
||||||
.mode();
|
|
||||||
assert_eq!(mode & 0o777, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_corrupt_file_is_named_in_the_error() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(enrollment_file(dir.path()), b"not json").unwrap();
|
|
||||||
let err = load_enrollment_in(dir.path()).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("enrollment.json"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,20 @@
|
|||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//!
|
//!
|
||||||
//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T'
|
//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B'
|
||||||
//! desktop-app --ca /path/to/ca.pem # after the first run above
|
//! desktop-app # after the first run above
|
||||||
|
//! desktop-app --ca /path/to/ca.pem # a link that carries no CA
|
||||||
//!
|
//!
|
||||||
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
|
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
|
||||||
//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather
|
//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather
|
||||||
//! than scanned, since a desktop has no camera to assume. It is parsed and
|
//! than scanned, since a desktop has no camera to assume. It is parsed and
|
||||||
//! saved to `config::save_enrollment` once; later runs read it back and
|
//! saved once; later runs read it back and `--link` is only needed again
|
||||||
//! `--link` is only needed again to enrol against a different server. The
|
//! to enrol against a different server.
|
||||||
//! CA is never persisted -- it is a public certificate whose path a
|
//!
|
||||||
//! caller is expected to already know (`AGENTS.md`'s "prefer exercising
|
//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which
|
||||||
//! the server directly": the same `certs/ca.pem` a `curl --cacert` call
|
//! `ai-server` now always includes) and is saved with it. `--ca` is the
|
||||||
//! uses).
|
//! override for a link that carries none, and names the same
|
||||||
|
//! `certs/ca.pem` a `curl --cacert` call uses.
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -24,7 +26,7 @@ mod config;
|
|||||||
use client_core::config::EnrolledServer;
|
use client_core::config::EnrolledServer;
|
||||||
|
|
||||||
struct Args {
|
struct Args {
|
||||||
ca_path: std::path::PathBuf,
|
ca_path: Option<std::path::PathBuf>,
|
||||||
link: Option<String>,
|
link: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +45,7 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
other => return Err(format!("unrecognised argument '{other}'")),
|
other => return Err(format!("unrecognised argument '{other}'")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Args {
|
Ok(Args { ca_path, link })
|
||||||
ca_path: ca_path.ok_or(
|
|
||||||
"--ca PATH is required (the pinned CA's certificate, e.g. \
|
|
||||||
~/.config/ai-app/certs/ca.pem)",
|
|
||||||
)?,
|
|
||||||
link,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
|
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
|
||||||
@@ -60,14 +56,17 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
/// other way (`DefaultApp::run()` takes no payload).
|
/// other way (`DefaultApp::run()` takes no payload).
|
||||||
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||||
let args = parse_args()?;
|
let args = parse_args()?;
|
||||||
|
let store = config::store();
|
||||||
let server = match args.link {
|
let server = match args.link {
|
||||||
Some(link) => {
|
Some(link) => {
|
||||||
let server = EnrolledServer::parse_link(&link)?;
|
let server = EnrolledServer::parse_link(&link)?;
|
||||||
config::save_enrollment(&server)
|
store
|
||||||
|
.save(&server)
|
||||||
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||||
server
|
server
|
||||||
}
|
}
|
||||||
None => config::load_enrollment()
|
None => store
|
||||||
|
.load()
|
||||||
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -77,8 +76,20 @@ fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
|||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
};
|
};
|
||||||
let ca_pem = std::fs::read(&args.ca_path)
|
// `--ca` wins where it was given, so a caller can point a link's
|
||||||
.map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?;
|
// server at a certificate it did not carry -- and so the flag still
|
||||||
|
// means what it did before the link could carry one.
|
||||||
|
let ca_pem = match (&args.ca_path, &server.ca_pem) {
|
||||||
|
(Some(path), _) => std::fs::read(path)
|
||||||
|
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
|
||||||
|
(None, Some(pem)) => pem.clone().into_bytes(),
|
||||||
|
(None, None) => {
|
||||||
|
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
|
||||||
|
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
|
||||||
|
minted by a server that includes one"
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
Ok((server, ca_pem))
|
Ok((server, ca_pem))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ impl DefaultAppState for State {
|
|||||||
}
|
}
|
||||||
let span = rsc.ui.widgets.add_strong(span);
|
let span = rsc.ui.widgets.add_strong(span);
|
||||||
let span_weak = span.weak();
|
let span_weak = span.weak();
|
||||||
let root = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y));
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Scroll::new(span.any(), Axis::Y, true));
|
||||||
ui_state.set_root(root.any());
|
ui_state.set_root(root.any());
|
||||||
Self {
|
Self {
|
||||||
ui_state,
|
ui_state,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proc-macro2 = "1.0.103"
|
proc-macro2 = "1.0.107"
|
||||||
quote = "1.0.42"
|
quote = "1.0.47"
|
||||||
syn = { version = "2.0.111", features = ["full"] }
|
syn = { version = "3.0.5", features = ["full"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
@@ -18,6 +18,12 @@ struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct InputFn {
|
struct InputFn {
|
||||||
|
/// Everything written above the `fn` -- in practice a `///` doc
|
||||||
|
/// comment, which is why this exists: `masked_by` and its siblings
|
||||||
|
/// are public API and rustdoc is where their contract is read, so a
|
||||||
|
/// macro that silently rejected `///` sent the explanation into an
|
||||||
|
/// ordinary `//` comment nobody generating docs ever sees.
|
||||||
|
attrs: Vec<Attribute>,
|
||||||
sig: Signature,
|
sig: Signature,
|
||||||
body: Block,
|
body: Block,
|
||||||
}
|
}
|
||||||
@@ -32,9 +38,10 @@ impl Parse for Input {
|
|||||||
input.parse::<Token![;]>()?;
|
input.parse::<Token![;]>()?;
|
||||||
let mut fns = Vec::new();
|
let mut fns = Vec::new();
|
||||||
while !input.is_empty() {
|
while !input.is_empty() {
|
||||||
|
let attrs = input.call(Attribute::parse_outer)?;
|
||||||
let sig = input.parse()?;
|
let sig = input.parse()?;
|
||||||
let body = input.parse()?;
|
let body = input.parse()?;
|
||||||
fns.push(InputFn { sig, body })
|
fns.push(InputFn { attrs, sig, body })
|
||||||
}
|
}
|
||||||
if !input.is_empty() {
|
if !input.is_empty() {
|
||||||
input.error("function expected");
|
input.error("function expected");
|
||||||
@@ -59,10 +66,15 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
|
|||||||
fns,
|
fns,
|
||||||
} = parse_macro_input!(input as Input);
|
} = parse_macro_input!(input as Input);
|
||||||
|
|
||||||
let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect();
|
// The attributes go on the trait's own signature, which is the one
|
||||||
|
// rustdoc renders; the impl gets the bare `fn`.
|
||||||
|
let sigs: Vec<_> = fns
|
||||||
|
.iter()
|
||||||
|
.map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig })
|
||||||
|
.collect();
|
||||||
let impls: Vec<_> = fns
|
let impls: Vec<_> = fns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|InputFn { sig, body }| quote! { #sig #body })
|
.map(|InputFn { sig, body, .. }| quote! { #sig #body })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let Some(GenericParam::Type(state)) = generics.params.first() else {
|
let Some(GenericParam::Type(state)) = generics.params.first() else {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,25 @@
|
|||||||
#
|
#
|
||||||
# ./run-headless.sh tabs [-- cargo args]
|
# ./run-headless.sh tabs [-- cargo args]
|
||||||
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
||||||
|
# ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
|
||||||
|
# ./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
|
||||||
|
# --shot /tmp/p.png -- -p transcript-fixture
|
||||||
|
#
|
||||||
|
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
|
||||||
|
# and the window take Iris's phone's own size and density (1080x2424 at
|
||||||
|
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
|
||||||
|
# carried in `transcript_fixture::PHONE_*`), and `IRIS_SCALE` hands that
|
||||||
|
# density to iris the way `DisplayMetrics.density` does on Android
|
||||||
|
# (`iris::default::content_scale`). So a screenshot from here and one
|
||||||
|
# from the phone are the same layout at the same density, and what
|
||||||
|
# differs is only the renderer. Without it the output stays desktop-
|
||||||
|
# shaped, which is what every other example wants.
|
||||||
|
#
|
||||||
|
# `--replay FILE` drives one of the `.touch` recordings the headless
|
||||||
|
# tests use (`iris/transcript-fixture/touch/`) into the window through
|
||||||
|
# `rig-input`'s `replay-touch` -- one recording, both layers. With
|
||||||
|
# `--shot` it also writes `<shot>-before.png` from just before the
|
||||||
|
# gesture, since "the list moved" is a claim about two pictures.
|
||||||
#
|
#
|
||||||
# `--bin` runs a real crate binary instead of an example (E4's
|
# `--bin` runs a real crate binary instead of an example (E4's
|
||||||
# `desktop-app`, which is a window a person runs, not a demo) --
|
# `desktop-app`, which is a window a person runs, not a demo) --
|
||||||
@@ -14,10 +33,10 @@
|
|||||||
# through positionally without disturbing the existing `-- cargo args`
|
# through positionally without disturbing the existing `-- cargo args`
|
||||||
# convention above.
|
# convention above.
|
||||||
#
|
#
|
||||||
# The VM has a virtio-gpu render node (Vulkan 1.4 through Venus, GL 4.6
|
# The VM has a real GPU and no display (the `this-machine-graphics` skill
|
||||||
# through virgl), so wgpu runs on the host's real GPU -- what is missing is
|
# says what it is and how it fails), so what is missing here is only a
|
||||||
# only a compositor to give winit a surface. So: a headless sway, the same
|
# compositor to give winit a surface. So: a headless sway, the same trick
|
||||||
# trick `emu` uses for the Android emulator, and `grim` to see the result.
|
# `emu` uses for the Android emulator, and `grim` to see the result.
|
||||||
#
|
#
|
||||||
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
|
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
|
||||||
# to the one an emulator is sitting in resizes that emulator's window, and a
|
# to the one an emulator is sitting in resizes that emulator's window, and a
|
||||||
@@ -29,19 +48,31 @@ here=$(cd "$(dirname "$0")" && pwd)
|
|||||||
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
||||||
seconds=3
|
seconds=3
|
||||||
shot=""
|
shot=""
|
||||||
|
replay=""
|
||||||
example=""
|
example=""
|
||||||
kind=example
|
kind=example
|
||||||
|
phone=no
|
||||||
|
|
||||||
|
# The phone Iris runs the bench on. Not typed from memory: these are
|
||||||
|
# `transcript_fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
|
||||||
|
# in turn come from her own reports -- keep the three in step.
|
||||||
|
PHONE_MODE=1080x2424@120Hz
|
||||||
|
PHONE_SCALE=2.55
|
||||||
|
DESKTOP_MODE=1920x1200@60Hz
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--shot) shot=$2; shift 2 ;;
|
--shot) shot=$2; shift 2 ;;
|
||||||
--seconds) seconds=$2; shift 2 ;;
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
--bin) kind=bin; shift ;;
|
--bin) kind=bin; shift ;;
|
||||||
|
--phone) phone=yes; shift ;;
|
||||||
|
--replay) replay=$2; shift 2 ;;
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
*) example=$1; shift ;;
|
*) example=$1; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
||||||
|
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
|
||||||
|
|
||||||
mkdir -p "$run"
|
mkdir -p "$run"
|
||||||
export SWAYSOCK="$run/sway.sock"
|
export SWAYSOCK="$run/sway.sock"
|
||||||
@@ -78,6 +109,27 @@ export WAYLAND_DISPLAY
|
|||||||
|
|
||||||
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
||||||
|
|
||||||
|
# Set every run rather than only when it changes: this compositor is
|
||||||
|
# reused across runs (see the socket comment above), so a desktop-shaped
|
||||||
|
# run after a phone-shaped one would otherwise inherit the phone's output
|
||||||
|
# and silently screenshot the wrong size.
|
||||||
|
if [ "$phone" = yes ]; then
|
||||||
|
mode=$PHONE_MODE
|
||||||
|
export IRIS_SCALE="$PHONE_SCALE"
|
||||||
|
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
|
||||||
|
else
|
||||||
|
mode=$DESKTOP_MODE
|
||||||
|
fi
|
||||||
|
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
|
||||||
|
# The extent `replay-touch` positions against, so a script's coordinates
|
||||||
|
# are the output's own pixels.
|
||||||
|
out_w=${mode%x*}
|
||||||
|
out_h=${mode#*x}; out_h=${out_h%@*}
|
||||||
|
|
||||||
|
# Built before the app starts, so a compile error is not reported as a
|
||||||
|
# window that failed to move.
|
||||||
|
[ -z "$replay" ] || cargo build --bin replay-touch -p rig-input >&2
|
||||||
|
|
||||||
cd "$here"
|
cd "$here"
|
||||||
if [ "$kind" = bin ]; then
|
if [ "$kind" = bin ]; then
|
||||||
cargo build --bin "$example" "$@" >&2
|
cargo build --bin "$example" "$@" >&2
|
||||||
@@ -111,6 +163,18 @@ while [ $i -lt "$((seconds * 2))" ]; do
|
|||||||
i=$((i + 1)); sleep 0.5
|
i=$((i + 1)); sleep 0.5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
if [ -n "$shot" ]; then
|
||||||
|
grim "${shot%.png}-before.png"
|
||||||
|
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
|
||||||
|
fi
|
||||||
|
"$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
|
||||||
|
# A fling outlives the finger: the gesture's own last sample is not
|
||||||
|
# when the list stops. Long enough for Android's spline to settle
|
||||||
|
# (`FlingCalculator::duration` tops out around a second and a half).
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
if kill -0 "$pid" 2>/dev/null; then
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ impl<T: HasAndroidUiState> FocusHost for T {
|
|||||||
self.android_state_mut().focus = id;
|
self.android_state_mut().focus = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.android_state().focus == Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||||
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
|
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
|
||||||
// and this runs deep inside the platform-agnostic sensor dispatch
|
// and this runs deep inside the platform-agnostic sensor dispatch
|
||||||
|
|||||||
@@ -49,6 +49,52 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
|
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
|
||||||
self.state.android_state().focus
|
self.state.android_state().focus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell Gboard where the caret/selection and the composing region
|
||||||
|
/// actually are, via `InputMethodManager.updateSelection` -- every one
|
||||||
|
/// of android-view's own demo's `set_composing_text_internal`/`render`
|
||||||
|
/// calls this, and this bridge never did, which is what left Gboard's
|
||||||
|
/// own model of the field diverging from `TextEdit`'s real one after
|
||||||
|
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
|
||||||
|
/// hit space, and also doesn't move cursor forward" -- Gboard holds
|
||||||
|
/// its composing keystrokes back until it believes the app has caught
|
||||||
|
/// up, and without this call it never does). Called from
|
||||||
|
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
|
||||||
|
/// callback already runs through, rather than duplicated at each of
|
||||||
|
/// this file's mutating methods.
|
||||||
|
///
|
||||||
|
/// `candidates_start`/`candidates_end` report the composing region;
|
||||||
|
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
|
||||||
|
/// convention. `compose_len` is tracked in `char`s (this module's doc
|
||||||
|
/// comment), so this reports it as that many UTF-16 units back from the
|
||||||
|
/// caret -- exact for the common BMP case, the same approximation
|
||||||
|
/// `set_composing_text` already makes.
|
||||||
|
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
|
||||||
|
let Some(focus) = self.focus() else { return };
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let Some(sel) = text.selection_range() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let content = text.text();
|
||||||
|
let sel_start = byte_to_utf16(content, sel.start) as i32;
|
||||||
|
let sel_end = byte_to_utf16(content, sel.end) as i32;
|
||||||
|
let compose_len = self.state.android_state().compose_len;
|
||||||
|
let (comp_start, comp_end) = if compose_len > 0 {
|
||||||
|
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
|
||||||
|
(caret - compose_len as i32, caret)
|
||||||
|
} else {
|
||||||
|
(-1, -1)
|
||||||
|
};
|
||||||
|
let imm = ctx.view.input_method_manager(&mut ctx.env);
|
||||||
|
imm.update_selection(
|
||||||
|
&mut ctx.env,
|
||||||
|
&ctx.view,
|
||||||
|
sel_start,
|
||||||
|
sel_end,
|
||||||
|
comp_start,
|
||||||
|
comp_end,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||||
|
|||||||
@@ -45,16 +45,38 @@ pub struct Insets {
|
|||||||
pub top: i32,
|
pub top: i32,
|
||||||
pub right: i32,
|
pub right: i32,
|
||||||
pub bottom: i32,
|
pub bottom: i32,
|
||||||
/// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate
|
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
|
||||||
/// from `bottom` (the system bars): a layout wants to know about the
|
/// pixels, separate from `bottom` (the system bars): a layout wants to
|
||||||
/// keyboard specifically, since it usually means "make room" rather
|
/// know about the keyboard specifically, since it usually means "make
|
||||||
/// than "stay clear of a corner".
|
/// room" rather than "stay clear of a corner".
|
||||||
pub ime_bottom: i32,
|
pub ime_bottom: i32,
|
||||||
|
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||||
|
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||||
|
/// are carried separately. They disagree for the frames the keyboard
|
||||||
|
/// spends sliding: visible, with a height still on its way to the full
|
||||||
|
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||||
|
/// anything asking "is the keyboard up" reads this. See
|
||||||
|
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||||
|
/// be sent *as* this boolean, which is what left the composer padded by
|
||||||
|
/// one pixel on Iris's phone.
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Shared {
|
pub struct Shared {
|
||||||
pub insets: Insets,
|
pub insets: Insets,
|
||||||
|
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||||
|
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||||
|
/// field of `Insets`, which is compared for equality each frame to
|
||||||
|
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||||
|
/// would make every dispatch look like a change.
|
||||||
|
///
|
||||||
|
/// It exists because "the keyboard does not push anything up" has two
|
||||||
|
/// completely different causes that look identical on screen -- the
|
||||||
|
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||||
|
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||||
|
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||||
|
pub updates: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||||
@@ -89,15 +111,19 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
right: jint,
|
right: jint,
|
||||||
bottom: jint,
|
bottom: jint,
|
||||||
ime_bottom: jint,
|
ime_bottom: jint,
|
||||||
|
ime_visible: jint,
|
||||||
) {
|
) {
|
||||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||||
shared.borrow_mut().insets = Insets {
|
let mut shared = shared.borrow_mut();
|
||||||
|
shared.insets = Insets {
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
right,
|
right,
|
||||||
bottom,
|
bottom,
|
||||||
ime_bottom,
|
ime_bottom,
|
||||||
|
ime_visible: ime_visible != 0,
|
||||||
};
|
};
|
||||||
|
shared.updates += 1;
|
||||||
}
|
}
|
||||||
// Insets can change (the keyboard opening) with no resize and no
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
// touch, so nothing else here would otherwise ask for a frame.
|
// touch, so nothing else here would otherwise ask for a frame.
|
||||||
@@ -115,7 +141,7 @@ pub fn register_native_methods<'local, 'other_local>(
|
|||||||
&[
|
&[
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
name: "applyWindowInsetsNative".into(),
|
name: "applyWindowInsetsNative".into(),
|
||||||
sig: "(JIIIII)V".into(),
|
sig: "(JIIIIII)V".into(),
|
||||||
fn_ptr: apply_window_insets as *mut c_void,
|
fn_ptr: apply_window_insets as *mut c_void,
|
||||||
},
|
},
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod attr;
|
|||||||
mod ime;
|
mod ime;
|
||||||
mod input;
|
mod input;
|
||||||
mod insets;
|
mod insets;
|
||||||
|
mod platform;
|
||||||
mod render;
|
mod render;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{JNIEnv, objects::JValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::view::HasAndroidUiState;
|
||||||
|
|
||||||
|
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
|
||||||
|
/// real work is a JNI call and this runs deep inside the sensor dispatch
|
||||||
|
/// with no `CallbackCtx` in reach -- so it raises a flag that
|
||||||
|
/// `IrisViewPeer::after_input` consumes, exactly as
|
||||||
|
/// `pending_show_keyboard` does.
|
||||||
|
///
|
||||||
|
/// Last request wins: two links cannot be tapped in one frame, and a URL
|
||||||
|
/// left queued from a frame that somehow never reached `after_input`
|
||||||
|
/// would open at some unrelated later tap, which is worse than dropping
|
||||||
|
/// it.
|
||||||
|
impl<T: HasAndroidUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.android_state_mut().pending_open_url = Some(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||||
|
/// own context.
|
||||||
|
///
|
||||||
|
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||||
|
/// may be an application context rather than the activity's -- Android
|
||||||
|
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||||
|
/// and it is harmless when the context *is* an activity's.
|
||||||
|
///
|
||||||
|
/// Every failure is logged with the URL and returns; there is nothing to
|
||||||
|
/// fall back to, and the reader will see that nothing happened.
|
||||||
|
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||||
|
match try_open_url(env, view, url) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// A pending Java exception makes every later JNI call fail in
|
||||||
|
// ways nowhere near here, so it is cleared at the boundary.
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
log::warn!("could not open {url}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_open_url<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
view: &View<'local>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), android_view::jni::errors::Error> {
|
||||||
|
let context = env
|
||||||
|
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let jurl = env.new_string(url)?;
|
||||||
|
let uri = env.call_static_method(
|
||||||
|
"android/net/Uri",
|
||||||
|
"parse",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri;",
|
||||||
|
&[JValue::Object(jurl.as_ref())],
|
||||||
|
)?;
|
||||||
|
let action = env.new_string("android.intent.action.VIEW")?;
|
||||||
|
let intent = env.new_object(
|
||||||
|
"android/content/Intent",
|
||||||
|
"(Ljava/lang/String;Landroid/net/Uri;)V",
|
||||||
|
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&intent,
|
||||||
|
"addFlags",
|
||||||
|
"(I)Landroid/content/Intent;",
|
||||||
|
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&context,
|
||||||
|
"startActivity",
|
||||||
|
"(Landroid/content/Intent;)V",
|
||||||
|
&[JValue::Object(&intent)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
|
||||||
|
/// a static-field read: it is part of the platform's stable ABI and
|
||||||
|
/// reading it costs two more JNI calls that can each fail.
|
||||||
|
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
|
||||||