Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800da46188 | ||
|
|
00767eed4d | ||
|
|
683db4908a | ||
|
|
8d23a20792 | ||
|
|
d01c105037 | ||
|
|
88631f5e8b | ||
|
|
e6924298bc | ||
|
|
68b48cfd14 | ||
|
|
e6c884a0cd | ||
|
|
a6cb9a9082 | ||
|
|
0be6a571c4 | ||
|
|
bfe93c4188 | ||
|
|
5b7dc0e4e2 | ||
|
|
621f08d725 | ||
|
|
e49d0e606f | ||
|
|
e2a1fadbec | ||
|
|
0e4629361b | ||
|
|
1e7b1cddb7 | ||
|
|
470f8e5019 | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
6e49ce8c92 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a |
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||||
|
# every crate in this repo into one workspace -- they are deliberately
|
||||||
|
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
||||||
|
# from the repo root runs xtask/src/main.rs directly.
|
||||||
|
[alias]
|
||||||
|
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
||||||
@@ -55,4 +55,21 @@ components: [
|
|||||||
// the terminal the QR would be printed on.
|
// the terminal the QR would be printed on.
|
||||||
enroll: "server/enroll-link.sh",
|
enroll: "server/enroll-link.sh",
|
||||||
),
|
),
|
||||||
|
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
|
||||||
|
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
|
||||||
|
// with the same release key as "app" above so the two can install
|
||||||
|
// over each other -- a separate component, not a mode of "app" above,
|
||||||
|
// because it is a different applicationId (com.example.aiapp.shell)
|
||||||
|
// built by a different tool from different sources. No `cwd`: it
|
||||||
|
// defaults to this checkout's root, which both the `cargo xtask`
|
||||||
|
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||||
|
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||||
|
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
||||||
|
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
||||||
|
// both need.
|
||||||
|
Apk(
|
||||||
|
name: "shell",
|
||||||
|
modes: ["release", "debug"],
|
||||||
|
build: "cargo xtask apk",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
+19
@@ -1,12 +1,20 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
app/androidApp/build/
|
app/androidApp/build/
|
||||||
|
app/shellApp/build/
|
||||||
local.properties
|
local.properties
|
||||||
.kotlin/
|
.kotlin/
|
||||||
*.iml
|
*.iml
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
server/target/
|
||||||
|
event-model/target/
|
||||||
|
client-core/target/
|
||||||
|
android-shell/target/
|
||||||
|
|
||||||
|
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||||
|
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||||
|
app/shellApp/src/main/jniLibs/
|
||||||
|
|
||||||
# Server logs from a development run (ai-server.log by convention,
|
# Server logs from a development run (ai-server.log by convention,
|
||||||
# wg-test.log from ./test-wg-tunnel.sh).
|
# wg-test.log from ./test-wg-tunnel.sh).
|
||||||
@@ -21,3 +29,14 @@ certs/
|
|||||||
config.ron
|
config.ron
|
||||||
config.json
|
config.json
|
||||||
sessions/
|
sessions/
|
||||||
|
|
||||||
|
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||||
|
iris/target/
|
||||||
|
iris/android-app/target/
|
||||||
|
|
||||||
|
# E5's packaging xtask (RUST.md). `build/` above already covers
|
||||||
|
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
||||||
|
# The repo root has no Cargo workspace, so this is xtask's own
|
||||||
|
# intermediate working files (target/xtask/apk/...), not a shared one.
|
||||||
|
xtask/target/
|
||||||
|
/target/
|
||||||
@@ -5,19 +5,14 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
|||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||||
between them.
|
between them.
|
||||||
|
|
||||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||||||
its rationale, and what was rejected. Read it before changing anything
|
date, its rationale, and what was rejected. Read it before changing anything
|
||||||
structural, and update it in place when a decision changes rather than
|
structural, and update it in place when a decision changes rather than
|
||||||
letting this file and the plan become two versions of the truth. This file is
|
letting this file and the plan become two versions of the truth. This file is
|
||||||
the working notes layer: layout, commands, and things that have bitten.
|
the working notes layer: layout, commands, rigs, and things that have bitten.
|
||||||
|
The design and working documents live under `docs/` — everything except this
|
||||||
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
file and `CLAUDE.md`, which stay at the root because that is where Claude
|
||||||
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
Code and other agent harnesses look for them.
|
||||||
ssh here, how importing behaves, and the measurements not worth re-taking.
|
|
||||||
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
|
||||||
you are actually running one, and this file is sent with every request. Read
|
|
||||||
it before writing or running a benchmark, driving the UI from a script, or
|
|
||||||
touching the import screen.
|
|
||||||
|
|
||||||
The central design point, worth not undoing by accident: **a session is a
|
The central design point, worth not undoing by accident: **a session is a
|
||||||
child process, translated into one common event model.** A new session type
|
child process, translated into one common event model.** A new session type
|
||||||
@@ -30,23 +25,10 @@ Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
|||||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||||||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
||||||
Module-by-module intent is in PLAN.md's "Backend layout".
|
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
**A llama.cpp session runs on whatever machine its setup names** (built
|
|
||||||
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
|
||||||
port the server binds *there* and the port that reaches it *here*, and
|
|
||||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
|
||||||
the command. Three things fell out of it and are easy to get wrong again —
|
|
||||||
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
|
||||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
|
||||||
the same kill left it loaded on the far machine; the model is looked for on
|
|
||||||
the machine that will serve it, so the spawn screen offers
|
|
||||||
`GET /setups/{id}/models` rather than the backend's own downloads; and the
|
|
||||||
readiness poll watches the process as well as the port, since a model that
|
|
||||||
will not load exits in a second and was being reported as "gave up after
|
|
||||||
300s". See PLAN.md's "Transport" and "llama-server management".
|
|
||||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||||
@@ -61,16 +43,23 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
projects version-locked to the commit this repo pins. What deliberately did
|
projects version-locked to the commit this repo pins. What deliberately did
|
||||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||||
sessions and setups are what makes this project itself.
|
sessions and setups are what makes this project itself.
|
||||||
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
- `docs/` — every design and working document except this file and
|
||||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
`CLAUDE.md`:
|
||||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||||||
choices made there that are still awaiting review.
|
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
the opening and stream effects in `SessionScreen.kt`.
|
||||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
- `docs/TODO.md` — the working list.
|
||||||
and stream effects in `SessionScreen.kt`.
|
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||||||
- `TODO.md` — the working list.
|
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||||
|
framework decision, and the ordered experiments with their pass
|
||||||
|
conditions. Read it before touching anything under that branch.
|
||||||
|
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
||||||
|
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
||||||
|
own public API log, working list, decisions log, 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
|
||||||
@@ -107,7 +96,11 @@ two icon buttons the same width without either being given one — and why
|
|||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||||||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
||||||
pure logic with no Android in it.
|
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
|
||||||
|
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
|
||||||
|
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
|
||||||
|
type compiles separately and lint has caught real bugs debug alone never
|
||||||
|
would (see "Android Lint" below).
|
||||||
- **Android Lint is not optional and is not run by a build.** It found a
|
- **Android Lint is not optional and is not run by a build.** It found a
|
||||||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||||||
desugaring off) and later a permission check that silently dropped every
|
desugaring off) and later a permission check that silently dropped every
|
||||||
@@ -163,6 +156,174 @@ two icon buttons the same width without either being given one — and why
|
|||||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||||
involved. That is how to verify the wg0-only posture.
|
involved. That is how to verify the wg0-only posture.
|
||||||
|
|
||||||
|
## The rigs
|
||||||
|
|
||||||
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||||||
|
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris
|
||||||
|
asked for before porting continues: a deterministic, checked-in synthetic
|
||||||
|
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
||||||
|
this app and iris open with no server, so a frame-time comparison
|
||||||
|
measures the renderer rather than the data. `./build-apk.sh bench` builds
|
||||||
|
it — own application id (`com.example.aiapp.bench`) and label ("AI
|
||||||
|
Sessions bench") so it installs beside a real enrollment rather than
|
||||||
|
replacing it. Opening it goes straight to a session screen holding the
|
||||||
|
fixture (no enrollment, no permission prompts) with a "Run benchmark"
|
||||||
|
control beside "Copy" in session settings: it drives the same scroll loop
|
||||||
|
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
|
||||||
|
`ui-trace`, but in-process, since a real phone has no usable system
|
||||||
|
tracing and no agent can drive one (this-machine-android's skill).
|
||||||
|
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
|
||||||
|
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
|
||||||
|
requests from an in-memory copy of the fixture instead of opening a
|
||||||
|
socket — so the fold, the paging and `uniqueItems` under test are the
|
||||||
|
screen's real ones, never a shortcut built just for this. The report
|
||||||
|
gains a `bench:` section (process CPU time, peak RSS, battery current) on
|
||||||
|
every build, empty except when `BenchRun.kt` filled it in.
|
||||||
|
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||||
|
and data directory, holding eight invented Claude Code transcripts and a
|
||||||
|
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||||
|
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||||||
|
real agent transcripts, so exercising *delete* against the ordinary server
|
||||||
|
deletes somebody's conversation and exercising *import* starts a real
|
||||||
|
`--resume` on the owner's account.
|
||||||
|
Its port and root derive from the checkout's name, so two checkouts'
|
||||||
|
sandboxes cannot reach each other, and its token is generated once into
|
||||||
|
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||||||
|
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||||||
|
start banner prints the command) and stays enrolled. It shares the real TLS
|
||||||
|
certificates, because the installed APK pins that CA.
|
||||||
|
Driving verbs, so none of this is re-derived per session:
|
||||||
|
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||||
|
`./ui-sandbox.sh send SID text|@file`, and
|
||||||
|
`./ui-sandbox.sh api /path [curl args]`.
|
||||||
|
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||||||
|
enrolment already there — for when the fixture under test was expensive to
|
||||||
|
build; plain `start` wipes them, which is right for the list-screen
|
||||||
|
fixtures and wrong for that.
|
||||||
|
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||||||
|
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||||||
|
fake CLI slow to start. Both exist because operations that finish in
|
||||||
|
milliseconds have states on the way that nothing can observe, and an
|
||||||
|
unobservable state is one where broken and working look identical.
|
||||||
|
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||||||
|
explorer, holding the states otherwise only reachable by finding a real
|
||||||
|
machine in one: an empty directory, a name with a tab and one with an
|
||||||
|
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||||||
|
symlink to a directory and a broken one, a source file per language, and
|
||||||
|
the three sizes the limits were measured against (`edit-32k.rs`,
|
||||||
|
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||||||
|
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||||
|
The explorer's 409 is produced by editing the file on the machine
|
||||||
|
(`printf … > file`) between pressing the pencil and pressing save.
|
||||||
|
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||||||
|
echo driver is the right rig for most things and the wrong one for anything
|
||||||
|
whose cost scales with what was actually written: a real reply is longer,
|
||||||
|
is real markdown, and carries tool calls whose input and output are
|
||||||
|
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||||||
|
page of history landing mid-fling threw the reader back to the newest end,
|
||||||
|
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||||||
|
`-b` takes the biggest conversation on the machine rather than the newest,
|
||||||
|
which is what a scrolling test wants; `--stop` takes it down.
|
||||||
|
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||||||
|
own, so the import can only see the copy — importing spawns `claude
|
||||||
|
--resume`, and against the real file that is a second CLI writing to a
|
||||||
|
conversation somebody may still be in. **A transcript never goes in this
|
||||||
|
repository**: they hold whatever was said, read and written in that
|
||||||
|
session, and `~/repos` is shared with the host besides.
|
||||||
|
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||||
|
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||||
|
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||||
|
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||||
|
signal. So adopt, stop, restart and start are all drivable without a real
|
||||||
|
`--resume` and without spending a turn on somebody's account. Reach for
|
||||||
|
this when what is under test is *whether a process is running*, and for
|
||||||
|
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||||
|
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||||
|
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||||
|
gesture loop, and prints the app's render report — the same one the in-app
|
||||||
|
copy button produces, whose `on screen:` line names what the viewport was
|
||||||
|
holding. Compare two runs with the same gestures; the emulator's absolute
|
||||||
|
frame times transfer nothing, the report's accounting does. Run it either
|
||||||
|
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||||||
|
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||||||
|
that move first are the worst `record: one block`, the reparse mean while
|
||||||
|
streaming, and the draw phase's accounting line.
|
||||||
|
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||||||
|
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||||||
|
resets the report, sends FILE, waits for the transcript to stop growing,
|
||||||
|
and prints. Both of those are corrections to a first version that measured
|
||||||
|
nothing: a transcript parked further back never redraws while a reply
|
||||||
|
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||||||
|
for idle answers before the turn has started.
|
||||||
|
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||||||
|
framework, from `atrace` text output with no trace processor needed. It is
|
||||||
|
how the cost of a layout node per link was attributed to the framework
|
||||||
|
rather than guessed at.
|
||||||
|
|
||||||
|
### Driving the UI
|
||||||
|
|
||||||
|
**No script that drives this app's UI presses a coordinate.** Every control
|
||||||
|
is found by the name it already carries for assistive technology —
|
||||||
|
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
|
||||||
|
against the screen at the moment of the gesture and fails the whole run when
|
||||||
|
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
|
||||||
|
coordinate is a position measured once by hand, and anything that moves the
|
||||||
|
control makes the tap land on whatever now sits there — the bench then
|
||||||
|
reports a number that was never measured, which reads exactly like a result.
|
||||||
|
Both bench scripts pressed the render report at `tap 723 205` until that
|
||||||
|
button moved into the session settings dialog on 2026-09-03. The check that
|
||||||
|
none has crept back:
|
||||||
|
|
||||||
|
grep -n "tap [0-9]" app/*.sh
|
||||||
|
|
||||||
|
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||||
|
is a distance rather than a control.
|
||||||
|
|
||||||
|
**Two traps in the emulator bench loop**, each of which cost a run.
|
||||||
|
`adb shell pm clear` removes the enrolment and the notification permission
|
||||||
|
along with the saved anchors, so the next run measures a permission dialog —
|
||||||
|
re-enrol with the command `ui-sandbox.sh` prints, and
|
||||||
|
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
|
||||||
|
so the only way two builds start a scroll from the same place is a *fresh
|
||||||
|
session for each*.
|
||||||
|
|
||||||
|
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||||||
|
`emu up` creates and boots the AVD named after this checkout — whatever `emu
|
||||||
|
name` prints, never a name typed out here, since this file is the same in
|
||||||
|
every clone. `run-android.sh` is that plus a build and an install. The `adb`
|
||||||
|
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
|
||||||
|
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
|
||||||
|
script from `emulator-tools` runs `emu check` before `installDebug`,
|
||||||
|
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
|
||||||
|
to every attached device; when it refuses, say which device you mean at the
|
||||||
|
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
||||||
|
|
||||||
|
### Testing llama.cpp and ssh here
|
||||||
|
|
||||||
|
The prebuilt CPU llama.cpp lives outside the repo at
|
||||||
|
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
|
||||||
|
own directory on `LD_LIBRARY_PATH`, so start the server as
|
||||||
|
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
||||||
|
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
|
||||||
|
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||||
|
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||||
|
broken driver — `llama-cli` produces the same from the file directly, which
|
||||||
|
is how to tell the two apart in a hurry.
|
||||||
|
|
||||||
|
There is no second machine, so **ssh this VM to itself**: generate a
|
||||||
|
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
|
||||||
|
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
|
||||||
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
|
||||||
|
nothing real. Point a provider's `command` at something harmless like
|
||||||
|
`/bin/echo` rather than at `claude`: the transport is what is under test, the
|
||||||
|
process exiting immediately is the signal, and it costs no tokens. **Take the
|
||||||
|
key back out afterwards.** The remote login shell here is **fish**; the
|
||||||
|
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||||||
|
both, but that is luck rather than design, and a shell that is neither is the
|
||||||
|
thing to suspect first if a remote spawn ever mangles an argument.
|
||||||
|
|
||||||
## Where things run (host vs this VM)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
||||||
@@ -186,7 +347,7 @@ means here:
|
|||||||
## Sessions outlive the backend
|
## Sessions outlive the backend
|
||||||
|
|
||||||
Since 2026-08-29 a session's process is deliberately left running when
|
Since 2026-08-29 a session's process is deliberately left running when
|
||||||
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
|
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
|
||||||
day to day:
|
day to day:
|
||||||
|
|
||||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
- **Stopping the server no longer stops the sessions.** After `pkill
|
||||||
@@ -209,24 +370,42 @@ day to day:
|
|||||||
in `process.json`; removing either by hand while the session is live loses
|
in `process.json`; removing either by hand while the session is live loses
|
||||||
output or replays it.
|
output or replays it.
|
||||||
|
|
||||||
## Auto-resume
|
## Importing
|
||||||
|
|
||||||
**A session switched to it sends itself a message once the account's usage
|
The import list reports each session's **size as well as its line count**,
|
||||||
limit lifts** — off by default, per session, in the session settings dialog.
|
because the two disagree in the way that matters: these transcripts embed
|
||||||
PLAN.md's "Auto-resume" is the design; day to day:
|
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
||||||
|
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
|
||||||
|
count tells you what continuing a session will cost. Shown, not warned about;
|
||||||
|
importing a large session is a choice somebody is entitled to make.
|
||||||
|
|
||||||
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
**Never import a Claude Code session that is open in a terminal.** The app
|
||||||
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
|
||||||
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
than a warning.
|
||||||
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
|
||||||
the reset time the *meter* now gives.
|
**One Claude Code session id can name two files, and the listing offers it
|
||||||
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
once.** Resuming from a different working directory makes the CLI write a
|
||||||
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
second transcript with the same id under that directory's project folder — an
|
||||||
what the meter answers. They are deliberately separate: the two disagreeing
|
ordinary state of a machine, not corruption. Everything downstream addresses
|
||||||
is the case the design exists for. `/usage 20` is the limit lifting.
|
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||||||
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||||||
backend restart. A day after the limit was hit it gives up and says so in
|
the copy with the most lines, because the other is usually a few-hundred-byte
|
||||||
the transcript.
|
stub and is often the *newer* of the two, so recency is the wrong key.
|
||||||
|
Deleting removes every copy rather than the first, or the row came back after
|
||||||
|
a delete that reported success. The phone's half is `uniqueItems`, which
|
||||||
|
every list keyed on a server-chosen id goes through: a repeat there must
|
||||||
|
never be able to close the app, whatever produced it.
|
||||||
|
|
||||||
|
**Deleting a session offers to take the machine's own transcript with it** —
|
||||||
|
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||||
|
confirmation, and only where the driver keeps a record of its own
|
||||||
|
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||||||
|
because leaving that copy is what makes an ordinary delete recoverable — and
|
||||||
|
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||||||
|
since the sentence promising the conversation "should still be there to
|
||||||
|
import again" is exactly the one the switch makes false. The server deletes
|
||||||
|
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||||||
|
where it was instead of half-deleted.
|
||||||
|
|
||||||
## Shared appearance
|
## Shared appearance
|
||||||
|
|
||||||
@@ -239,17 +418,6 @@ PLAN.md's "Auto-resume" is the design; day to day:
|
|||||||
swallowed the drag along with the tap, so a list could not be scrolled
|
swallowed the drag along with the tap, so a list could not be scrolled
|
||||||
while anything in it was busy.
|
while anything in it was busy.
|
||||||
|
|
||||||
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
|
||||||
One machine offers echo, the Claude CLI and a local model at once and only
|
|
||||||
the CLI spends anything, so a session says which meter reports on it
|
|
||||||
(`usageProvider`, from `DriverKind::usage_provider`, which
|
|
||||||
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
|
||||||
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
|
||||||
or echo session, and the phone draws **nothing** for one — not a zero, and
|
|
||||||
not "unknown". Nothing while the first fetch is out either: "checking"
|
|
||||||
under a session that turns out to meter nothing is a row the screen then
|
|
||||||
has to withdraw.
|
|
||||||
|
|
||||||
## Things that have bitten
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
Project-specific only — a lesson that would bite any project on this machine
|
||||||
@@ -355,3 +523,37 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|||||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
||||||
that wraps the *fetch* and leaves the work done with the result outside it.
|
that wraps the *fetch* and leaves the work done with the result outside it.
|
||||||
|
|
||||||
|
## Measurements worth not re-taking
|
||||||
|
|
||||||
|
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||||
|
emulator against a real imported transcript with the server at
|
||||||
|
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||||
|
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||||
|
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||||
|
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||||
|
is *not* at the floor is the first few seconds after opening a session,
|
||||||
|
where every row on the way is being composed for the first time; that is
|
||||||
|
inherent to a lazy list and it is why a measurement taken before the screen
|
||||||
|
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||||
|
- **The reset path is not reachable by reopening a session.** Measured
|
||||||
|
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||||
|
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||||
|
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||||
|
opening page, then one span covering the whole distance. To exercise the
|
||||||
|
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||||
|
the app takes the reset on a live connection, clears, refills and carries
|
||||||
|
on without reconnecting.
|
||||||
|
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||||
|
the launcher while 415 events were produced brought no reconnect at all,
|
||||||
|
which is not what the comment above that loop expects, and is most likely
|
||||||
|
this emulator being headless rather than the phone's behaviour.
|
||||||
|
- **Reopening a cached session costs one request for one event** (the probe),
|
||||||
|
and scrolling the whole conversation back costs nothing more; a cold open
|
||||||
|
of the same 500-event session is two pages, 100 events. Measured
|
||||||
|
2026-09-04 on the emulator against the sandbox.
|
||||||
|
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||||
|
28,000-line file because it draws one row per line; the editor is one
|
||||||
|
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||||
|
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||||
|
screen. If you make the editor faster, that number is what to move.
|
||||||
|
docs/EXPLORER.md's "What the measurements said" has the rest.
|
||||||
Generated
+1081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "android-shell"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
||||||
|
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
||||||
|
# why those two classes cannot be anything but Java/Kotlin, registered from
|
||||||
|
# the manifest by name). Everything they would otherwise have done in
|
||||||
|
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
||||||
|
# picking a session for a share -- is here instead, built on `client-core`
|
||||||
|
# so the networking and parsing are not duplicated a third time next to the
|
||||||
|
# server and the Kotlin app.
|
||||||
|
#
|
||||||
|
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
||||||
|
# on a normal host target without an Android NDK toolchain, the same
|
||||||
|
# posture `client-core` and `server` already have.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "android_shell"
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
client-core = { path = "../client-core" }
|
||||||
|
jni = "0.22"
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
|
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
||||||
|
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
||||||
|
# without a backend installed -- so without this, every recoverable error
|
||||||
|
# at a native entry point would be silently dropped rather than reaching
|
||||||
|
# logcat. Android-only: nothing else here needs it, and it does not build
|
||||||
|
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
||||||
|
# is used).
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android_logger = "0.15"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
||||||
|
//! (a class name, a method name and a signature, all as plain `&str`).
|
||||||
|
//!
|
||||||
|
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
||||||
|
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
||||||
|
//! here uniformly rather than switching to the compile-time `jni_str!`
|
||||||
|
//! literal macro call by call -- these are a handful of short, one-off
|
||||||
|
//! lookups, not a hot loop, so the difference is not worth two code paths
|
||||||
|
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
||||||
|
//! `FieldSignature`, which is why those go through
|
||||||
|
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
||||||
|
//! parsed form is what lets these calls skip re-validating the signature
|
||||||
|
//! against the arguments on every call, which is the whole reason `jni`
|
||||||
|
//! moved to it.
|
||||||
|
//!
|
||||||
|
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
||||||
|
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
||||||
|
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
||||||
|
//! `call_method` on an object it already has, which needs no such lookup)
|
||||||
|
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
||||||
|
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
||||||
|
//! That default is fine on a thread the JVM itself started -- an
|
||||||
|
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
||||||
|
//! from `android-shell`'s own background thread (the notification
|
||||||
|
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
||||||
|
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
||||||
|
//! never gave an app classloader. Framework classes
|
||||||
|
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
||||||
|
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
||||||
|
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
||||||
|
//! packaged inside this app's own APK. The failure was
|
||||||
|
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
||||||
|
//! as "failed to resolve Java class ... (class not found or linkage
|
||||||
|
//! error)" -- on a real device this reads as "the notification silently
|
||||||
|
//! never arrives," since the whole call is inside the follow loop and the
|
||||||
|
//! ongoing foreground notification (built on the main thread, in
|
||||||
|
//! `try_start`, before the background thread exists) posts fine either
|
||||||
|
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
||||||
|
//! first time any entry point has a `Context` to ask, and every class
|
||||||
|
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
||||||
|
//! rather than the thread-dependent default -- so it is correct on the
|
||||||
|
//! main thread and on this crate's own background threads alike.
|
||||||
|
|
||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
||||||
|
use jni::refs::{Global, LoaderContext};
|
||||||
|
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
||||||
|
use jni::strings::JNIString;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
||||||
|
/// Cheap to call from every entry point that has a `Context` on hand
|
||||||
|
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
||||||
|
/// a `OnceLock::get` and nothing else.
|
||||||
|
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||||
|
if CLASS_LOADER.get().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
||||||
|
// real objects throughout, so this needs no class-name lookup of its
|
||||||
|
// own and has nothing to bootstrap.
|
||||||
|
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
||||||
|
let loader_obj = call_method(
|
||||||
|
env,
|
||||||
|
&class_obj,
|
||||||
|
"getClassLoader",
|
||||||
|
"()Ljava/lang/ClassLoader;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
||||||
|
let global = env.new_global_ref(&loader)?;
|
||||||
|
// Lost the race with another entry point calling this concurrently --
|
||||||
|
// both loaders name the same app, so either one is fine and there is
|
||||||
|
// nothing to reconcile.
|
||||||
|
let _ = CLASS_LOADER.set(global);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
||||||
|
/// through the cached app classloader when one has been remembered, and
|
||||||
|
/// through the ordinary default otherwise -- which is every call made
|
||||||
|
/// before any entry point has run, and is also correct for a main-thread
|
||||||
|
/// caller, so there is no case this makes worse.
|
||||||
|
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||||
|
match CLASS_LOADER.get() {
|
||||||
|
Some(loader) => {
|
||||||
|
let binary_name = name.replace('/', ".");
|
||||||
|
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
||||||
|
}
|
||||||
|
None => env.find_class(JNIString::new(name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||||
|
resolve_class(env, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new Java string as a plain `JObject` -- what every call site here
|
||||||
|
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
||||||
|
/// `JString: Into<JObject>` is the documented way across).
|
||||||
|
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||||
|
Ok(env.new_string(text)?.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_object<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JObject<'local>> {
|
||||||
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.new_object(class, sig.method_signature(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call_method<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
obj: &JObject,
|
||||||
|
method: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||||
|
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call_static_method<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
method: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_static_field<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
field: &str,
|
||||||
|
sig: &str,
|
||||||
|
) -> Result<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
||||||
|
//! package comment for what this crate is and RUST.md's E3 entry for the
|
||||||
|
//! design decisions.
|
||||||
|
//!
|
||||||
|
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
||||||
|
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
||||||
|
//! the macro derives the mangled export name and the JNI signature from the
|
||||||
|
//! Rust function itself, so the two cannot drift apart the way a
|
||||||
|
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
||||||
|
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
||||||
|
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
||||||
|
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
||||||
|
//! as an exception that would crash the app over something recoverable.
|
||||||
|
//!
|
||||||
|
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
||||||
|
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
||||||
|
//! side-effecting const (here, generating the `#[export_name]`d function
|
||||||
|
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
||||||
|
//! warning for a binding nothing reads.
|
||||||
|
|
||||||
|
mod jcall;
|
||||||
|
mod notify;
|
||||||
|
mod settings;
|
||||||
|
mod share;
|
||||||
|
|
||||||
|
use jni::errors::LogErrorAndDefault;
|
||||||
|
use jni::objects::{JClass, JObject};
|
||||||
|
use jni::sys::jint;
|
||||||
|
use jni::{Env, NativeMethod, native_method};
|
||||||
|
|
||||||
|
/// Installs the `log` backend that routes to logcat, once per process.
|
||||||
|
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
||||||
|
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
||||||
|
/// call into the `log` facade's default no-op logger, and a real failure
|
||||||
|
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
||||||
|
/// crashing, since nothing on screen or in the log says a notification was
|
||||||
|
/// dropped. Called from every entry point below rather than a Java-side
|
||||||
|
/// `Application.onCreate`, since this crate deliberately has no such class
|
||||||
|
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
||||||
|
fn ensure_logger() {
|
||||||
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
|
ONCE.call_once(|| {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
android_logger::init_once(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(log::LevelFilter::Debug)
|
||||||
|
.with_tag("android-shell"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parameters are spelled as their Java types, not as `JObject`: the
|
||||||
|
// macro encodes each argument into the exported symbol's JNI signature
|
||||||
|
// (and JNI resolves `Java_...` names *by* that signature), so a generic
|
||||||
|
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
|
||||||
|
// method actually declared `(Landroid/app/Activity;...)` -- two different
|
||||||
|
// symbols that never resolve to each other, silently, with no compiler
|
||||||
|
// error on either side. `android.app.Activity` etc. have no dedicated
|
||||||
|
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
|
||||||
|
// implementation functions below (the "Built-in Types" note in
|
||||||
|
// `native_method!`'s docs).
|
||||||
|
const _: NativeMethod = native_method! {
|
||||||
|
java_type = "com.example.aiapp.shell.MainActivity",
|
||||||
|
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
|
||||||
|
error_policy = LogErrorAndDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
||||||
|
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
||||||
|
fn native_handle_intent<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
activity: JObject<'local>,
|
||||||
|
intent: JObject<'local>,
|
||||||
|
) -> Result<(), jni::errors::Error> {
|
||||||
|
ensure_logger();
|
||||||
|
jcall::remember_class_loader(env, &activity)?;
|
||||||
|
share::handle_intent(env, &activity, &intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const _: NativeMethod = native_method! {
|
||||||
|
java_type = "com.example.aiapp.shell.NotificationService",
|
||||||
|
static extern fn native_sync(context: android.content.Context) -> (),
|
||||||
|
error_policy = LogErrorAndDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
||||||
|
/// enrollment may have just landed) and from `NotificationService.sync`
|
||||||
|
/// itself. See `notify::sync`.
|
||||||
|
fn native_sync<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
context: JObject<'local>,
|
||||||
|
) -> Result<(), jni::errors::Error> {
|
||||||
|
ensure_logger();
|
||||||
|
jcall::remember_class_loader(env, &context)?;
|
||||||
|
notify::sync(env, &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
const _: NativeMethod = native_method! {
|
||||||
|
java_type = "com.example.aiapp.shell.NotificationService",
|
||||||
|
static extern fn native_on_start_command(service: android.app.Service) -> jint,
|
||||||
|
error_policy = LogErrorAndDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||||
|
fn native_on_start_command<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
service: JObject<'local>,
|
||||||
|
) -> Result<jint, jni::errors::Error> {
|
||||||
|
ensure_logger();
|
||||||
|
jcall::remember_class_loader(env, &service)?;
|
||||||
|
Ok(notify::on_start_command(env, service))
|
||||||
|
}
|
||||||
|
|
||||||
|
const _: NativeMethod = native_method! {
|
||||||
|
java_type = "com.example.aiapp.shell.NotificationService",
|
||||||
|
static extern fn native_on_destroy() -> (),
|
||||||
|
error_policy = LogErrorAndDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||||
|
fn native_on_destroy<'local>(
|
||||||
|
_env: &mut Env<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
) -> Result<(), jni::errors::Error> {
|
||||||
|
ensure_logger();
|
||||||
|
notify::on_destroy();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
//! Where a notification is said, and the foreground service that keeps
|
||||||
|
//! the connection open while the app is closed. Ported from
|
||||||
|
//! `Notifications.kt`'s `NotificationService`, minus the "session on
|
||||||
|
//! screen" / "hand to the app as a banner" branches: those read
|
||||||
|
//! process-wide state that only exists because a screen is drawn to
|
||||||
|
//! register against, and this experiment draws no screen yet (that is
|
||||||
|
//! E4's job, on iris). So every notification here takes the third branch
|
||||||
|
//! Kotlin's `show` already had -- the platform's own drawer -- which is
|
||||||
|
//! also exactly the case E3's pass condition asks for: **a notification
|
||||||
|
//! arrives with the app closed.**
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use client_core::api::UreqTransport;
|
||||||
|
use client_core::notifications::{SessionNotification, follow_notifications};
|
||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JObject, JValue};
|
||||||
|
use jni::sys::{JNI_TRUE, jint};
|
||||||
|
|
||||||
|
use crate::settings::{self, ServerSettings};
|
||||||
|
|
||||||
|
const ALERT_CHANNEL: &str = "sessions";
|
||||||
|
const ONGOING_CHANNEL: &str = "connection";
|
||||||
|
const ONGOING_ID: i32 = 1;
|
||||||
|
const ALERT_ID: i32 = 2;
|
||||||
|
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
||||||
|
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||||
|
|
||||||
|
/// Whether the follow-loop thread is already running. **A deviation from
|
||||||
|
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
|
||||||
|
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
|
||||||
|
/// on *every* call, with nothing to notice a previous one is still going --
|
||||||
|
/// and `sync()` calling `startForegroundService` when the service is
|
||||||
|
/// already running is an ordinary Android start, not a restart, so
|
||||||
|
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
|
||||||
|
/// `sync` once itself, then again inside `handle_enrollment` after saving
|
||||||
|
/// the token) hits exactly this path and was observed opening **two**
|
||||||
|
/// concurrent connections to `/notifications` from one process -- caught
|
||||||
|
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
|
||||||
|
/// thread ai-app-notifications` lines for one enrollment. Guarded here
|
||||||
|
/// rather than left to match Kotlin's behaviour exactly, since duplicating
|
||||||
|
/// a live connection is a resource leak with no upside; worth carrying the
|
||||||
|
/// same guard back to `Notifications.kt` separately.
|
||||||
|
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
||||||
|
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
||||||
|
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
||||||
|
/// can interrupt a `ureq` read already blocked inside one connection --
|
||||||
|
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
||||||
|
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
||||||
|
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
||||||
|
/// practice this is a bounded wait rather than a hang; closing that gap
|
||||||
|
/// for real means adding a cancellation point to `client_core::Transport`,
|
||||||
|
/// which is a decision affecting every caller of that trait, not just this
|
||||||
|
/// one -- left for whoever next depends on prompt shutdown.
|
||||||
|
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||||
|
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/NotificationManagerCompat",
|
||||||
|
"from",
|
||||||
|
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
|
||||||
|
&[JValue::Object(context)],
|
||||||
|
)?
|
||||||
|
.l()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_channel(
|
||||||
|
env: &mut Env,
|
||||||
|
manager: &JObject,
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
importance: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let id_j = crate::jcall::jstr_obj(env, id)?;
|
||||||
|
let builder = crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||||
|
"(Ljava/lang/String;I)V",
|
||||||
|
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||||
|
)?;
|
||||||
|
let name_j = crate::jcall::jstr_obj(env, name)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setName",
|
||||||
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||||
|
&[JValue::Object(&name_j)],
|
||||||
|
)?;
|
||||||
|
let channel = crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"build",
|
||||||
|
"()Landroidx/core/app/NotificationChannelCompat;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
manager,
|
||||||
|
"createNotificationChannel",
|
||||||
|
"(Landroidx/core/app/NotificationChannelCompat;)V",
|
||||||
|
&[JValue::Object(&channel)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two channels, because they are two different things to be told -- see
|
||||||
|
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
|
||||||
|
/// importances here are copied from it exactly, since a phone that has
|
||||||
|
/// seen both apps should not learn two different vocabularies for the
|
||||||
|
/// same fact.
|
||||||
|
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
|
||||||
|
let manager = notification_manager(env, context)?;
|
||||||
|
let default = static_int(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/NotificationManagerCompat",
|
||||||
|
"IMPORTANCE_DEFAULT",
|
||||||
|
)?;
|
||||||
|
let min = static_int(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/NotificationManagerCompat",
|
||||||
|
"IMPORTANCE_MIN",
|
||||||
|
)?;
|
||||||
|
create_channel(
|
||||||
|
env,
|
||||||
|
&manager,
|
||||||
|
ALERT_CHANNEL,
|
||||||
|
"Sessions needing attention",
|
||||||
|
default,
|
||||||
|
)?;
|
||||||
|
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_intent_for<'l>(
|
||||||
|
env: &mut Env<'l>,
|
||||||
|
context: &JObject,
|
||||||
|
class_name: &str,
|
||||||
|
) -> Result<JObject<'l>> {
|
||||||
|
let target_class = crate::jcall::find_class(env, class_name)?;
|
||||||
|
crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
"android/content/Intent",
|
||||||
|
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&target_class)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
|
||||||
|
/// `sessionIntent`, including building the URI through `Uri.Builder`
|
||||||
|
/// rather than string concatenation, for the same reason: an id needing
|
||||||
|
/// escaping must survive the round trip.
|
||||||
|
fn session_intent<'l>(
|
||||||
|
env: &mut Env<'l>,
|
||||||
|
context: &JObject,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<JObject<'l>> {
|
||||||
|
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||||
|
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&intent,
|
||||||
|
"setAction",
|
||||||
|
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||||
|
&[JValue::Object(&action_view)],
|
||||||
|
)?;
|
||||||
|
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||||
|
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"scheme",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
|
&[JValue::Object(&scheme)],
|
||||||
|
)?;
|
||||||
|
let authority = crate::jcall::jstr_obj(env, "session")?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"authority",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
|
&[JValue::Object(&authority)],
|
||||||
|
)?;
|
||||||
|
let path = crate::jcall::jstr_obj(env, session_id)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"appendPath",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
|
&[JValue::Object(&path)],
|
||||||
|
)?;
|
||||||
|
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&intent,
|
||||||
|
"setData",
|
||||||
|
"(Landroid/net/Uri;)Landroid/content/Intent;",
|
||||||
|
&[JValue::Object(&uri)],
|
||||||
|
)?;
|
||||||
|
Ok(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_activity<'l>(
|
||||||
|
env: &mut Env<'l>,
|
||||||
|
context: &JObject,
|
||||||
|
intent: &JObject,
|
||||||
|
) -> Result<JObject<'l>> {
|
||||||
|
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||||
|
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"android/app/PendingIntent",
|
||||||
|
"getActivity",
|
||||||
|
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
|
||||||
|
&[
|
||||||
|
JValue::Object(context),
|
||||||
|
JValue::Int(0),
|
||||||
|
JValue::Object(intent),
|
||||||
|
JValue::Int(update_current | immutable),
|
||||||
|
],
|
||||||
|
)?
|
||||||
|
.l()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builder_call<'l>(
|
||||||
|
env: &mut Env<'l>,
|
||||||
|
builder: &JObject<'l>,
|
||||||
|
method: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<()> {
|
||||||
|
crate::jcall::call_method(env, builder, method, sig, args)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type Android 14+ requires a foreground service to declare, and
|
||||||
|
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
|
||||||
|
fn foreground_type(env: &mut Env) -> Result<i32> {
|
||||||
|
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||||
|
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
|
||||||
|
if sdk >= upside_down_cake {
|
||||||
|
static_int(
|
||||||
|
env,
|
||||||
|
"android/content/pm/ServiceInfo",
|
||||||
|
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
|
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||||
|
let builder = crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
|
)?;
|
||||||
|
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setContentTitle",
|
||||||
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Object(&title)],
|
||||||
|
)?;
|
||||||
|
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setSmallIcon",
|
||||||
|
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Int(icon)],
|
||||||
|
)?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setOngoing",
|
||||||
|
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Bool(JNI_TRUE)],
|
||||||
|
)?;
|
||||||
|
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setPriority",
|
||||||
|
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Int(priority_min)],
|
||||||
|
)?;
|
||||||
|
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the service if there is a server to connect to, and stops it
|
||||||
|
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
|
||||||
|
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||||
|
let service_intent =
|
||||||
|
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||||
|
if settings::load(env, context)?.is_none() {
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
context,
|
||||||
|
"stopService",
|
||||||
|
"(Landroid/content/Intent;)Z",
|
||||||
|
&[JValue::Object(&service_intent)],
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
create_channels(env, context)?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"androidx/core/content/ContextCompat",
|
||||||
|
"startForegroundService",
|
||||||
|
"(Landroid/content/Context;Landroid/content/Intent;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&service_intent)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Service.onStartCommand` body -- loads settings, starts the
|
||||||
|
/// foreground notification, and spawns the follow-loop thread. Answers the
|
||||||
|
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
||||||
|
/// framework rather than hardcoded so a wrong guess at their values cannot
|
||||||
|
/// silently pick the other behaviour.
|
||||||
|
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||||
|
match try_start(env, &service) {
|
||||||
|
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||||
|
Ok(false) => {
|
||||||
|
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||||
|
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error(env, "onStartCommand", &e);
|
||||||
|
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||||
|
let Some(settings) = settings::load(env, service)? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let ca = settings::load_pinned_ca(env)?;
|
||||||
|
let notification = ongoing_notification(env, service)?;
|
||||||
|
let fg_type = foreground_type(env)?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/ServiceCompat",
|
||||||
|
"startForeground",
|
||||||
|
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
||||||
|
&[
|
||||||
|
JValue::Object(service),
|
||||||
|
JValue::Int(ONGOING_ID),
|
||||||
|
JValue::Object(¬ification),
|
||||||
|
JValue::Int(fg_type),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
||||||
|
// the first is still going -- the ordinary case for this service,
|
||||||
|
// since `sync()` is called from more than one place -- must not open
|
||||||
|
// a second connection.
|
||||||
|
if RUNNING.swap(true, Ordering::SeqCst) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vm = env.get_java_vm()?;
|
||||||
|
let context = env.new_global_ref(service)?;
|
||||||
|
STOPPING.store(false, Ordering::SeqCst);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("ai-app-notifications".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
// Requests a *permanent* attachment (detached only when this thread
|
||||||
|
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
||||||
|
// this is the long-lived follow loop, not a one-shot callback.
|
||||||
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
|
follow_loop(env, &context, settings, &ca);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows the backend's notification stream, reconnecting until stopped
|
||||||
|
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
||||||
|
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
||||||
|
/// it cannot connect, for the same reason as the Kotlin original: a
|
||||||
|
/// notification saying "I could not tell you whether anything happened" is
|
||||||
|
/// noise about a condition nobody can act on.
|
||||||
|
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||||
|
while !STOPPING.load(Ordering::SeqCst) {
|
||||||
|
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||||
|
let _ = follow_notifications(&transport, |notification| {
|
||||||
|
if let Err(e) = show(env, context, ¬ification) {
|
||||||
|
log_error(env, "show", &e);
|
||||||
|
}
|
||||||
|
!STOPPING.load(Ordering::SeqCst)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if STOPPING.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One notification per session, replacing that session's previous one --
|
||||||
|
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
||||||
|
/// branches this module's doc comment explains.
|
||||||
|
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||||
|
let manager = notification_manager(env, context)?;
|
||||||
|
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||||
|
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
|
||||||
|
let allowed = if sdk < tiramisu {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||||
|
let granted = static_int(
|
||||||
|
env,
|
||||||
|
"android/content/pm/PackageManager",
|
||||||
|
"PERMISSION_GRANTED",
|
||||||
|
)?;
|
||||||
|
let result = crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"androidx/core/content/ContextCompat",
|
||||||
|
"checkSelfPermission",
|
||||||
|
"(Landroid/content/Context;Ljava/lang/String;)I",
|
||||||
|
&[JValue::Object(context), JValue::Object(&permission)],
|
||||||
|
)?
|
||||||
|
.i()?;
|
||||||
|
result == granted
|
||||||
|
};
|
||||||
|
let enabled =
|
||||||
|
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
||||||
|
if !allowed || !enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||||
|
let pending = pending_activity(env, context, &intent)?;
|
||||||
|
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||||
|
let builder = crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
|
)?;
|
||||||
|
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setContentTitle",
|
||||||
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Object(&title)],
|
||||||
|
)?;
|
||||||
|
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setContentText",
|
||||||
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Object(&text)],
|
||||||
|
)?;
|
||||||
|
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setSmallIcon",
|
||||||
|
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Int(icon)],
|
||||||
|
)?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setContentIntent",
|
||||||
|
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Object(&pending)],
|
||||||
|
)?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setAutoCancel",
|
||||||
|
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Bool(JNI_TRUE)],
|
||||||
|
)?;
|
||||||
|
let when = (notification.at * 1000.0) as i64;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setWhen",
|
||||||
|
"(J)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Long(when)],
|
||||||
|
)?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setShowWhen",
|
||||||
|
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Bool(JNI_TRUE)],
|
||||||
|
)?;
|
||||||
|
let built =
|
||||||
|
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&manager,
|
||||||
|
"notify",
|
||||||
|
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
||||||
|
&[
|
||||||
|
JValue::Object(&tag),
|
||||||
|
JValue::Int(ALERT_ID),
|
||||||
|
JValue::Object(&built),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
||||||
|
/// the gap this module's `STOPPING` doc explains.
|
||||||
|
pub fn on_destroy() {
|
||||||
|
STOPPING.store(true, Ordering::SeqCst);
|
||||||
|
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||||
|
// comment): the old thread may still be inside a blocked read when a
|
||||||
|
// new `onStartCommand` follows immediately, which would spawn a
|
||||||
|
// second one before the first has actually stopped. Narrower than not
|
||||||
|
// resetting at all -- a service destroyed and never restarted would
|
||||||
|
// otherwise wedge `RUNNING` true forever -- and no worse than the
|
||||||
|
// known gap already accepted above.
|
||||||
|
RUNNING.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||||
|
let message = format!("android-shell: {where_}: {error}");
|
||||||
|
let _ = (|| -> Result<()> {
|
||||||
|
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
||||||
|
let msg = crate::jcall::jstr_obj(env, &message)?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"android/util/Log",
|
||||||
|
"e",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)I",
|
||||||
|
&[JValue::Object(&tag), JValue::Object(&msg)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Enrollment: where the backend is, and the Keystore-sealed token to
|
||||||
|
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
|
||||||
|
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
|
||||||
|
//! class the production app already uses (see `ServerConfig.kt`), through
|
||||||
|
//! JNI, for two reasons: that code is shared with Dev Updater and already
|
||||||
|
//! tested, and the sealed value on a real phone is keyed to the exact
|
||||||
|
//! Keystore alias that class already uses -- reimplementing the crypto
|
||||||
|
//! here would either duplicate it or invalidate an existing enrollment.
|
||||||
|
|
||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
|
||||||
|
/// Where the backend is and how to authenticate to it -- the Rust twin of
|
||||||
|
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
|
||||||
|
/// rather than kept as a live JNI reference, so it can cross a thread
|
||||||
|
/// boundary (a `JObject` is tied to one `Env`/thread).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServerSettings {
|
||||||
|
pub host: String,
|
||||||
|
pub port: i32,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerSettings {
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("https://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This experiment's own scheme and Keystore alias -- distinct from the
|
||||||
|
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
||||||
|
/// installed side by side on the same development device without
|
||||||
|
/// colliding over which one a scanned QR or a deep link resolves to. See
|
||||||
|
/// RUST.md's E3 entry for why they are not the same value.
|
||||||
|
pub(crate) const SCHEME: &str = "aiappshell";
|
||||||
|
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||||
|
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||||
|
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||||
|
|
||||||
|
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||||
|
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
||||||
|
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||||
|
crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
STORE_CLASS,
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(&scheme), JValue::Object(&alias)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||||
|
let host = get_string(env, settings_obj, "getHost")?;
|
||||||
|
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||||
|
let token = get_string(env, settings_obj, "getToken")?;
|
||||||
|
Ok(ServerSettings { host, port, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||||
|
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
|
jstr.try_to_string(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored enrollment, or `None` when there is not one -- mirrors
|
||||||
|
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||||
|
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
|
let store = new_store(env)?;
|
||||||
|
let settings_obj = crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&store,
|
||||||
|
"load",
|
||||||
|
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
|
||||||
|
&[JValue::Object(context)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
if settings_obj.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(read_settings(env, &settings_obj)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
||||||
|
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||||
|
let store = new_store(env)?;
|
||||||
|
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
||||||
|
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
||||||
|
let settings_obj = crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
SETTINGS_CLASS,
|
||||||
|
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||||
|
&[
|
||||||
|
JValue::Object(&host),
|
||||||
|
JValue::Int(settings.port),
|
||||||
|
JValue::Object(&token),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&store,
|
||||||
|
"save",
|
||||||
|
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
||||||
|
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
||||||
|
/// query-parameter rules rather than re-deriving them here.
|
||||||
|
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
|
let store = new_store(env)?;
|
||||||
|
let settings_obj = crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&store,
|
||||||
|
"parseEnrollmentUri",
|
||||||
|
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
|
||||||
|
&[JValue::Object(uri)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
if settings_obj.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(read_settings(env, &settings_obj)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CA this build pins, generated at build time the same way
|
||||||
|
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
|
||||||
|
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||||
|
/// own to generate into.
|
||||||
|
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||||
|
let value = crate::jcall::get_static_field(
|
||||||
|
env,
|
||||||
|
"com/example/aiapp/shell/PinnedCa",
|
||||||
|
"PINNED_CA_PEM",
|
||||||
|
"Ljava/lang/String;",
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
|
Ok(jstr.try_to_string(env)?.into_bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
||||||
|
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
||||||
|
//!
|
||||||
|
//! **Scope cut, recorded rather than silent**: only shared *text*
|
||||||
|
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
||||||
|
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
||||||
|
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
||||||
|
//! ported here, because `client-core`'s `ApiClient` does not have the
|
||||||
|
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
||||||
|
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
||||||
|
//! `content://` stream and no text falls through to a toast saying so,
|
||||||
|
//! rather than silently doing nothing. Closing this gap is the same
|
||||||
|
//! `client-core` work whichever caller needs it next.
|
||||||
|
//!
|
||||||
|
//! **Which session a share lands in** is also a placeholder: with no
|
||||||
|
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
||||||
|
//! to whichever session has the latest `last_activity` -- the one most
|
||||||
|
//! likely to be what somebody meant. Worth revisiting once a real screen
|
||||||
|
//! exists to ask instead of guessing.
|
||||||
|
|
||||||
|
use client_core::api::{ApiClient, UreqTransport};
|
||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
|
||||||
|
use crate::notify;
|
||||||
|
use crate::settings;
|
||||||
|
|
||||||
|
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||||
|
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||||
|
const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
||||||
|
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||||
|
|
||||||
|
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||||
|
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
|
Ok(Some(jstr.try_to_string(env)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||||
|
let message = crate::jcall::jstr_obj(env, message)?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"com/example/aiapp/shell/MainActivity",
|
||||||
|
"toast",
|
||||||
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&message)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one place an incoming intent is sorted into what it means -- mirrors
|
||||||
|
/// `MainActivity.kt`'s `handleIntent`.
|
||||||
|
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
|
let action = get_string_method(env, intent, "getAction")?;
|
||||||
|
if matches!(
|
||||||
|
action.as_deref(),
|
||||||
|
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
||||||
|
) {
|
||||||
|
return handle_share(env, activity, intent);
|
||||||
|
}
|
||||||
|
if action.as_deref() != Some(ACTION_VIEW) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
||||||
|
if uri.is_null() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let scheme = get_string_method(env, &uri, "getScheme")?;
|
||||||
|
if scheme.as_deref() != Some(settings::SCHEME) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
||||||
|
Some("session") => handle_session_open(env, activity, &uri),
|
||||||
|
Some("enroll") => handle_enrollment(env, activity, &uri),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||||
|
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// There is no session screen yet (E4's job); the toast is this
|
||||||
|
// experiment's stand-in proof that the tap was routed to the right
|
||||||
|
// session id.
|
||||||
|
toast(env, activity, &format!("Opened session {session_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||||
|
match settings::parse_enrollment_uri(env, uri)? {
|
||||||
|
Some(parsed) => {
|
||||||
|
settings::save(env, activity, &parsed)?;
|
||||||
|
notify::sync(env, activity)?;
|
||||||
|
toast(
|
||||||
|
env,
|
||||||
|
activity,
|
||||||
|
&format!("Enrolled with {}", parsed.base_url()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => toast(env, activity, "Not a valid enrollment code"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
||||||
|
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
||||||
|
/// what happens to it, minus attachments per this module's doc comment.
|
||||||
|
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
|
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||||
|
let text = crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
intent,
|
||||||
|
"getStringExtra",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
&[JValue::Object(&extra_text)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let text = if text.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let jstr: JString = env.cast_local::<JString>(text)?;
|
||||||
|
Some(jstr.try_to_string(env)?)
|
||||||
|
};
|
||||||
|
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
||||||
|
return toast(
|
||||||
|
env,
|
||||||
|
activity,
|
||||||
|
"Nothing to share -- only shared text is supported so far",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Network I/O must not run on the calling thread: `handle_intent` is
|
||||||
|
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
||||||
|
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
||||||
|
// the actual send happens on a JNI-attached background thread, the
|
||||||
|
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
||||||
|
// thread is safe because `MainActivity.toast` itself hops back to the
|
||||||
|
// main looper (see that method).
|
||||||
|
let vm = env.get_java_vm()?;
|
||||||
|
let activity_ref = env.new_global_ref(activity)?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
|
share_in_background(env, &activity_ref, text);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
||||||
|
let outcome = attach_to_a_session(env, activity, &text);
|
||||||
|
let message = match outcome {
|
||||||
|
Ok(title) => format!("Shared into \"{title}\""),
|
||||||
|
Err(message) => message,
|
||||||
|
};
|
||||||
|
let _ = toast(env, activity, &message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_to_a_session(
|
||||||
|
env: &mut Env,
|
||||||
|
activity: &JObject,
|
||||||
|
text: &str,
|
||||||
|
) -> std::result::Result<String, String> {
|
||||||
|
let settings = settings::load(env, activity)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
||||||
|
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
||||||
|
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
||||||
|
let target = sessions
|
||||||
|
.into_iter()
|
||||||
|
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
||||||
|
.ok_or_else(|| "No session to share into".to_string())?;
|
||||||
|
client
|
||||||
|
.send_message(&target.id, text, &[])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(target.title)
|
||||||
|
}
|
||||||
@@ -102,6 +102,16 @@ android {
|
|||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
|
||||||
|
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
|
||||||
|
// the `bench` build type below, which overrides it.
|
||||||
|
buildConfigField("boolean", "FIXTURE_MODE", "false")
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
|
||||||
|
buildConfig = true
|
||||||
|
// Only for the bench build type's resValue("string", "app_name", ...) below.
|
||||||
|
resValues = true
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
@@ -131,6 +141,35 @@ android {
|
|||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
|
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
|
||||||
|
// optimisations so a frame time measured here means what release means everywhere else in
|
||||||
|
// this project, its own application id so it installs beside a real enrollment rather than
|
||||||
|
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
|
||||||
|
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
|
||||||
|
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
|
||||||
|
// be one more secret to keep off this machine's shared mount for no benefit.
|
||||||
|
create("bench") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
|
||||||
|
// with dev-updater and has no reason to know this project invented one -- so this says
|
||||||
|
// which of its build types to link against instead.
|
||||||
|
matchingFallbacks += listOf("release")
|
||||||
|
applicationIdSuffix = ".bench"
|
||||||
|
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
|
||||||
|
// Settings): this resValue overrides res/values/strings.xml's app_name for this
|
||||||
|
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
|
||||||
|
// rather than a literal so a build type can override it without touching the
|
||||||
|
// manifest.
|
||||||
|
resValue("string", "app_name", "AI Sessions bench")
|
||||||
|
buildConfigField("boolean", "FIXTURE_MODE", "true")
|
||||||
|
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceSets {
|
||||||
|
// The fixture both bench builds (this one and iris's) open with; see
|
||||||
|
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
|
||||||
|
// into androidApp/src -- one file to keep in sync with the generator, not two.
|
||||||
|
getByName("bench").assets.directories.add("../bench-fixture/assets")
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
the fix is a judgement about how this app should look. Drop this
|
the fix is a judgement about how this app should look. Drop this
|
||||||
suppression when a real icon lands. -->
|
suppression when a real icon lands. -->
|
||||||
<application
|
<application
|
||||||
android:label="AI Sessions"
|
android:label="@string/app_name"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
tools:ignore="MissingApplicationIcon">
|
tools:ignore="MissingApplicationIcon">
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
|
||||||
|
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
|
||||||
|
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
|
||||||
|
*
|
||||||
|
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||||
|
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
|
||||||
|
* that both [MainActivity] (which every variant compiles) and this can share without one.
|
||||||
|
*
|
||||||
|
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
|
||||||
|
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
|
||||||
|
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
|
||||||
|
* `https://` connection to that host and answers from this object's in-memory event log instead of
|
||||||
|
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
|
||||||
|
* the fold, uniqueItems) never learns the difference.
|
||||||
|
*/
|
||||||
|
object BenchFixture {
|
||||||
|
const val FIXTURE_HOST = "bench.fixture.invalid"
|
||||||
|
const val FIXTURE_PORT = 1
|
||||||
|
|
||||||
|
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||||
|
private const val BACKLOG_COUNT = 3200
|
||||||
|
|
||||||
|
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||||
|
|
||||||
|
/** The session id every bench run opens; nothing else in this build ever mints one. */
|
||||||
|
const val SESSION_ID = "bench-fixture-session"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
|
||||||
|
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
|
||||||
|
* live frame agree on what has "already happened" -- the same thing a real server's own
|
||||||
|
* transcript file guarantees.
|
||||||
|
*/
|
||||||
|
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
|
||||||
|
|
||||||
|
/** The events not yet appended to [log] -- the streaming phase's own source. */
|
||||||
|
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
|
||||||
|
|
||||||
|
private val images = mutableMapOf<String, ByteArray>()
|
||||||
|
|
||||||
|
@Volatile private var loaded = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun ensureLoaded(context: Context) {
|
||||||
|
if (loaded) return
|
||||||
|
val lines =
|
||||||
|
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
|
||||||
|
it.isNotBlank()
|
||||||
|
}
|
||||||
|
val parsed = lines.map { it to parseSeqEvent(it) }
|
||||||
|
log.addAll(parsed.take(BACKLOG_COUNT))
|
||||||
|
streamTail = parsed.drop(BACKLOG_COUNT)
|
||||||
|
for (name in listOf("bench1.png", "bench2.png")) {
|
||||||
|
images[name] = context.assets.open(name).readBytes()
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events the streaming phase has left to send. */
|
||||||
|
fun remainingStreamEvents(): Int = streamTail.size
|
||||||
|
|
||||||
|
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
|
||||||
|
fun pushNextLiveEvent(): Boolean {
|
||||||
|
val next = streamTail.firstOrNull() ?: return false
|
||||||
|
streamTail = streamTail.drop(1)
|
||||||
|
log.add(next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
|
||||||
|
@Synchronized
|
||||||
|
fun resetToBacklog(context: Context) {
|
||||||
|
loaded = false
|
||||||
|
log.clear()
|
||||||
|
ensureLoaded(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fileBytes(name: String): ByteArray? = images[name]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
|
||||||
|
*/
|
||||||
|
fun linesAfter(after: Long): List<String> =
|
||||||
|
log.filter { it.second.seq > after }.map { it.first }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
|
||||||
|
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
|
||||||
|
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
|
||||||
|
* client-core's own port tracks separately (CLIENT_CORE.md).
|
||||||
|
*/
|
||||||
|
fun page(before: Long?, limit: Int, after: Long?): List<String> {
|
||||||
|
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
|
||||||
|
val candidates = log.filter {
|
||||||
|
it.second.seq < upper && (after == null || it.second.seq > after)
|
||||||
|
}
|
||||||
|
return candidates.takeLast(limit).map { it.first }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.PipedInputStream
|
||||||
|
import java.io.PipedOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLStreamHandler
|
||||||
|
import java.net.URLStreamHandlerFactory
|
||||||
|
import java.security.Principal
|
||||||
|
import java.security.cert.Certificate
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
|
||||||
|
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
|
||||||
|
* per process, and a second real call throws -- so this guards it rather than relying on every
|
||||||
|
* caller to remember.
|
||||||
|
*
|
||||||
|
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
|
||||||
|
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun installFixtureNetworkOnce() {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
URL.setURLStreamHandlerFactory(
|
||||||
|
URLStreamHandlerFactory { protocol ->
|
||||||
|
if (protocol != "https") null
|
||||||
|
else
|
||||||
|
object : URLStreamHandler() {
|
||||||
|
override fun openConnection(url: URL): HttpURLConnection =
|
||||||
|
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
|
||||||
|
else
|
||||||
|
// The bench build makes no other https call -- this factory is
|
||||||
|
// installed only in FIXTURE_MODE (MainActivity) -- so there is
|
||||||
|
// deliberately no delegate to a platform handler here: once a
|
||||||
|
// URLStreamHandlerFactory is installed there is no supported way to
|
||||||
|
// ask the JDK for its own default handler back, and re-entering this
|
||||||
|
// same factory for the fallback would recurse forever rather than
|
||||||
|
// reach one.
|
||||||
|
throw java.io.IOException(
|
||||||
|
"bench build's fixture network has no route to https host " +
|
||||||
|
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var installed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
|
||||||
|
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
|
||||||
|
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
|
||||||
|
* `responseCode` and `inputStream`.
|
||||||
|
*/
|
||||||
|
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
|
||||||
|
private var input: InputStream? = null
|
||||||
|
private var writer: Thread? = null
|
||||||
|
|
||||||
|
override fun connect() {
|
||||||
|
if (input != null) return
|
||||||
|
input = route(url.path, url.query)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
writer?.interrupt()
|
||||||
|
try {
|
||||||
|
input?.close()
|
||||||
|
} catch (_: IOException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun usingProxy() = false
|
||||||
|
|
||||||
|
override fun getResponseCode(): Int {
|
||||||
|
connect()
|
||||||
|
return 200
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInputStream(): InputStream {
|
||||||
|
connect()
|
||||||
|
return input!!
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getErrorStream(): InputStream? = null
|
||||||
|
|
||||||
|
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
|
||||||
|
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
|
||||||
|
// fabricating a plausible-looking certificate.
|
||||||
|
override fun getCipherSuite() = "none (bench fixture, no TLS)"
|
||||||
|
|
||||||
|
override fun getLocalCertificates(): Array<Certificate>? = null
|
||||||
|
|
||||||
|
override fun getServerCertificates(): Array<Certificate> =
|
||||||
|
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||||
|
|
||||||
|
override fun getPeerPrincipal(): Principal =
|
||||||
|
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||||
|
|
||||||
|
override fun getLocalPrincipal(): Principal? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
|
||||||
|
*/
|
||||||
|
private fun route(path: String, query: String?): InputStream {
|
||||||
|
val params =
|
||||||
|
(query ?: "")
|
||||||
|
.split("&")
|
||||||
|
.filter { it.contains('=') }
|
||||||
|
.associate {
|
||||||
|
val (k, v) = it.split("=", limit = 2)
|
||||||
|
k to java.net.URLDecoder.decode(v, "UTF-8")
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
path.endsWith("/transcript") -> {
|
||||||
|
val lines =
|
||||||
|
BenchFixture.page(
|
||||||
|
before = params["before"]?.toLongOrNull(),
|
||||||
|
limit = params["limit"]?.toIntOrNull() ?: 80,
|
||||||
|
after = params["after"]?.toLongOrNull(),
|
||||||
|
)
|
||||||
|
val body = JSONArray(lines.map { org.json.JSONObject(it) })
|
||||||
|
ByteArrayInputStream(body.toString().toByteArray())
|
||||||
|
}
|
||||||
|
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
|
||||||
|
path.contains("/files/") -> {
|
||||||
|
val name = path.substringAfterLast("/files/")
|
||||||
|
val bytes =
|
||||||
|
BenchFixture.fileBytes(name)
|
||||||
|
?: throw IOException("bench fixture has no file named $name")
|
||||||
|
ByteArrayInputStream(bytes)
|
||||||
|
}
|
||||||
|
else -> throw IOException("bench fixture has no route for $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
|
||||||
|
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
|
||||||
|
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
|
||||||
|
* rather than something worth a condition variable for.
|
||||||
|
*/
|
||||||
|
private fun openEventsStream(after: Long): InputStream {
|
||||||
|
val pipeIn = PipedInputStream(1 shl 16)
|
||||||
|
val pipeOut = PipedOutputStream(pipeIn)
|
||||||
|
var sent = after
|
||||||
|
val thread = Thread {
|
||||||
|
try {
|
||||||
|
while (!Thread.currentThread().isInterrupted) {
|
||||||
|
val fresh = BenchFixture.linesAfter(sent)
|
||||||
|
for (line in fresh) {
|
||||||
|
pipeOut.write("data: $line\n\n".toByteArray())
|
||||||
|
pipeOut.flush()
|
||||||
|
sent = org.json.JSONObject(line).getLong("seq")
|
||||||
|
}
|
||||||
|
Thread.sleep(50)
|
||||||
|
}
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
// disconnect() -- the ordinary way this ends.
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// The reader side (Sse) closed its end.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
pipeOut.close()
|
||||||
|
} catch (_: IOException) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.also {
|
||||||
|
it.isDaemon = true
|
||||||
|
it.start()
|
||||||
|
}
|
||||||
|
writer = thread
|
||||||
|
return pipeIn
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Process
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
|
||||||
|
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
|
||||||
|
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
|
||||||
|
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||||
|
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||||
|
* [BenchFixture]'s doc comment gives.
|
||||||
|
*/
|
||||||
|
object BenchRun {
|
||||||
|
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
|
||||||
|
private const val CYCLES = 6
|
||||||
|
private const val SWIPE_PX = 900f
|
||||||
|
private const val SWIPE_MS = 200
|
||||||
|
private const val SWIPE_PAUSE_MS = 500L
|
||||||
|
|
||||||
|
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
|
||||||
|
private const val STREAM_EVENTS_PER_SEC = 20
|
||||||
|
private const val STREAM_SECONDS = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
|
||||||
|
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
|
||||||
|
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
|
||||||
|
*/
|
||||||
|
suspend fun run(
|
||||||
|
context: Context,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
listState: LazyListState,
|
||||||
|
): List<String> {
|
||||||
|
FrameStats.reset()
|
||||||
|
DebugStats.reset()
|
||||||
|
val cpuStartMs = Process.getElapsedCpuTime()
|
||||||
|
|
||||||
|
val battery = BatterySampler(context)
|
||||||
|
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
|
||||||
|
// suspend this function until the sampler job ended -- and it only ends when told to.
|
||||||
|
val samplerJob = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
battery.sample()
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
|
||||||
|
// content and two back, so a cycle returns to where it started and the whole loop measures
|
||||||
|
// steady-state scrolling rather than travelling somewhere new each time.
|
||||||
|
repeat(CYCLES) {
|
||||||
|
repeat(2) {
|
||||||
|
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
|
||||||
|
delay(SWIPE_PAUSE_MS)
|
||||||
|
}
|
||||||
|
repeat(2) {
|
||||||
|
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
|
||||||
|
delay(SWIPE_PAUSE_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to
|
||||||
|
// latest" tap is -- a reply streamed into a list parked further back arrives off-screen and
|
||||||
|
// the report would show nothing happened.
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
|
||||||
|
var sent = 0
|
||||||
|
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||||
|
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||||
|
BenchFixture.pushNextLiveEvent()
|
||||||
|
sent++
|
||||||
|
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||||
|
}
|
||||||
|
// Lets the last few deltas land and draw before the report is read.
|
||||||
|
delay(300)
|
||||||
|
|
||||||
|
samplerJob.cancel()
|
||||||
|
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||||
|
val rssLine = peakRssLine()
|
||||||
|
val batteryLine = battery.finish()
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
|
||||||
|
" process CPU time over this run: ${cpuMs}ms",
|
||||||
|
rssLine,
|
||||||
|
batteryLine,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||||
|
private fun peakRssLine(): String {
|
||||||
|
val kb =
|
||||||
|
try {
|
||||||
|
File("/proc/self/status")
|
||||||
|
.readLines()
|
||||||
|
.firstOrNull { it.startsWith("VmHWM:") }
|
||||||
|
?.trim()
|
||||||
|
?.removePrefix("VmHWM:")
|
||||||
|
?.trim()
|
||||||
|
?.removeSuffix("kB")
|
||||||
|
?.trim()
|
||||||
|
?.toLongOrNull()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return " peak RSS: " +
|
||||||
|
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
|
||||||
|
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
|
||||||
|
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
|
||||||
|
* real samples, which would silently understate every number after it. See UI_RULES: never present
|
||||||
|
* an inferred value as a measured one.
|
||||||
|
*/
|
||||||
|
private class BatterySampler(context: Context) {
|
||||||
|
private val manager = context.getSystemService(BatteryManager::class.java)
|
||||||
|
private val samples = mutableListOf<Int>()
|
||||||
|
|
||||||
|
fun sample() {
|
||||||
|
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||||
|
if (value != null && value != Int.MIN_VALUE) samples.add(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish(): String {
|
||||||
|
if (samples.isEmpty()) return " battery current: unavailable on this device"
|
||||||
|
val meanUa = samples.sum() / samples.size
|
||||||
|
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
|
||||||
|
" (min ${samples.min()}, max ${samples.max()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,13 @@ fun debugReport(
|
|||||||
frames: List<String>,
|
frames: List<String>,
|
||||||
accounting: List<String>,
|
accounting: List<String>,
|
||||||
crash: String?,
|
crash: String?,
|
||||||
|
/**
|
||||||
|
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
|
||||||
|
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
|
||||||
|
* heading only appears when there is something to put under it, so an ordinary copy from the
|
||||||
|
* render-report button reads exactly as it did before this existed.
|
||||||
|
*/
|
||||||
|
extra: List<String> = emptyList(),
|
||||||
): String = buildString {
|
): String = buildString {
|
||||||
appendLine("ai-app render report")
|
appendLine("ai-app render report")
|
||||||
appendLine(device)
|
appendLine(device)
|
||||||
@@ -152,6 +159,11 @@ fun debugReport(
|
|||||||
appendLine("work since this was last copied:")
|
appendLine("work since this was last copied:")
|
||||||
val work = DebugStats.lines()
|
val work = DebugStats.lines()
|
||||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
||||||
|
if (extra.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("bench:")
|
||||||
|
extra.forEach { appendLine(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
||||||
|
|||||||
@@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
// Transparent status bar on every version; the Surface below paints through underneath it
|
||||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
|
||||||
|
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
|
||||||
|
// permission, and no notification prompt -- none of them mean anything with no server and
|
||||||
|
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
|
||||||
|
// to talk to a real backend is made to talk to this instead. Still needs the same
|
||||||
|
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
|
||||||
|
// platform's own default from Android 15 on this app's targetSdk, with or without the call
|
||||||
|
// above, so skipping the padding here put the header's own buttons under the status bar --
|
||||||
|
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
|
||||||
|
if (BuildConfig.FIXTURE_MODE) {
|
||||||
|
installFixtureNetworkOnce()
|
||||||
|
BenchFixture.ensureLoaded(this)
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(colorScheme = AiAppColors) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
|
||||||
|
SessionScreen(
|
||||||
|
settings = BenchFixture.settings,
|
||||||
|
summary = benchSessionSummary(),
|
||||||
|
onBack = { finish() },
|
||||||
|
onFiles = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
||||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||||
@@ -147,6 +176,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
|
||||||
|
private fun benchSessionSummary() =
|
||||||
|
SessionSummary(
|
||||||
|
id = BenchFixture.SESSION_ID,
|
||||||
|
setup = "bench",
|
||||||
|
setupName = "bench",
|
||||||
|
provider = "bench",
|
||||||
|
title = "P0 benchmark",
|
||||||
|
model = null,
|
||||||
|
keepsOwnTranscript = false,
|
||||||
|
permissionMode = null,
|
||||||
|
imported = false,
|
||||||
|
notify = false,
|
||||||
|
cwd = null,
|
||||||
|
contextTokens = null,
|
||||||
|
maxImageEdge = null,
|
||||||
|
status = "idle",
|
||||||
|
lastActivity = 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||||
// lands here rather than in a second activity instance.
|
// lands here rather than in a second activity instance.
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|||||||
@@ -1228,7 +1228,12 @@ fun SessionScreen(
|
|||||||
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
||||||
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
||||||
// whatever now sat there -- reporting a number that was never measured.
|
// whatever now sat there -- reporting a number that was never measured.
|
||||||
val copyRenderReport = {
|
// Shared by the ordinary "Copy" button and (bench build only) "Run benchmark": what differs
|
||||||
|
// between them is only whether there is a [extra] section, built by BenchRun.run beforehand --
|
||||||
|
// everything about assembling, copying and logging the report is exactly the same act either
|
||||||
|
// way, and a second copy of it beside `onRunBenchmark` below would be the two silently
|
||||||
|
// disagreeing about what "the report" contains the first time either one changed.
|
||||||
|
fun buildAndCopyReport(extra: List<String> = emptyList()) {
|
||||||
val report =
|
val report =
|
||||||
debugReport(
|
debugReport(
|
||||||
device =
|
device =
|
||||||
@@ -1250,6 +1255,7 @@ fun SessionScreen(
|
|||||||
accounting =
|
accounting =
|
||||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||||
crash = lastCrash(context),
|
crash = lastCrash(context),
|
||||||
|
extra = extra,
|
||||||
)
|
)
|
||||||
context.copyToClipboard("ai-app render report", report)
|
context.copyToClipboard("ai-app render report", report)
|
||||||
// Also to the log, so a session driving the app over adb can read the same report the
|
// Also to the log, so a session driving the app over adb can read the same report the
|
||||||
@@ -1263,6 +1269,20 @@ fun SessionScreen(
|
|||||||
DebugStats.reset()
|
DebugStats.reset()
|
||||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
val copyRenderReport = { buildAndCopyReport() }
|
||||||
|
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
|
||||||
|
// fixture session opened below instead of a real server. Null everywhere else -- see
|
||||||
|
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||||
|
val runBenchmark: (() -> Unit)? =
|
||||||
|
if (BuildConfig.FIXTURE_MODE) {
|
||||||
|
{
|
||||||
|
settingsOpen = false
|
||||||
|
scope.launch {
|
||||||
|
val extra = BenchRun.run(context, scope, listState)
|
||||||
|
buildAndCopyReport(extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -1952,6 +1972,7 @@ fun SessionScreen(
|
|||||||
},
|
},
|
||||||
onDismiss = { settingsOpen = false },
|
onDismiss = { settingsOpen = false },
|
||||||
onCopyRenderReport = copyRenderReport,
|
onCopyRenderReport = copyRenderReport,
|
||||||
|
onRunBenchmark = runBenchmark,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ fun SessionSettingsDialog(
|
|||||||
* measures is that screen's own state.
|
* measures is that screen's own state.
|
||||||
*/
|
*/
|
||||||
onCopyRenderReport: () -> Unit,
|
onCopyRenderReport: () -> Unit,
|
||||||
|
/**
|
||||||
|
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
|
||||||
|
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
|
||||||
|
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
|
||||||
|
* control with nothing behind it on every other build is not a state worth drawing.
|
||||||
|
*/
|
||||||
|
onRunBenchmark: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
var name by remember(sessionId) { mutableStateOf(title) }
|
||||||
@@ -508,6 +515,21 @@ fun SessionSettingsDialog(
|
|||||||
Text("Render timings", modifier = Modifier.weight(1f))
|
Text("Render timings", modifier = Modifier.weight(1f))
|
||||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||||
}
|
}
|
||||||
|
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
|
||||||
|
// ui-trace and the emulator smoke run find it by that label, the same way every
|
||||||
|
// other control here is found -- see AGENTS.md's "Driving the UI".
|
||||||
|
onRunBenchmark?.let { run ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("P0 benchmark", modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = run) { Text("Run benchmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Overridden by the `bench` build type's resValue (build.gradle.kts) to "AI Sessions bench",
|
||||||
|
so the two are never mistaken for each other in the launcher or in Settings. -->
|
||||||
|
<string name="app_name">AI Sessions</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# The P0 benchmark fixture
|
||||||
|
|
||||||
|
`transcript.jsonl` is a synthetic transcript in the app's own event model (the JSON lines
|
||||||
|
`GET /sessions/{id}/transcript` returns; see `Events.kt`'s `parseSeqEvent` and
|
||||||
|
`server/src/session/driver.rs`) -- never a real one. It is what both the Compose `bench` build
|
||||||
|
and iris's bench build open with no server, so the two apps draw exactly the same content and a
|
||||||
|
frame-time comparison is measuring the renderer rather than the data.
|
||||||
|
|
||||||
|
Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it
|
||||||
|
reproduces the same file byte for byte). It writes into `assets/` -- a separate directory from this
|
||||||
|
script and README, because the Compose `bench` build type points its own asset source set straight
|
||||||
|
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
|
||||||
|
script and a markdown file have no business inside an APK:
|
||||||
|
|
||||||
|
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
|
||||||
|
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
||||||
|
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
|
||||||
|
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
|
||||||
|
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
|
||||||
|
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
|
||||||
|
fold path a real SSE reply arrives on, which is P0's "streaming phase."
|
||||||
|
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
|
||||||
|
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||||
|
`"type":"image"` events in the transcript.
|
||||||
|
|
||||||
|
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
|
||||||
|
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
|
||||||
|
both apps' bench builds embed the identical bytes without needing this script at build time.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
File diff suppressed because it is too large.
Load diff
Executable
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates transcript.jsonl -- the synthetic fixture P0's benchmark opens in both apps.
|
||||||
|
|
||||||
|
Deterministic (fixed seed), so a Compose bench APK and an iris bench APK draw byte-identical
|
||||||
|
content: the point of the fixture is a like-for-like comparison, not a realistic one.
|
||||||
|
|
||||||
|
Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary
|
||||||
|
style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL
|
||||||
|
shape. This file's shape is the *app's own event model* instead: one JSON object per line,
|
||||||
|
matching what GET /sessions/{id}/transcript returns and what Events.kt's parseSeqEvent reads
|
||||||
|
(server/src/session/driver.rs is the source of truth for the field names).
|
||||||
|
|
||||||
|
./generate.py writes transcript.jsonl and bench1.png/bench2.png here
|
||||||
|
|
||||||
|
BACKLOG_COUNT events (seq 1..BACKLOG_COUNT) are the scrolled-back history the benchmark opens
|
||||||
|
with. A further STREAM_COUNT events (seq BACKLOG_COUNT+1..) are not part of the opening window;
|
||||||
|
both bench harnesses replay them at a fixed rate as the "streaming reply" phase, appended through
|
||||||
|
the same live path a real SSE reply arrives on. Keeping both halves in one file means one
|
||||||
|
generator and one seed to keep in sync, rather than two fixtures that can drift apart.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SEED = 20260905
|
||||||
|
BACKLOG_COUNT = 3200
|
||||||
|
STREAM_COUNT = 400
|
||||||
|
HERE = Path(__file__).resolve().parent / "assets"
|
||||||
|
|
||||||
|
random.seed(SEED)
|
||||||
|
|
||||||
|
LANGUAGES = ["rust", "kotlin", "python", "sh", "json", "toml"]
|
||||||
|
|
||||||
|
CODE_SNIPPETS = {
|
||||||
|
"rust": '''fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
// a comment worth keeping: this is the fold the app's own screen runs
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}''',
|
||||||
|
"kotlin": '''fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> {
|
||||||
|
// mirrors the server's own event model, one item per line
|
||||||
|
return items + TranscriptItem.from(entry)
|
||||||
|
}''',
|
||||||
|
"python": '''def render_report(frames, cpu_ms, rss_kb):
|
||||||
|
# printed for a human to paste back, so every number carries its unit
|
||||||
|
return f"{frames} frames, {cpu_ms}ms cpu, {rss_kb}kb peak rss"''',
|
||||||
|
"sh": '''#!/bin/sh
|
||||||
|
# scripted scroll loop, the shape transcript-bench.sh drives on a phone
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
ui-trace record --do "swipe 540 700 540 1600 200"
|
||||||
|
done''',
|
||||||
|
"json": '{"seq": 1, "type": "status", "state": "running"}',
|
||||||
|
"toml": '''[package]
|
||||||
|
name = "bench-fixture"
|
||||||
|
version = "0.1.0"''',
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADINGS = [
|
||||||
|
"## Plan",
|
||||||
|
"## What changed",
|
||||||
|
"## Why this approach",
|
||||||
|
"### Open questions",
|
||||||
|
"## Results",
|
||||||
|
]
|
||||||
|
|
||||||
|
WORDS = (
|
||||||
|
"session render report frame budget scroll transcript fold event cache "
|
||||||
|
"cursor probe stream backlog swipe fixture bench compose iris widget layout "
|
||||||
|
"measure place draw tool call token context window anchor"
|
||||||
|
).split()
|
||||||
|
|
||||||
|
|
||||||
|
def paragraph(n=24):
|
||||||
|
words = [random.choice(WORDS) for _ in range(n)]
|
||||||
|
words[0] = words[0].capitalize()
|
||||||
|
text = " ".join(words) + "."
|
||||||
|
# Sprinkle markdown inline spans so the syntax highlighter/markdown parser sees a real mix.
|
||||||
|
text = text.replace(" fold ", " **fold** ", 1)
|
||||||
|
text = text.replace(" cursor ", " *cursor* ", 1)
|
||||||
|
text = text.replace(" cache ", " `cache` ", 1)
|
||||||
|
if "bench" in text:
|
||||||
|
text = text.replace(
|
||||||
|
" bench ", " [bench](https://example.com/bench) ", 1
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def make_png(rgb, size=8):
|
||||||
|
"""A tiny, valid PNG -- flat colour, no external dependency."""
|
||||||
|
|
||||||
|
def chunk(tag, data):
|
||||||
|
c = tag + data
|
||||||
|
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c))
|
||||||
|
|
||||||
|
sig = b"\x89PNG\r\n\x1a\n"
|
||||||
|
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||||
|
raw = b""
|
||||||
|
for _ in range(size):
|
||||||
|
raw += b"\x00" + bytes(rgb) * size
|
||||||
|
idat = zlib.compress(raw)
|
||||||
|
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
HERE.mkdir(exist_ok=True)
|
||||||
|
lines = []
|
||||||
|
seq = 1
|
||||||
|
ts = 1_788_000_000.0
|
||||||
|
|
||||||
|
def emit(type_, **fields):
|
||||||
|
nonlocal seq, ts
|
||||||
|
obj = {"seq": seq, "ts": round(ts, 3), "type": type_}
|
||||||
|
obj.update(fields)
|
||||||
|
lines.append(json.dumps(obj, separators=(",", ":")))
|
||||||
|
seq += 1
|
||||||
|
ts += random.uniform(0.05, 2.0)
|
||||||
|
|
||||||
|
emit("status", state="running")
|
||||||
|
emit("settings", model="bench-model", permissionMode="auto")
|
||||||
|
|
||||||
|
image_refs = []
|
||||||
|
turn = 0
|
||||||
|
while seq <= BACKLOG_COUNT:
|
||||||
|
turn += 1
|
||||||
|
emit("userMessage", text=f"Turn {turn}: {paragraph(12)}", id=None, attachments=[])
|
||||||
|
|
||||||
|
# A tool call with kilobyte-scale input/output every few turns.
|
||||||
|
if turn % 3 == 0:
|
||||||
|
tool_id = f"tool-{turn}"
|
||||||
|
big_input = json.dumps({"path": f"/repo/file_{turn}.rs", "content": paragraph(400)})
|
||||||
|
emit("toolStart", id=tool_id, tool="Edit", input=big_input)
|
||||||
|
big_output = "\n".join(paragraph(60) for _ in range(20))
|
||||||
|
emit("toolUpdate", id=tool_id, output=big_output[: len(big_output) // 2])
|
||||||
|
emit("toolEnd", id=tool_id, output=big_output)
|
||||||
|
|
||||||
|
# A reply: a heading, prose, a fenced block in a rotating language, a table, then deltas.
|
||||||
|
emit("assistantText", delta=f"{random.choice(HEADINGS)}\n\n")
|
||||||
|
emit("assistantText", delta=paragraph(30) + "\n\n")
|
||||||
|
lang = LANGUAGES[turn % len(LANGUAGES)]
|
||||||
|
emit("assistantText", delta=f"```{lang}\n{CODE_SNIPPETS[lang]}\n```\n\n")
|
||||||
|
if turn % 5 == 0:
|
||||||
|
emit(
|
||||||
|
"assistantText",
|
||||||
|
delta="| column | value |\n|---|---|\n| a | " + paragraph(3) + " |\n\n",
|
||||||
|
)
|
||||||
|
# A run of small deltas -- the shape a live reply actually streams in.
|
||||||
|
for _ in range(random.randint(3, 8)):
|
||||||
|
emit("assistantText", delta=paragraph(6) + " ")
|
||||||
|
|
||||||
|
# A couple of images, base64 PNGs, the way a real transcript embeds a screenshot.
|
||||||
|
if turn in (10, 40):
|
||||||
|
ref = f"bench{len(image_refs) + 1}.png"
|
||||||
|
image_refs.append(ref)
|
||||||
|
emit("image", ref=ref, about=None)
|
||||||
|
|
||||||
|
emit("usageDelta", tokens=random.randint(200, 4000), context=random.randint(2000, 180000))
|
||||||
|
|
||||||
|
if turn % 15 == 0:
|
||||||
|
emit(
|
||||||
|
"compacted",
|
||||||
|
preTokens=180000,
|
||||||
|
postTokens=20000,
|
||||||
|
trigger="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
|
||||||
|
# bench harness replays at a fixed events/sec through the live fold path.
|
||||||
|
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||||
|
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||||
|
emit("assistantText", delta=paragraph(5) + " ")
|
||||||
|
emit("status", state="idle")
|
||||||
|
|
||||||
|
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
(HERE / "bench1.png").write_bytes(make_png((220, 90, 90)))
|
||||||
|
(HERE / "bench2.png").write_bytes(make_png((90, 150, 220)))
|
||||||
|
|
||||||
|
print(f"wrote {len(lines)} events ({BACKLOG_COUNT} backlog + {STREAM_COUNT} stream) to transcript.jsonl")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+9
-3
@@ -4,6 +4,11 @@
|
|||||||
# ./build-apk.sh the release build, signed (what the phone runs)
|
# ./build-apk.sh the release build, signed (what the phone runs)
|
||||||
# ./build-apk.sh debug the debug build, for reproducing something the
|
# ./build-apk.sh debug the debug build, for reproducing something the
|
||||||
# emulator scripts would build anyway
|
# emulator scripts would build anyway
|
||||||
|
# ./build-apk.sh bench P0's benchmark build (own app id, "AI Sessions
|
||||||
|
# bench" label, opens straight onto the fixture
|
||||||
|
# session -- see docs/RUST.md's P0 box and
|
||||||
|
# app/bench-fixture/README.md). Signed the same
|
||||||
|
# as release; never touches the CA it pins.
|
||||||
#
|
#
|
||||||
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
||||||
# build modes, one command line each; it passes nothing else, so the word
|
# build modes, one command line each; it passes nothing else, so the word
|
||||||
@@ -25,8 +30,9 @@ VARIANT=${1:-release}
|
|||||||
case "$VARIANT" in
|
case "$VARIANT" in
|
||||||
release) TASK=assembleRelease ;;
|
release) TASK=assembleRelease ;;
|
||||||
debug) TASK=assembleDebug ;;
|
debug) TASK=assembleDebug ;;
|
||||||
|
bench) TASK=assembleBench ;;
|
||||||
*)
|
*)
|
||||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2
|
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug, bench)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -81,7 +87,7 @@ fi
|
|||||||
# uninstalling it first: the signatures differ, and Android refuses to
|
# uninstalling it first: the signatures differ, and Android refuses to
|
||||||
# update across them.
|
# update across them.
|
||||||
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
||||||
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
if { [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; } && [ ! -f "$KEYSTORE" ]; then
|
||||||
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
||||||
KEYTOOL="${KEYTOOL:-keytool}"
|
KEYTOOL="${KEYTOOL:-keytool}"
|
||||||
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
||||||
@@ -97,7 +103,7 @@ if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
|||||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
||||||
fi
|
fi
|
||||||
if [ "$VARIANT" = release ]; then
|
if [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; then
|
||||||
AI_APP_KEYSTORE="$KEYSTORE"
|
AI_APP_KEYSTORE="$KEYSTORE"
|
||||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
||||||
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes" pass (2026-09-05). The same
|
||||||
|
# 24-swipe/6-cycle loop as transcript-bench.sh's, extracted for iris's own
|
||||||
|
# demo app -- transcript-bench.sh itself is Compose-specific (opens by
|
||||||
|
# session title through the Compose app's own UI) and cannot be called
|
||||||
|
# directly against dev.iris.android.demo.
|
||||||
|
#
|
||||||
|
# MUST be run from inside this checkout (not /tmp): ui-trace/adb pick which
|
||||||
|
# emulator to target from the current directory's basename (the
|
||||||
|
# per-checkout-AVD rule), and a previous pass lost two attempts to a `cd`
|
||||||
|
# into /tmp that made this resolve to a nonexistent "tmp" checkout.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
. ./android-env.sh >/dev/null 2>&1
|
||||||
|
|
||||||
|
cycles=${1:-6}
|
||||||
|
|
||||||
|
ui-trace record -d 3000 --do "tap 'Reset frame report'" -o /tmp/iris-bench-reset.txt >/dev/null
|
||||||
|
adb logcat -c
|
||||||
|
|
||||||
|
DO=""
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt "$cycles" ]; do
|
||||||
|
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
eval ui-trace record -d $((cycles * 16000 + 20000)) $DO -o /tmp/iris-bench-scroll.txt >/dev/null
|
||||||
|
|
||||||
|
ui-trace record -d 3000 --do "tap 'Frame report'" -o /tmp/iris-bench-report.txt >/dev/null
|
||||||
|
sleep 1
|
||||||
|
adb logcat -d -s iris-android-app:I | grep "iris frame report:"
|
||||||
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
include(":androidApp")
|
include(":androidApp")
|
||||||
|
|
||||||
|
// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a
|
||||||
|
// separate module from :androidApp so the ~13,000 lines of working Compose
|
||||||
|
// UI there are untouched. See shellApp/build.gradle.kts's module comment.
|
||||||
|
include(":shellApp")
|
||||||
|
|
||||||
// The app half of wg-app-link, resolved by path through the submodule so
|
// The app half of wg-app-link, resolved by path through the submodule so
|
||||||
// this checkout and the crate it consumes move together -- the same
|
// this checkout and the crate it consumes move together -- the same
|
||||||
// arrangement `server/` uses for the Rust half. See that repo's README.
|
// arrangement `server/` uses for the Rust half. See that repo's README.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
plugins { alias(libs.plugins.androidApplication) }
|
||||||
|
|
||||||
|
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
||||||
|
// into Rust (`../../android-shell`). Deliberately its own module rather
|
||||||
|
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
||||||
|
// of working Compose UI this experiment does not touch, and the two can be
|
||||||
|
// installed side by side on the same development device (see
|
||||||
|
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
|
||||||
|
// and Keystore alias are not the production app's). No Compose plugin, no
|
||||||
|
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
||||||
|
// Java, and the CA constant below is generated as Java too.
|
||||||
|
//
|
||||||
|
// The CA this build pins is baked in the same way `androidApp`'s does --
|
||||||
|
// see that module's `build.gradle.kts` comment for the reasoning (the
|
||||||
|
// trust boundary follows the machine that builds, never a pasted copy).
|
||||||
|
// `PinnedCa.java`'s package must match `android-shell`'s
|
||||||
|
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
||||||
|
val pinnedCaPath: String =
|
||||||
|
System.getenv("AI_APP_CA")
|
||||||
|
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
||||||
|
"/ai-app/certs/ca.pem"
|
||||||
|
|
||||||
|
abstract class GeneratePinnedCa : DefaultTask() {
|
||||||
|
@get:Input abstract val caPath: Property<String>
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
@get:Optional
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val caCertificate: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val path = caPath.get()
|
||||||
|
val ca = File(path)
|
||||||
|
if (!ca.isFile) {
|
||||||
|
throw GradleException(
|
||||||
|
"No CA certificate at $path.\n" +
|
||||||
|
"Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " +
|
||||||
|
"generates the CA this build pins.\n" +
|
||||||
|
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pem = ca.readText().trim()
|
||||||
|
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||||
|
throw GradleException("$path is not a PEM certificate.")
|
||||||
|
}
|
||||||
|
val dir = outputDir.get().dir("com/example/aiapp/shell").asFile
|
||||||
|
dir.mkdirs()
|
||||||
|
// Same reasoning as androidApp's generatePinnedCert: the text block
|
||||||
|
// must start immediately after the opening `"""`, or
|
||||||
|
// CertificateFactory stops recognising the "-----BEGIN" preamble.
|
||||||
|
File(dir, "PinnedCa.java")
|
||||||
|
.writeText(
|
||||||
|
"""
|
||||||
|
|// Generated from $path by the generatePinnedCa task. Do not edit.
|
||||||
|
|package com.example.aiapp.shell;
|
||||||
|
|
|
||||||
|
|public final class PinnedCa {
|
||||||
|
| private PinnedCa() {}
|
||||||
|
| public static final String PINNED_CA_PEM = ""${'"'}
|
||||||
|
|$pem""${'"'};
|
||||||
|
|}
|
||||||
|
|"""
|
||||||
|
.trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val generatePinnedCa =
|
||||||
|
tasks.register<GeneratePinnedCa>("generatePinnedCa") {
|
||||||
|
val ca = file(pinnedCaPath)
|
||||||
|
caPath.set(pinnedCaPath)
|
||||||
|
if (ca.isFile) {
|
||||||
|
caCertificate.set(ca)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.aiapp.shell"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.aiapp.shell"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
// Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md)
|
||||||
|
// signs its own, Gradle-free build with this same keystore, and the two can only
|
||||||
|
// `adb install -r` over each other if they carry the same certificate.
|
||||||
|
val keystore = System.getenv("AI_APP_KEYSTORE")
|
||||||
|
signingConfigs {
|
||||||
|
if (keystore != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystore)
|
||||||
|
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = "ai-app"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does
|
||||||
|
// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a
|
||||||
|
// packaging step, so this one task is the single place Gradle still runs in that pipeline. It
|
||||||
|
// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes
|
||||||
|
// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration,
|
||||||
|
// which would hand back .aar files d8 cannot read directly.
|
||||||
|
val artifactType = Attribute.of("artifactType", String::class.java)
|
||||||
|
|
||||||
|
tasks.register("printRuntimeClasspathJars") {
|
||||||
|
description = "Writes the resolved release runtime classpath jars, one per line, for xtask."
|
||||||
|
val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt")
|
||||||
|
outputs.file(outputFile)
|
||||||
|
val jars =
|
||||||
|
configurations
|
||||||
|
.getByName("releaseRuntimeClasspath")
|
||||||
|
.incoming
|
||||||
|
.artifactView { attributes.attribute(artifactType, "android-classes-jar") }
|
||||||
|
.files
|
||||||
|
// Captured as a plain FileCollection (not the ArtifactView itself, which the
|
||||||
|
// configuration cache cannot serialize) so this task is still cacheable.
|
||||||
|
inputs.files(jars)
|
||||||
|
doLast {
|
||||||
|
val file = outputFile.get().asFile
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
file.writeText(jars.joinToString("\n") { it.absolutePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
||||||
|
// android-shell's settings.rs calls into this Kotlin class directly
|
||||||
|
// over JNI rather than re-sealing the token in Rust; see that file's
|
||||||
|
// module doc.
|
||||||
|
implementation(project(":link"))
|
||||||
|
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
||||||
|
// ServiceCompat -- android-shell's notify.rs calls these classes over
|
||||||
|
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
||||||
|
// the library that already has it, rather than being re-derived as a
|
||||||
|
// set of Build.VERSION.SDK_INT branches in Rust.
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Mirrors androidApp's manifest (AGENTS.md: reuse it rather than
|
||||||
|
re-deriving it) for the permissions and declarations E3 actually
|
||||||
|
exercises. Not carried over: the QR scanner activity (this
|
||||||
|
experiment enrolls via the aiappshell://enroll deep link directly,
|
||||||
|
per AGENTS.md's ui-sandbox.sh banner) and the app icon warning
|
||||||
|
suppression below, for the same reason androidApp's is there. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="AI Sessions (shell)"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
tools:ignore="MissingApplicationIcon">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Enrollment: aiappshell://enroll?host=...&port=...&token=...,
|
||||||
|
per AGENTS.md's ui-sandbox.sh banner (fed to this app with
|
||||||
|
`adb shell am start -a android.intent.action.VIEW -d
|
||||||
|
'aiappshell://enroll?...'`, or -n'd at this component
|
||||||
|
directly if a second app also claims the aiapp scheme). -->
|
||||||
|
<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="aiappshell" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- The share sheet - see android-shell's share.rs. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- specialUse, not dataSync, for the reason androidApp's manifest
|
||||||
|
gives: a connection that has to keep listening overnight
|
||||||
|
cannot accept dataSync's six-hour cap. -->
|
||||||
|
<service
|
||||||
|
android:name=".NotificationService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="E3 experiment: holds one connection to the sandbox server so a
|
||||||
|
session that needs an answer can be reported while the app is closed." />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.example.aiapp.shell;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E3's floor, per RUST.md's "How much Java is unavoidable": a class the framework
|
||||||
|
* constructs by name from the manifest, with its lifecycle methods handing straight to Rust
|
||||||
|
* (android-shell's {@code share::handle_intent}). No Compose, no layout -- there is no screen to
|
||||||
|
* draw yet (that is E4's job, on iris); {@link #toast} is this experiment's stand-in for showing
|
||||||
|
* something happened.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("android_shell");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
NotificationService.sync(this);
|
||||||
|
nativeHandleIntent(this, getIntent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchMode="singleTop": a notification tap or a share while this activity is already on
|
||||||
|
// top lands here rather than in a second instance -- same reasoning as MainActivity.kt's.
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
nativeHandleIntent(this, intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from android-shell, sometimes from a background thread (a share's network call is
|
||||||
|
* never made on the calling thread -- see share.rs). {@code Toast} itself is main-thread-only,
|
||||||
|
* so this hops there with a {@link Handler} rather than assuming the caller already has.
|
||||||
|
*/
|
||||||
|
static void toast(Context context, String message) {
|
||||||
|
new Handler(Looper.getMainLooper())
|
||||||
|
.post(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeHandleIntent(Activity activity, Intent intent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.aiapp.shell;
|
||||||
|
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.IBinder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E3's second unavoidable Java class (RUST.md): a foreground service constructed by the framework
|
||||||
|
* from the manifest, existing only to hand its lifecycle to android-shell's {@code notify} module
|
||||||
|
* -- the SSE follow loop, deciding what a notification says, and posting it are all Rust reached
|
||||||
|
* through these three native calls. See {@code Notifications.kt}'s {@code NotificationService} for
|
||||||
|
* the Kotlin original this mirrors.
|
||||||
|
*/
|
||||||
|
public class NotificationService extends Service {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("android_shell");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
return nativeOnStartCommand(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
nativeOnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts this service if there is a server to connect to, and stops it otherwise. */
|
||||||
|
static void sync(Context context) {
|
||||||
|
nativeSync(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeSync(Context context);
|
||||||
|
|
||||||
|
private static native int nativeOnStartCommand(Service service);
|
||||||
|
|
||||||
|
private static native void nativeOnDestroy();
|
||||||
|
}
|
||||||
Generated
+940
@@ -0,0 +1,940 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "client-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"event-model",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "displaydoc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locale_core",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "potential_utf"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||||
|
dependencies = [
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"cookie_store",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "url"
|
||||||
|
version = "2.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"idna",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerotrie"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec"
|
||||||
|
version = "0.11.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||||
|
dependencies = [
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.11.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "client-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# The app's pure logic, held once instead of twice: the event model (shared
|
||||||
|
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
|
||||||
|
# surface (see `server/src/routes.rs`'s module doc for the table), the
|
||||||
|
# transcript fold and cache, the markdown block model, the syntax
|
||||||
|
# highlighter and the ANSI parser. See `docs/CLIENT_CORE.md` for
|
||||||
|
# what this holds today, what it does not yet, and how it corresponds to
|
||||||
|
# the Kotlin it replaces.
|
||||||
|
#
|
||||||
|
# No UI framework dependency of any kind -- this crate is meant to outlive
|
||||||
|
# whichever one the app ends up drawing with (see RUST.md).
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
event-model = { path = "../event-model" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
|
# 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
|
||||||
|
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||||
|
# TLS, so this reuses that choice rather than pulling in reqwest's async
|
||||||
|
# stack -- a client that runs one blocking request at a time, the way
|
||||||
|
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
|
||||||
|
# no need of an async runtime, and RUST.md's brief for this port is
|
||||||
|
# "lightweight" throughout.
|
||||||
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
//! What a tool printed, with its terminal styling applied and everything
|
||||||
|
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
||||||
|
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
||||||
|
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
||||||
|
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
||||||
|
//! [`Style`] onto whatever it draws with.
|
||||||
|
//!
|
||||||
|
//! Bash output arrives exactly as the program wrote it, escape sequences
|
||||||
|
//! included, and drawn verbatim those are line noise in the middle of the
|
||||||
|
//! thing being read. Stripping them all would be the other half-answer --
|
||||||
|
//! colour is often the whole of what a diff or a test run is saying.
|
||||||
|
//!
|
||||||
|
//! So the sequences that decide how text *looks* become spans, and every
|
||||||
|
//! other one is dropped rather than shown: the rest move a cursor around a
|
||||||
|
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
||||||
|
//! document.
|
||||||
|
//!
|
||||||
|
//! A carriage return is honoured the way a terminal honours it: what was
|
||||||
|
//! written since the last line break is thrown away and the line starts
|
||||||
|
//! again. That is what makes a progress bar show its final state rather
|
||||||
|
//! than every state it passed through.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||||
|
/// because the one place that needs partial transparency (dimming) says so
|
||||||
|
/// with a separate flag rather than baking it into the colour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Rgb {
|
||||||
|
pub r: u8,
|
||||||
|
pub g: u8,
|
||||||
|
pub b: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rgb {
|
||||||
|
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||||
|
Self { r, g, b }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sixteen colours a terminal program names, and the two it assumes.
|
||||||
|
///
|
||||||
|
/// Its own palette rather than the syntax one: a program that prints in red
|
||||||
|
/// has chosen red, where a highlighter's colours are this app's reading of
|
||||||
|
/// somebody else's code.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnsiPalette {
|
||||||
|
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||||
|
pub colours: [Rgb; 16],
|
||||||
|
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||||
|
pub foreground: Rgb,
|
||||||
|
/// What the text sits on, needed for reverse video.
|
||||||
|
pub background: Rgb,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One span's worth of styling. `None` fields mean "unspecified", the same
|
||||||
|
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||||
|
pub struct Style {
|
||||||
|
pub color: Option<Rgb>,
|
||||||
|
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
|
||||||
|
pub alpha: Option<f32>,
|
||||||
|
pub background: Option<Rgb>,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub underline: bool,
|
||||||
|
pub strikethrough: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
||||||
|
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct StyledText {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<(Range<usize>, Style)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StyledText {
|
||||||
|
fn plain(text: String) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
spans: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESC: char = '\u{1B}';
|
||||||
|
const BELL: char = '\u{7}';
|
||||||
|
|
||||||
|
/// [text] with its terminal styling applied and everything else taken out;
|
||||||
|
/// see the module doc.
|
||||||
|
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||||
|
// The common case by a long way -- nothing to do, and nothing allocated
|
||||||
|
// to find that out.
|
||||||
|
if !text.contains(ESC) && !text.contains('\r') {
|
||||||
|
return StyledText::plain(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
|
||||||
|
let mut sgr = Sgr::PLAIN;
|
||||||
|
let mut at = 0usize;
|
||||||
|
let mut plain = String::new();
|
||||||
|
|
||||||
|
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
|
||||||
|
if !plain.is_empty() {
|
||||||
|
runs.push((std::mem::take(plain), sgr.span(palette)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while at < chars.len() {
|
||||||
|
let c = chars[at];
|
||||||
|
if c == ESC {
|
||||||
|
flush(&mut plain, sgr, &mut runs);
|
||||||
|
at = skip_escape(&chars, at, |params, final_byte| {
|
||||||
|
if final_byte == 'm' {
|
||||||
|
sgr = sgr.apply(params, palette);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||||
|
// A bare carriage return rewrites the line. One before a newline
|
||||||
|
// is the other half of a Windows line ending: it rewrites
|
||||||
|
// nothing, and it is dropped rather than kept, since that pair
|
||||||
|
// is one line break.
|
||||||
|
flush(&mut plain, sgr, &mut runs);
|
||||||
|
drop_line(&mut runs);
|
||||||
|
at += 1;
|
||||||
|
} else if c == '\r' {
|
||||||
|
at += 1;
|
||||||
|
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||||
|
// Everything printable, plus the two control characters that are
|
||||||
|
// layout rather than terminal commands. A stray bell or
|
||||||
|
// backspace goes for the same reason a cursor move does.
|
||||||
|
plain.push(c);
|
||||||
|
at += 1;
|
||||||
|
} else {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut plain, sgr, &mut runs);
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (run_text, style) in runs {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(&run_text);
|
||||||
|
if let Some(style) = style {
|
||||||
|
spans.push((start..out.len(), style));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StyledText { text: out, spans }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throws away everything written since the last line break, as a carriage
|
||||||
|
/// return does.
|
||||||
|
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||||
|
while let Some((text, style)) = runs.pop() {
|
||||||
|
if let Some(break_at) = text.rfind('\n') {
|
||||||
|
runs.push((text[..=break_at].to_string(), style));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes that end a CSI sequence.
|
||||||
|
fn is_csi_final(c: char) -> bool {
|
||||||
|
('@'..='~').contains(&c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps over the escape sequence starting at `at`, reporting a CSI's
|
||||||
|
/// parameters and final byte. One reader for every kind, because the point
|
||||||
|
/// is to *leave* them all behind: a sequence this did not recognise would
|
||||||
|
/// otherwise have its body printed as ordinary text. Three shapes -- the CSI
|
||||||
|
/// (`ESC [ ... letter`), the string escapes which run to a terminator, and
|
||||||
|
/// the two-character ones.
|
||||||
|
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
|
||||||
|
let Some(&next) = chars.get(at + 1) else {
|
||||||
|
return at + 1;
|
||||||
|
};
|
||||||
|
match next {
|
||||||
|
'[' => {
|
||||||
|
let mut end = at + 2;
|
||||||
|
while end < chars.len() && !is_csi_final(chars[end]) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end >= chars.len() {
|
||||||
|
// Cut off mid-sequence, which is what a stream that has not
|
||||||
|
// finished arriving looks like: drop the fragment rather
|
||||||
|
// than printing it, and the whole sequence arrives with the
|
||||||
|
// next delta.
|
||||||
|
chars.len()
|
||||||
|
} else {
|
||||||
|
let params: String = chars[at + 2..end].iter().collect();
|
||||||
|
on_csi(¶ms, chars[end]);
|
||||||
|
end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
']' | 'P' | 'X' | '^' | '_' => {
|
||||||
|
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
||||||
|
// allows after an OSC.
|
||||||
|
let mut end = at + 2;
|
||||||
|
while end < chars.len() {
|
||||||
|
if chars[end] == BELL {
|
||||||
|
return end + 1;
|
||||||
|
}
|
||||||
|
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
|
||||||
|
return end + 2;
|
||||||
|
}
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
chars.len()
|
||||||
|
}
|
||||||
|
_ => at + 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
struct Sgr {
|
||||||
|
fg: Option<Rgb>,
|
||||||
|
bg: Option<Rgb>,
|
||||||
|
bold: bool,
|
||||||
|
dim: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strike: bool,
|
||||||
|
reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
||||||
|
const DIM_ALPHA: f32 = 0.65;
|
||||||
|
|
||||||
|
impl Sgr {
|
||||||
|
const PLAIN: Sgr = Sgr {
|
||||||
|
fg: None,
|
||||||
|
bg: None,
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strike: false,
|
||||||
|
reverse: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
||||||
|
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||||
|
if *self == Sgr::PLAIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let front = if self.reverse {
|
||||||
|
Some(self.bg.unwrap_or(palette.background))
|
||||||
|
} else {
|
||||||
|
self.fg
|
||||||
|
};
|
||||||
|
let back = if self.reverse {
|
||||||
|
Some(self.fg.unwrap_or(palette.foreground))
|
||||||
|
} else {
|
||||||
|
self.bg
|
||||||
|
};
|
||||||
|
// Dim has to have a colour to dim, so where none was named it dims
|
||||||
|
// the ordinary one.
|
||||||
|
let stated = front.or(if self.dim {
|
||||||
|
Some(palette.foreground)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
});
|
||||||
|
Some(Style {
|
||||||
|
color: stated,
|
||||||
|
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
|
||||||
|
background: back,
|
||||||
|
bold: self.bold,
|
||||||
|
italic: self.italic,
|
||||||
|
underline: self.underline,
|
||||||
|
strikethrough: self.strike,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This state with `params` applied -- one `ESC[...m`, which carries any
|
||||||
|
/// number of them.
|
||||||
|
///
|
||||||
|
/// A code this does not model is ignored rather than reset from: the
|
||||||
|
/// program meant something by it, and starting again would also drop
|
||||||
|
/// the codes beside it that are understood.
|
||||||
|
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||||
|
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
||||||
|
// zero too.
|
||||||
|
let codes: Vec<i64> = params
|
||||||
|
.split(';')
|
||||||
|
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||||
|
.collect();
|
||||||
|
let mut state = *self;
|
||||||
|
let mut at = 0usize;
|
||||||
|
while at < codes.len() {
|
||||||
|
let code = codes[at];
|
||||||
|
state = match code {
|
||||||
|
0 => Sgr::PLAIN,
|
||||||
|
1 => Sgr {
|
||||||
|
bold: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
2 => Sgr { dim: true, ..state },
|
||||||
|
3 => Sgr {
|
||||||
|
italic: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
4 => Sgr {
|
||||||
|
underline: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
7 => Sgr {
|
||||||
|
reverse: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
9 => Sgr {
|
||||||
|
strike: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
21 | 22 => Sgr {
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
23 => Sgr {
|
||||||
|
italic: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
24 => Sgr {
|
||||||
|
underline: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
27 => Sgr {
|
||||||
|
reverse: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
29 => Sgr {
|
||||||
|
strike: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
30..=37 => Sgr {
|
||||||
|
fg: Some(palette.colours[(code - 30) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
90..=97 => Sgr {
|
||||||
|
fg: Some(palette.colours[(code - 90 + 8) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
40..=47 => Sgr {
|
||||||
|
bg: Some(palette.colours[(code - 40) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
100..=107 => Sgr {
|
||||||
|
bg: Some(palette.colours[(code - 100 + 8) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
39 => Sgr { fg: None, ..state },
|
||||||
|
49 => Sgr { bg: None, ..state },
|
||||||
|
38 | 48 => {
|
||||||
|
let (colour, last) = extended_colour(&codes, at, palette);
|
||||||
|
at = last;
|
||||||
|
if code == 38 {
|
||||||
|
Sgr {
|
||||||
|
fg: colour,
|
||||||
|
..state
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Sgr {
|
||||||
|
bg: colour,
|
||||||
|
..state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => state,
|
||||||
|
};
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
|
||||||
|
/// last parameter.
|
||||||
|
///
|
||||||
|
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
|
||||||
|
/// one. The first sixteen of that table are the palette's own, so a program
|
||||||
|
/// asking for "colour 1" through either spelling gets the same red.
|
||||||
|
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||||
|
match codes.get(at + 1) {
|
||||||
|
Some(&5) => match codes.get(at + 2) {
|
||||||
|
None => (None, at + 1),
|
||||||
|
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
|
||||||
|
},
|
||||||
|
Some(&2) => {
|
||||||
|
let r = codes.get(at + 2);
|
||||||
|
let g = codes.get(at + 3);
|
||||||
|
let b = codes.get(at + 4);
|
||||||
|
match (r, g, b) {
|
||||||
|
(Some(&r), Some(&g), Some(&b)) => (
|
||||||
|
Some(Rgb::new(
|
||||||
|
r.clamp(0, 255) as u8,
|
||||||
|
g.clamp(0, 255) as u8,
|
||||||
|
b.clamp(0, 255) as u8,
|
||||||
|
)),
|
||||||
|
at + 4,
|
||||||
|
),
|
||||||
|
_ => (None, at + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (None, at + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
||||||
|
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||||
|
|
||||||
|
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
||||||
|
/// grey ramp.
|
||||||
|
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||||
|
if n < 0 {
|
||||||
|
palette.foreground
|
||||||
|
} else if n < 16 {
|
||||||
|
palette.colours[n as usize]
|
||||||
|
} else if n < 232 {
|
||||||
|
let i = (n - 16) as usize;
|
||||||
|
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||||
|
} else if n < 256 {
|
||||||
|
let grey = (8 + (n - 232) * 10) as u8;
|
||||||
|
Rgb::new(grey, grey, grey)
|
||||||
|
} else {
|
||||||
|
palette.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
||||||
|
/// white foreground, black background.
|
||||||
|
fn palette() -> AnsiPalette {
|
||||||
|
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||||
|
for (i, c) in colours.iter_mut().enumerate() {
|
||||||
|
*c = Rgb::new(i as u8, 0, 0);
|
||||||
|
}
|
||||||
|
AnsiPalette {
|
||||||
|
colours,
|
||||||
|
foreground: Rgb::new(255, 255, 255),
|
||||||
|
background: Rgb::new(0, 0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn styled(text: &str) -> StyledText {
|
||||||
|
ansi_styled(text, &palette())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The style covering the first character of `word`, or `None` where
|
||||||
|
/// nothing styles it.
|
||||||
|
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||||
|
let out = styled(text);
|
||||||
|
let at = out
|
||||||
|
.text
|
||||||
|
.find(word)
|
||||||
|
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
|
||||||
|
out.spans
|
||||||
|
.iter()
|
||||||
|
.find(|(range, _)| range.contains(&at))
|
||||||
|
.map(|(_, style)| *style)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
|
||||||
|
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
|
||||||
|
assert_eq!(styled(&text).text, "plain red plain");
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&text, "red").unwrap().color,
|
||||||
|
Some(Rgb::new(1, 0, 0))
|
||||||
|
);
|
||||||
|
assert!(style_over(&text, "plain").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(9, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
|
||||||
|
Some(Rgb::new(4, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(1, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(0, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(255, 255, 255))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
|
||||||
|
.unwrap()
|
||||||
|
.color,
|
||||||
|
Some(Rgb::new(10, 20, 30))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||||
|
// A cursor move, an erase, an OSC window title with its bell, and a
|
||||||
|
// bare two-character escape.
|
||||||
|
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||||
|
assert_eq!(styled(&text).text, "abcde");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
|
||||||
|
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
|
||||||
|
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
|
||||||
|
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unstyled_text_costs_no_spans_at_all() {
|
||||||
|
assert_eq!(styled("nothing to do here").spans.len(), 0);
|
||||||
|
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
||||||
|
//! module doc for the table); the SSE half is [`crate::event_stream`].
|
||||||
|
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
||||||
|
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
||||||
|
//! which do not.
|
||||||
|
//!
|
||||||
|
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
||||||
|
//! crate, and anything built on it, can be tested against a fake one with
|
||||||
|
//! no server involved. [`UreqTransport`] is the only real implementation.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// A request that did not produce what it asked for, carrying the server's
|
||||||
|
/// own wording where it sent some.
|
||||||
|
///
|
||||||
|
/// `status` is the HTTP status where there was a response at all, and
|
||||||
|
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||||
|
/// `Api.kt`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub message: String,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
|
/// A request body to send, in whichever of the two shapes the surface
|
||||||
|
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
||||||
|
pub enum Body {
|
||||||
|
Json(Value),
|
||||||
|
Bytes {
|
||||||
|
content_type: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a transport hands back for a REST call: the status and the body
|
||||||
|
/// read whole. A streamed body ([`Transport::stream`]) is a different
|
||||||
|
/// method because its whole point is not reading it whole.
|
||||||
|
pub struct RawResponse {
|
||||||
|
pub status: u16,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The network boundary this crate's pure logic is kept out from behind.
|
||||||
|
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
||||||
|
pub trait Transport: Send + Sync {
|
||||||
|
/// One request/response call -- everything but the long-lived SSE GETs.
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError>;
|
||||||
|
|
||||||
|
/// Opens `path` and answers a reader over the response body, for a
|
||||||
|
/// caller that reads it as a stream rather than all at once (the SSE
|
||||||
|
/// connections in [`crate::event_stream`]). Fails the same way
|
||||||
|
/// [`Transport::request`] does for a non-2xx response.
|
||||||
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
|
||||||
|
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
|
||||||
|
/// field means and why `setup` is never shown.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub setup: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keeps_own_transcript: bool,
|
||||||
|
pub setup_name: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub imported: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub notify: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_image_edge: Option<u32>,
|
||||||
|
pub status: String,
|
||||||
|
pub last_activity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
||||||
|
/// built on it. Holds no state of its own beyond the transport -- the
|
||||||
|
/// session id or setup id a call is about is a parameter, per this
|
||||||
|
/// project's "ask for the least you need".
|
||||||
|
pub struct ApiClient<T: Transport> {
|
||||||
|
transport: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> ApiClient<T> {
|
||||||
|
pub fn new(transport: T) -> Self {
|
||||||
|
Self { transport }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> Result<R, ApiError> {
|
||||||
|
let raw = self.transport.request(method, path, body.map(Body::Json))?;
|
||||||
|
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
|
||||||
|
message: format!("Reached the server but couldn't read its response ({e})"),
|
||||||
|
status: Some(raw.status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
|
||||||
|
self.transport.request(method, path, body.map(Body::Json))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
|
||||||
|
self.json_request("GET", "/sessions", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
|
||||||
|
self.json_request("GET", &format!("/sessions/{session_id}"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_message(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
text: &str,
|
||||||
|
attachment_ids: &[String],
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/message"),
|
||||||
|
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/unqueue"),
|
||||||
|
Some(serde_json::json!({ "messageId": message_id })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn answer_question(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
question_id: &str,
|
||||||
|
answers: &[String],
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/answer"),
|
||||||
|
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/title"),
|
||||||
|
Some(serde_json::json!({ "title": title })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/cwd"),
|
||||||
|
Some(serde_json::json!({ "cwd": cwd })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/model"),
|
||||||
|
Some(serde_json::json!({ "model": model })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_permission_mode(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
mode: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/permission-mode"),
|
||||||
|
Some(serde_json::json!({ "permissionMode": mode })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/notify"),
|
||||||
|
Some(serde_json::json!({ "notify": notify })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/command"),
|
||||||
|
Some(serde_json::json!({ "text": text })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
|
||||||
|
let path = if delete_foreign {
|
||||||
|
format!("/sessions/{session_id}?deleteForeign=true")
|
||||||
|
} else {
|
||||||
|
format!("/sessions/{session_id}")
|
||||||
|
};
|
||||||
|
self.empty_request("DELETE", &path, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of transcript history. `before` is the newest-first cursor
|
||||||
|
/// (server default is "the newest page" when absent, which a caller
|
||||||
|
/// gets by passing `None`); the events themselves are handed back as
|
||||||
|
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
||||||
|
/// out of this method's signature so a caller that only wants the raw
|
||||||
|
/// lines (for the transcript cache) is not forced to parse them.
|
||||||
|
pub fn fetch_transcript_page(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: u32,
|
||||||
|
coalesce: bool,
|
||||||
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
|
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||||
|
if let Some(before) = before {
|
||||||
|
path.push_str(&format!("&before={before}"));
|
||||||
|
}
|
||||||
|
if coalesce {
|
||||||
|
path.push_str("&coalesce=true");
|
||||||
|
}
|
||||||
|
self.json_request("GET", &path, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||||
|
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
||||||
|
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
||||||
|
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
||||||
|
/// store -- the server's certificate is self-signed on purpose (see
|
||||||
|
/// `wg-app-link`).
|
||||||
|
pub struct UreqTransport {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UreqTransport {
|
||||||
|
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
||||||
|
/// exactly as read from `certs/ca.pem`.
|
||||||
|
pub fn new(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
token: impl Into<String>,
|
||||||
|
ca_pem: &[u8],
|
||||||
|
) -> Result<Self, ApiError> {
|
||||||
|
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
|
||||||
|
message: format!("The pinned CA certificate could not be read: {e}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
let tls_config = ureq::tls::TlsConfig::builder()
|
||||||
|
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
|
||||||
|
.build();
|
||||||
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||||
|
.tls_config(tls_config)
|
||||||
|
// Read the body ourselves on every status, the way
|
||||||
|
// `requestFromServer` does: the server's own error wording is
|
||||||
|
// in the body of a 4xx/5xx, and the default behaviour throws
|
||||||
|
// it away before this code can read it.
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||||
|
.build()
|
||||||
|
.into();
|
||||||
|
Ok(Self {
|
||||||
|
agent,
|
||||||
|
base_url: base_url.into(),
|
||||||
|
token: token.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.base_url, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for UreqTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
let url = self.url(path);
|
||||||
|
let auth = format!("Bearer {}", self.token);
|
||||||
|
let mut builder = ureq::http::Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(&url)
|
||||||
|
.header("Authorization", &auth);
|
||||||
|
let response = match body {
|
||||||
|
None => builder
|
||||||
|
.body(())
|
||||||
|
.map_err(ureq::Error::from)
|
||||||
|
.and_then(|req| self.agent.run(req)),
|
||||||
|
Some(Body::Json(value)) => {
|
||||||
|
builder = builder.header("Content-Type", "application/json");
|
||||||
|
builder
|
||||||
|
.body(serde_json::to_vec(&value).unwrap_or_default())
|
||||||
|
.map_err(ureq::Error::from)
|
||||||
|
.and_then(|req| self.agent.run(req))
|
||||||
|
}
|
||||||
|
Some(Body::Bytes {
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
}) => {
|
||||||
|
builder = builder.header("Content-Type", content_type);
|
||||||
|
builder
|
||||||
|
.body(bytes)
|
||||||
|
.map_err(ureq::Error::from)
|
||||||
|
.and_then(|req| self.agent.run(req))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let mut body = Vec::new();
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.as_reader()
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.map_err(|e| ApiError {
|
||||||
|
message: format!("Reached {url} but couldn't read its response ({e})"),
|
||||||
|
status: Some(status),
|
||||||
|
})?;
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
return Err(response_error(status, &body, path));
|
||||||
|
}
|
||||||
|
Ok(RawResponse { status, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||||
|
let url = self.url(path);
|
||||||
|
let auth = format!("Bearer {}", self.token);
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", &auth)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
// No read timeout: between events there is nothing to read for
|
||||||
|
// as long as the thing being followed is idle, mirroring
|
||||||
|
// `EventStream.kt`'s `readTimeout = 0`.
|
||||||
|
.config()
|
||||||
|
.timeout_recv_response(None)
|
||||||
|
.build()
|
||||||
|
.call();
|
||||||
|
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if status != 200 {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
let _ = response.body_mut().as_reader().read_to_end(&mut body);
|
||||||
|
return Err(response_error(status, &body, path));
|
||||||
|
}
|
||||||
|
Ok(Box::new(response.into_body().into_reader()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
||||||
|
ApiError {
|
||||||
|
message: format!(
|
||||||
|
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
|
||||||
|
device able to reach that address (WireGuard up)? [{path}]"
|
||||||
|
),
|
||||||
|
status: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
||||||
|
/// for the reader rather than a diagnostic -- see this project's UI rule
|
||||||
|
/// about shortening a failure in one place rather than at each display site.
|
||||||
|
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||||
|
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||||
|
let message = if status == 401 {
|
||||||
|
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
|
||||||
|
rotate with --rotate-token and scan the new one)."
|
||||||
|
.to_string()
|
||||||
|
} else if detail.is_empty() {
|
||||||
|
format!("Server returned HTTP {status} for {path}")
|
||||||
|
} else {
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
ApiError {
|
||||||
|
message,
|
||||||
|
status: Some(status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// A transport with no network at all, for the pure-logic tests this
|
||||||
|
/// module can run without a server.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeTransport {
|
||||||
|
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeTransport {
|
||||||
|
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
|
||||||
|
self.responses.lock().unwrap().push((
|
||||||
|
method.to_string(),
|
||||||
|
path.to_string(),
|
||||||
|
RawResponse {
|
||||||
|
status,
|
||||||
|
body: body.as_bytes().to_vec(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FakeTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
let mut responses = self.responses.lock().unwrap();
|
||||||
|
let index = responses
|
||||||
|
.iter()
|
||||||
|
.position(|(m, p, _)| m == method && p == path)
|
||||||
|
.ok_or_else(|| ApiError {
|
||||||
|
message: format!("no fake response for {method} {path}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
let (_, _, response) = responses.remove(index);
|
||||||
|
if !(200..300).contains(&response.status) {
|
||||||
|
return Err(response_error(response.status, &response.body, path));
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||||
|
Ok(Box::new(Cursor::new(Vec::new())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_sessions_parses_the_list() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond(
|
||||||
|
"GET",
|
||||||
|
"/sessions",
|
||||||
|
200,
|
||||||
|
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
|
||||||
|
"title":"hi","status":"idle","lastActivity":1.0}]"#,
|
||||||
|
);
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let sessions = client.fetch_sessions().unwrap();
|
||||||
|
assert_eq!(sessions.len(), 1);
|
||||||
|
assert_eq!(sessions[0].id, "s1");
|
||||||
|
assert_eq!(sessions[0].setup_name, "desktop");
|
||||||
|
// Defaults for fields the server omits.
|
||||||
|
assert!(sessions[0].notify);
|
||||||
|
assert_eq!(sessions[0].model, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let err = client.interrupt_session("s1").unwrap_err();
|
||||||
|
assert!(err.message.contains("Re-enroll"));
|
||||||
|
assert_eq!(err.status, Some(401));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond("POST", "/sessions/s1/stop", 500, "");
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let err = client.stop_session("s1").unwrap_err();
|
||||||
|
assert!(err.message.contains("500"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond(
|
||||||
|
"POST",
|
||||||
|
"/sessions/s1/cwd",
|
||||||
|
409,
|
||||||
|
"that path does not exist on this machine",
|
||||||
|
);
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
|
||||||
|
assert_eq!(err.message, "that path does not exist on this machine");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! What a Rust client needs to reach one enrolled server: host, port and
|
||||||
|
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
||||||
|
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
||||||
|
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
||||||
|
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
||||||
|
//! the same text a phone would scan as a QR, with no second format
|
||||||
|
//! invented for it (RUST.md's E4).
|
||||||
|
//!
|
||||||
|
//! What this type deliberately does not decide: where it is persisted, and
|
||||||
|
//! under what file permissions. A phone seals its token in the Android
|
||||||
|
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
||||||
|
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
||||||
|
//! never in the repo). Both are caller-specific, so they stay out of this
|
||||||
|
//! crate per the code rules' "ask for the least you need" -- see
|
||||||
|
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||||
|
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
||||||
|
/// 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
|
||||||
|
/// client is told a path).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EnrolledServer {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrolledServer {
|
||||||
|
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
||||||
|
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
||||||
|
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
||||||
|
/// contain `+`, which turns into a space if left to a naive splitter.
|
||||||
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||||
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"'{link}' has no query string (expected \
|
||||||
|
aiapp://enroll?host=...&port=...&token=...)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut host = None;
|
||||||
|
let mut port = None;
|
||||||
|
let mut token = None;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = percent_decode(value);
|
||||||
|
match key {
|
||||||
|
"host" => host = Some(value),
|
||||||
|
"port" => port = Some(value),
|
||||||
|
"token" => token = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
||||||
|
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
||||||
|
let port: u16 = port_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||||
|
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||||
|
|
||||||
|
Ok(Self { host, port, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a `client_core::api::UreqTransport` reaches this server.
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("https://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%'
|
||||||
|
&& i + 2 < bytes.len()
|
||||||
|
&& let Ok(byte) =
|
||||||
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||||
|
{
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_host_port_and_token() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
server,
|
||||||
|
EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "abcDEF123".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_order_does_not_matter() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(server.host, "example.com");
|
||||||
|
assert_eq!(server.port, 443);
|
||||||
|
assert_eq!(server.token, "tok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_percent_encoded_token_is_decoded() {
|
||||||
|
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
||||||
|
// otherwise arrive as a space.
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||||
|
assert_eq!(server.token, "a+b/c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_field_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("token"),
|
||||||
|
"error should name the missing field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_numeric_port_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("port"),
|
||||||
|
"error should name the offending field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! The SSE half of the API: one long-lived GET per open session screen,
|
||||||
|
//! replaying the transcript after a cursor and then following it live.
|
||||||
|
//! Ported from `app/.../EventStream.kt`; the framing itself is
|
||||||
|
//! [`crate::sse`].
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
|
use crate::api::{ApiError, Transport};
|
||||||
|
use crate::sse::SseReader;
|
||||||
|
|
||||||
|
/// The frame name the server uses to say a cursor was too far behind to
|
||||||
|
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
||||||
|
const RESET_EVENT: &str = "reset";
|
||||||
|
|
||||||
|
/// One frame of a session's event stream, folded from the wire shape the
|
||||||
|
/// caller needs to act on -- mirroring what `EventStream.kt`'s three
|
||||||
|
/// callbacks were for, as a single enum instead, since Rust has no
|
||||||
|
/// equivalent of handing three closures to one blocking call.
|
||||||
|
pub enum StreamItem {
|
||||||
|
/// The connection was accepted; the measured moment the stream is live
|
||||||
|
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
|
||||||
|
/// event, is what clears a previous failure on screen).
|
||||||
|
Open,
|
||||||
|
/// The cursor was too far behind to continue from: everything already
|
||||||
|
/// displayed is stale, and the events that follow are a fresh window.
|
||||||
|
/// Arrives before those events, so a caller that clears on it stays in
|
||||||
|
/// order.
|
||||||
|
Reset,
|
||||||
|
/// One event, as both the raw line the transcript cache stores and the
|
||||||
|
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
|
||||||
|
/// line, so both travel together rather than being parsed twice from
|
||||||
|
/// two call sites.
|
||||||
|
Event { raw: String, event: SeqEvent },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for
|
||||||
|
/// each [`StreamItem`] until the connection drops or `on_item` asks to
|
||||||
|
/// stop (by returning `false`). Reconnecting -- with the last seq seen as
|
||||||
|
/// the new cursor -- is the caller's job, same as in the Kotlin version.
|
||||||
|
pub fn follow_session_events(
|
||||||
|
transport: &dyn Transport,
|
||||||
|
session_id: &str,
|
||||||
|
after: u64,
|
||||||
|
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let path = format!("/sessions/{session_id}/events?after={after}");
|
||||||
|
let body = transport.stream(&path)?;
|
||||||
|
if !on_item(StreamItem::Open) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut lines = BufReader::new(body).lines();
|
||||||
|
let mut reader = SseReader::new();
|
||||||
|
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
|
||||||
|
message: format!("Can't reach the server -- retrying. ({e})"),
|
||||||
|
status: None,
|
||||||
|
})? {
|
||||||
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// A named frame carries no payload and a data frame has no name.
|
||||||
|
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||||
|
if !on_item(StreamItem::Reset) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else if !frame.data.is_empty() {
|
||||||
|
let event: SeqEvent = serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||||
|
message: format!("The server sent an event this build couldn't parse: {e}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
if !on_item(StreamItem::Event {
|
||||||
|
raw: frame.data,
|
||||||
|
event,
|
||||||
|
}) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::{Body, RawResponse};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
struct FixtureTransport {
|
||||||
|
body: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FixtureTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
_path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
unimplemented!("this fixture only serves a stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
|
||||||
|
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_and_a_reset_frame_are_told_apart() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "event:reset\n\ndata:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
|
||||||
|
};
|
||||||
|
let mut items = Vec::new();
|
||||||
|
follow_session_events(&transport, "s1", 0, |item| {
|
||||||
|
items.push(match item {
|
||||||
|
StreamItem::Open => "open".to_string(),
|
||||||
|
StreamItem::Reset => "reset".to_string(),
|
||||||
|
StreamItem::Event { event, .. } => format!("event:{}", event.seq),
|
||||||
|
});
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(items, vec!["open", "reset", "event:1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_caller_can_stop_early() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "data:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n\
|
||||||
|
data:{\"seq\":2,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
|
||||||
|
};
|
||||||
|
let mut count = 0;
|
||||||
|
follow_session_events(&transport, "s1", 0, |item| {
|
||||||
|
if matches!(item, StreamItem::Event { .. }) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
count < 1
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
||||||
|
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
||||||
|
//! why nearly every language is a row of data read by one shared scanner,
|
||||||
|
//! with Markdown the one exception (`super::markdown`).
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Language {
|
||||||
|
C,
|
||||||
|
Coffeescript,
|
||||||
|
Cpp,
|
||||||
|
Csharp,
|
||||||
|
Dart,
|
||||||
|
Fish,
|
||||||
|
Go,
|
||||||
|
Java,
|
||||||
|
Javascript,
|
||||||
|
Json,
|
||||||
|
Kotlin,
|
||||||
|
Markdown,
|
||||||
|
Perl,
|
||||||
|
Php,
|
||||||
|
Python,
|
||||||
|
Ron,
|
||||||
|
Ruby,
|
||||||
|
Rust,
|
||||||
|
Shell,
|
||||||
|
Swift,
|
||||||
|
Toml,
|
||||||
|
Typescript,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||||
|
/// (`Language.entries`).
|
||||||
|
pub const ALL: [Language; 22] = [
|
||||||
|
Language::C,
|
||||||
|
Language::Coffeescript,
|
||||||
|
Language::Cpp,
|
||||||
|
Language::Csharp,
|
||||||
|
Language::Dart,
|
||||||
|
Language::Fish,
|
||||||
|
Language::Go,
|
||||||
|
Language::Java,
|
||||||
|
Language::Javascript,
|
||||||
|
Language::Json,
|
||||||
|
Language::Kotlin,
|
||||||
|
Language::Markdown,
|
||||||
|
Language::Perl,
|
||||||
|
Language::Php,
|
||||||
|
Language::Python,
|
||||||
|
Language::Ron,
|
||||||
|
Language::Ruby,
|
||||||
|
Language::Rust,
|
||||||
|
Language::Shell,
|
||||||
|
Language::Swift,
|
||||||
|
Language::Toml,
|
||||||
|
Language::Typescript,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`super::scan`] needs to know about one language -- data, not code,
|
||||||
|
/// so that adding a language is a row here rather than a branch anywhere.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Rules {
|
||||||
|
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
||||||
|
/// anything else.
|
||||||
|
pub keywords: HashSet<&'static str>,
|
||||||
|
/// Tokens that open a comment running to the end of the line.
|
||||||
|
pub line_comments: Vec<&'static str>,
|
||||||
|
/// Whether `line_comments` count only at the start of a word. The shells
|
||||||
|
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
||||||
|
pub line_comments_at_word_start: bool,
|
||||||
|
pub block_comment: Option<BlockComment>,
|
||||||
|
/// The string forms. The longest opener that matches wins, so `"""` is
|
||||||
|
/// tried before `"`.
|
||||||
|
pub quotes: Vec<Quote>,
|
||||||
|
pub attributes: Attributes,
|
||||||
|
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
||||||
|
pub raw_strings: bool,
|
||||||
|
/// Rust: `'` opens a character literal only when a backslash or one
|
||||||
|
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||||
|
pub lifetimes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BlockComment {
|
||||||
|
pub open: &'static str,
|
||||||
|
pub close: &'static str,
|
||||||
|
pub nests: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One string form. `escapes` is whether a backslash escapes the closer
|
||||||
|
/// (and itself).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Quote {
|
||||||
|
pub open: &'static str,
|
||||||
|
pub close: &'static str,
|
||||||
|
pub escapes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What opens a metadata span, of the shapes that exist across these languages.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum Attributes {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
||||||
|
AtWord,
|
||||||
|
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
||||||
|
HashBracket,
|
||||||
|
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
||||||
|
HashLine,
|
||||||
|
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
||||||
|
LineBracket,
|
||||||
|
}
|
||||||
|
|
||||||
|
const C_STYLE: BlockComment = BlockComment {
|
||||||
|
open: "/*",
|
||||||
|
close: "*/",
|
||||||
|
nests: false,
|
||||||
|
};
|
||||||
|
const NESTING: BlockComment = BlockComment {
|
||||||
|
open: "/*",
|
||||||
|
close: "*/",
|
||||||
|
nests: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOUBLE: Quote = Quote {
|
||||||
|
open: "\"",
|
||||||
|
close: "\"",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
const SINGLE: Quote = Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
const TRIPLE_DOUBLE: Quote = Quote {
|
||||||
|
open: "\"\"\"",
|
||||||
|
close: "\"\"\"",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
const TRIPLE_SINGLE: Quote = Quote {
|
||||||
|
open: "'''",
|
||||||
|
close: "'''",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn words(list: &'static str) -> HashSet<&'static str> {
|
||||||
|
list.split_whitespace().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rules for one language. A `match` rather than a lazily-built map --
|
||||||
|
/// there is no once-per-process cost worth paying for in a language table
|
||||||
|
/// this small, and it sidesteps the Kotlin version's own workaround for
|
||||||
|
/// property initialization order.
|
||||||
|
pub fn rules_for(language: Language) -> Rules {
|
||||||
|
match language {
|
||||||
|
Language::C => Rules {
|
||||||
|
keywords: words(KEYWORDS_C),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::HashLine,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Cpp => Rules {
|
||||||
|
keywords: words(KEYWORDS_CPP),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::HashLine,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Csharp => Rules {
|
||||||
|
keywords: words(KEYWORDS_CSHARP),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
// `###` opens and closes a block comment and `#` opens a line one,
|
||||||
|
// which is why the scanner tries the block opener first.
|
||||||
|
Language::Coffeescript => Rules {
|
||||||
|
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
block_comment: Some(BlockComment {
|
||||||
|
open: "###",
|
||||||
|
close: "###",
|
||||||
|
nests: false,
|
||||||
|
}),
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Dart => Rules {
|
||||||
|
keywords: words(KEYWORDS_DART),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Fish => Rules {
|
||||||
|
keywords: words(KEYWORDS_FISH),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
line_comments_at_word_start: true,
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Go => Rules {
|
||||||
|
keywords: words(KEYWORDS_GO),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
Quote {
|
||||||
|
open: "`",
|
||||||
|
close: "`",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Java => Rules {
|
||||||
|
keywords: words(KEYWORDS_JAVA),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Javascript => Rules {
|
||||||
|
keywords: words(KEYWORDS_JAVASCRIPT),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
Quote {
|
||||||
|
open: "`",
|
||||||
|
close: "`",
|
||||||
|
escapes: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Json => Rules {
|
||||||
|
keywords: words(KEYWORDS_JSON),
|
||||||
|
quotes: vec![DOUBLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Kotlin => Rules {
|
||||||
|
keywords: words(KEYWORDS_KOTLIN),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![
|
||||||
|
Quote {
|
||||||
|
open: "\"\"\"",
|
||||||
|
close: "\"\"\"",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Perl => Rules {
|
||||||
|
keywords: words(KEYWORDS_PERL),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Php => Rules {
|
||||||
|
keywords: words(KEYWORDS_PHP),
|
||||||
|
line_comments: vec!["//", "#"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Python => Rules {
|
||||||
|
keywords: words(KEYWORDS_PYTHON),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Ron => Rules {
|
||||||
|
keywords: words(KEYWORDS_RON),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::HashBracket,
|
||||||
|
raw_strings: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Ruby => Rules {
|
||||||
|
keywords: words(KEYWORDS_RUBY),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Rust => Rules {
|
||||||
|
keywords: words(KEYWORDS_RUST),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
// No `'` here: `lifetimes` decides when one opens a character literal.
|
||||||
|
quotes: vec![DOUBLE],
|
||||||
|
attributes: Attributes::HashBracket,
|
||||||
|
raw_strings: true,
|
||||||
|
lifetimes: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Shell => Rules {
|
||||||
|
keywords: words(KEYWORDS_SHELL),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
line_comments_at_word_start: true,
|
||||||
|
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Swift => Rules {
|
||||||
|
keywords: words(KEYWORDS_SWIFT),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Toml => Rules {
|
||||||
|
keywords: words(KEYWORDS_TOML),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![
|
||||||
|
TRIPLE_DOUBLE,
|
||||||
|
Quote {
|
||||||
|
open: "'''",
|
||||||
|
close: "'''",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
DOUBLE,
|
||||||
|
Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: Attributes::LineBracket,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Typescript => Rules {
|
||||||
|
keywords: words(KEYWORDS_TYPESCRIPT),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
Quote {
|
||||||
|
open: "`",
|
||||||
|
close: "`",
|
||||||
|
escapes: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
||||||
|
Language::Markdown => Rules::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The keyword sets. Every list below other than RON, TOML, fish and JSON
|
||||||
|
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
|
||||||
|
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
||||||
|
// plain here either.
|
||||||
|
|
||||||
|
const KEYWORDS_C: &str =
|
||||||
|
"auto break case char const continue default do double else enum extern float for goto if
|
||||||
|
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||||
|
void volatile while";
|
||||||
|
|
||||||
|
const KEYWORDS_CPP: &str =
|
||||||
|
"asm auto bool break case catch char class const const_cast continue default delete do
|
||||||
|
double dynamic_cast else enum explicit export extern false float for friend goto if inline
|
||||||
|
int long mutable namespace new operator private protected public register reinterpret_cast
|
||||||
|
return short signed sizeof static static_cast struct switch template this throw true try
|
||||||
|
typedef typeid typename union unsigned using virtual void volatile wchar_t while";
|
||||||
|
|
||||||
|
const KEYWORDS_CSHARP: &str =
|
||||||
|
"abstract as base bool break byte case catch char checked class const continue decimal
|
||||||
|
default delegate do double else enum event explicit extern false finally fixed float for
|
||||||
|
foreach goto if implicit in int interface internal is lock long namespace new null object
|
||||||
|
operator out override params private protected public readonly ref return sbyte sealed short
|
||||||
|
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
|
||||||
|
unsafe ushort using virtual void volatile while";
|
||||||
|
|
||||||
|
const KEYWORDS_COFFEESCRIPT: &str =
|
||||||
|
"Infinity NaN and arguments await break by case catch class continue debugger delete defer
|
||||||
|
default do else export extends false finally for function if import in instanceof is isnt
|
||||||
|
let loop new no not null of on or package return super switch this throw true try typeof
|
||||||
|
unless undefined var wait when with yield";
|
||||||
|
|
||||||
|
const KEYWORDS_DART: &str =
|
||||||
|
"abstract as assert async await base break case catch class const continue covariant
|
||||||
|
default deferred do dynamic else enum export extends external factory false final finally
|
||||||
|
for get if implements import in interface is late library mixin new null on operator part
|
||||||
|
required rethrow return sealed set show static super switch this throw true try var void
|
||||||
|
when with while yield";
|
||||||
|
|
||||||
|
/// fish is not in the library at all, so its fences are drawn plain today.
|
||||||
|
/// The list is the shell's own words, which is what a fish fence is mostly
|
||||||
|
/// made of.
|
||||||
|
const KEYWORDS_FISH: &str =
|
||||||
|
"and begin break builtin case command continue else end exec for function if in not or
|
||||||
|
return switch while set echo test string math read source";
|
||||||
|
|
||||||
|
const KEYWORDS_GO: &str =
|
||||||
|
"break case chan const continue default defer else fallthrough false for func go goto if
|
||||||
|
import interface map package range return select struct switch true type var";
|
||||||
|
|
||||||
|
const KEYWORDS_JAVA: &str =
|
||||||
|
"abstract assert boolean break byte case catch char class const continue default do double
|
||||||
|
else enum extends final finally float for goto if implements import instanceof int interface
|
||||||
|
long native new null package private protected public return short static strictfp super
|
||||||
|
switch synchronized this throw throws transient try void volatile while";
|
||||||
|
|
||||||
|
const KEYWORDS_JAVASCRIPT: &str =
|
||||||
|
"async await boolean break case catch class const continue debugger default delete do else
|
||||||
|
enum export extends false finally for function if implements import in instanceof interface
|
||||||
|
let new null package private protected public return super switch this throw true try typeof
|
||||||
|
var void while with yield";
|
||||||
|
|
||||||
|
const KEYWORDS_JSON: &str = "true false null";
|
||||||
|
|
||||||
|
const KEYWORDS_KOTLIN: &str =
|
||||||
|
"actual abstract annotation as break by catch class companion const constructor continue
|
||||||
|
coroutine crossinline data delegate dynamic do else enum expect external false final finally
|
||||||
|
for fun get if import in infix inline interface internal is lazy lateinit native null object
|
||||||
|
open operator out override package private protected public reified return sealed set super
|
||||||
|
suspend tailrec this throw true try typealias typeof val var vararg when while yield";
|
||||||
|
|
||||||
|
const KEYWORDS_PERL: &str =
|
||||||
|
"__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
|
||||||
|
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
|
||||||
|
use while xor";
|
||||||
|
|
||||||
|
const KEYWORDS_PHP: &str =
|
||||||
|
"__halt_compiler abstract and array as break callable case catch class clone const continue
|
||||||
|
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
|
||||||
|
endwhile eval exit extends final finally fn for foreach function global goto if implements
|
||||||
|
include include_once instanceof insteadof interface isset list match new or print private
|
||||||
|
protected public require require_once return static switch throw trait try unset use var
|
||||||
|
while xor yield";
|
||||||
|
|
||||||
|
const KEYWORDS_PYTHON: &str =
|
||||||
|
"False True and as assert async await break class continue def del elif else except finally
|
||||||
|
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||||
|
yield";
|
||||||
|
|
||||||
|
/// RON is not in the library either; these are the words a RON file can hold.
|
||||||
|
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||||
|
|
||||||
|
const KEYWORDS_RUBY: &str =
|
||||||
|
"__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
|
||||||
|
else elsif end ensure false for if in module next nil not or redo rescue retry return self
|
||||||
|
super then true undef unless until when while yield";
|
||||||
|
|
||||||
|
const KEYWORDS_RUST: &str =
|
||||||
|
"as async await break const continue crate dyn else enum extern false fn for if impl in
|
||||||
|
let loop match mod move mut pub ref return Self self static struct super trait true type
|
||||||
|
union unsafe use where while abstract become box do final macro override priv try typeof
|
||||||
|
unsized virtual yield";
|
||||||
|
|
||||||
|
const KEYWORDS_SHELL: &str =
|
||||||
|
"alias bg bind break builtin caller cd command compgen complete compopt continue declare
|
||||||
|
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
|
||||||
|
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
|
||||||
|
test";
|
||||||
|
|
||||||
|
const KEYWORDS_SWIFT: &str =
|
||||||
|
"_ associatedtype class deinit enum extension fileprivate func import init inout internal
|
||||||
|
let open operator private precedencegroup protocol public rethrows static struct subscript
|
||||||
|
typealias var break case catch continue default defer do else fallthrough for guard if in
|
||||||
|
repeat return throw switch where while Any as await false is nil self Self super throws true
|
||||||
|
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
|
||||||
|
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||||
|
unowned weak willSet";
|
||||||
|
|
||||||
|
/// TOML is not in the library; `inf` and `nan` are values rather than
|
||||||
|
/// names, like the booleans.
|
||||||
|
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||||
|
|
||||||
|
const KEYWORDS_TYPESCRIPT: &str =
|
||||||
|
"abstract as asserts await break case catch class const constructor continue debugger
|
||||||
|
default delete do else enum export extends false finally for from function get if implements
|
||||||
|
import in infer instanceof interface is keyof let module namespace new null number object
|
||||||
|
package private protected public readonly require global return set static string super
|
||||||
|
switch this throw true try type typeof undefined unique unknown var void while with yield";
|
||||||
|
|
||||||
|
/// The highlighter's language for a fence's info word, or `None` for one it
|
||||||
|
/// has no rules for. Also what `super::file_language` reads for a file's
|
||||||
|
/// extension -- one table, so a language added for fences is a language
|
||||||
|
/// added for files.
|
||||||
|
pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
||||||
|
let name = name?.trim().to_lowercase();
|
||||||
|
FENCE_LANGUAGES
|
||||||
|
.iter()
|
||||||
|
.find(|(alias, _)| *alias == name)
|
||||||
|
.map(|(_, language)| *language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highlighter's language for a *file*, from its name.
|
||||||
|
///
|
||||||
|
/// The extension is the part after the *last* dot, which is what makes
|
||||||
|
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
|
||||||
|
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||||
|
/// all -- `Makefile` -- is likewise `None`.
|
||||||
|
pub fn file_language(name: &str) -> Option<Language> {
|
||||||
|
let dot = name.rfind('.')?;
|
||||||
|
if dot < 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
fence_language(Some(&name[dot + 1..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const FENCE_LANGUAGES: &[(&str, Language)] = &[
|
||||||
|
("kotlin", Language::Kotlin),
|
||||||
|
("kt", Language::Kotlin),
|
||||||
|
("kts", Language::Kotlin),
|
||||||
|
("rust", Language::Rust),
|
||||||
|
("rs", Language::Rust),
|
||||||
|
("sh", Language::Shell),
|
||||||
|
("bash", Language::Shell),
|
||||||
|
("shell", Language::Shell),
|
||||||
|
("zsh", Language::Shell),
|
||||||
|
("console", Language::Shell),
|
||||||
|
("python", Language::Python),
|
||||||
|
("py", Language::Python),
|
||||||
|
("javascript", Language::Javascript),
|
||||||
|
("js", Language::Javascript),
|
||||||
|
("jsx", Language::Javascript),
|
||||||
|
("typescript", Language::Typescript),
|
||||||
|
("ts", Language::Typescript),
|
||||||
|
("tsx", Language::Typescript),
|
||||||
|
("java", Language::Java),
|
||||||
|
("c", Language::C),
|
||||||
|
("h", Language::C),
|
||||||
|
("cpp", Language::Cpp),
|
||||||
|
("c++", Language::Cpp),
|
||||||
|
("cc", Language::Cpp),
|
||||||
|
("hpp", Language::Cpp),
|
||||||
|
("csharp", Language::Csharp),
|
||||||
|
("cs", Language::Csharp),
|
||||||
|
("c#", Language::Csharp),
|
||||||
|
("go", Language::Go),
|
||||||
|
("golang", Language::Go),
|
||||||
|
("swift", Language::Swift),
|
||||||
|
("dart", Language::Dart),
|
||||||
|
("ruby", Language::Ruby),
|
||||||
|
("rb", Language::Ruby),
|
||||||
|
("php", Language::Php),
|
||||||
|
("perl", Language::Perl),
|
||||||
|
("pl", Language::Perl),
|
||||||
|
("coffeescript", Language::Coffeescript),
|
||||||
|
("coffee", Language::Coffeescript),
|
||||||
|
("ron", Language::Ron),
|
||||||
|
("toml", Language::Toml),
|
||||||
|
("fish", Language::Fish),
|
||||||
|
("json", Language::Json),
|
||||||
|
("markdown", Language::Markdown),
|
||||||
|
("md", Language::Markdown),
|
||||||
|
];
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
||||||
|
//! in a reply, and a `.md` file in the viewer. Ported from
|
||||||
|
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
||||||
|
//! scanner rather than a row of [`super::Rules`] (what a character means
|
||||||
|
//! depends on where it sits, not on what it is) and why an indented code
|
||||||
|
//! block is deliberately not recognised.
|
||||||
|
//!
|
||||||
|
//! Structure is read a line at a time and each line's prose left to right,
|
||||||
|
//! except the two decisions that are not: a fenced block is state carried
|
||||||
|
//! forward, and a table is found by its delimiter row, which comes after
|
||||||
|
//! the header it belongs to (the one place here that looks ahead).
|
||||||
|
|
||||||
|
use super::{Kind, Span};
|
||||||
|
|
||||||
|
/// The characters an unordered list may be bulleted with.
|
||||||
|
const BULLETS: &str = "-*+";
|
||||||
|
/// The characters a thematic break, or a setext heading's underline, can be
|
||||||
|
/// drawn with.
|
||||||
|
const RULE_MARKERS: &str = "-*_=";
|
||||||
|
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
||||||
|
const EMPHASIS: &str = "*_~";
|
||||||
|
/// Characters that end a bare URL wherever they appear, and ones only
|
||||||
|
/// trimmed off the end.
|
||||||
|
const URL_STOPS: &str = "<>\"'`|";
|
||||||
|
const URL_TRAILING: &str = ".,:;!?";
|
||||||
|
|
||||||
|
pub fn scan_markdown(code: &str) -> Vec<Span> {
|
||||||
|
MarkdownScanner::new(code).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MarkdownScanner {
|
||||||
|
code: Vec<char>,
|
||||||
|
spans: Vec<Span>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownScanner {
|
||||||
|
fn new(code: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.chars().collect(),
|
||||||
|
spans: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut self) -> Vec<Span> {
|
||||||
|
let mut at = 0usize;
|
||||||
|
// The delimiter run that opened the fenced block we are inside, or
|
||||||
|
// None between them.
|
||||||
|
let mut fence: Option<Vec<char>> = None;
|
||||||
|
// Whether the row above was part of a table, which is what makes
|
||||||
|
// this one a body row.
|
||||||
|
let mut table = false;
|
||||||
|
loop {
|
||||||
|
let end = self.line_end(at);
|
||||||
|
if let Some(open) = fence.clone() {
|
||||||
|
// The content and the closing line alike: a fence is one
|
||||||
|
// block of code, and its own delimiters belong to it the
|
||||||
|
// way a string's quotes belong to the string.
|
||||||
|
self.emit(at, end, Kind::String);
|
||||||
|
if self.closes_fence(at, end, &open) {
|
||||||
|
fence = None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let opened = self.opens_fence(at, end);
|
||||||
|
if opened.is_some() {
|
||||||
|
table = false;
|
||||||
|
fence = opened;
|
||||||
|
} else {
|
||||||
|
table = self.row(at, end, table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end == self.code.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at = end + 1;
|
||||||
|
}
|
||||||
|
self.spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
||||||
|
fn line_end(&self, at: usize) -> usize {
|
||||||
|
self.code[at..]
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == '\n')
|
||||||
|
.map(|p| at + p)
|
||||||
|
.unwrap_or(self.code.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line that is not inside a fence, and whether the table it may be
|
||||||
|
/// part of is still open.
|
||||||
|
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||||
|
if self.table_delimiter(start, end) {
|
||||||
|
let indented = self.indented(start, end);
|
||||||
|
self.emit(indented, end, Kind::Mark);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
|
||||||
|
if (table || header) && self.has_pipe(start, end) {
|
||||||
|
self.table_row(start, end);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.structure(start, end);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
||||||
|
/// one of each needed.
|
||||||
|
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||||
|
let mut dashes = false;
|
||||||
|
let mut pipes = false;
|
||||||
|
for at in self.indented(start, end)..end {
|
||||||
|
match self.code[at] {
|
||||||
|
'-' => dashes = true,
|
||||||
|
'|' => pipes = true,
|
||||||
|
':' | ' ' | '\t' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dashes && pipes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_pipe(&self, start: usize, end: usize) -> bool {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end {
|
||||||
|
if self.code[at] == '\\' {
|
||||||
|
at += 2;
|
||||||
|
} else if self.code[at] == '|' {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table row: the pipes are the structure, and what is between them is prose.
|
||||||
|
fn table_row(&mut self, start: usize, end: usize) {
|
||||||
|
let mut at = self.indented(start, end);
|
||||||
|
let mut cell = at;
|
||||||
|
while at < end {
|
||||||
|
match self.code[at] {
|
||||||
|
'\\' => at += 2,
|
||||||
|
'|' => {
|
||||||
|
self.inline(cell, at);
|
||||||
|
self.emit(at, at + 1, Kind::Mark);
|
||||||
|
at += 1;
|
||||||
|
cell = at;
|
||||||
|
}
|
||||||
|
_ => at += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.inline(cell, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spans, coalesced with the one before when they touch and agree.
|
||||||
|
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||||
|
if end <= start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(last) = self.spans.last_mut()
|
||||||
|
&& last.kind == kind
|
||||||
|
&& last.end == start
|
||||||
|
{
|
||||||
|
last.end = end;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.spans.push(Span { start, end, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first character of the line at or after `start` that is not indentation.
|
||||||
|
fn indented(&self, start: usize, end: usize) -> usize {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run of backticks or tildes that could open or close a fence on
|
||||||
|
/// this line, or `None`.
|
||||||
|
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||||
|
let at = self.indented(start, end);
|
||||||
|
if at == end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let marker = self.code[at];
|
||||||
|
if marker != '`' && marker != '~' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut run = at;
|
||||||
|
while run < end && self.code[run] == marker {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
if run - at >= 3 { Some((at, run)) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws an opening fence line and answers its delimiter, or `None` if
|
||||||
|
/// this is not one.
|
||||||
|
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||||
|
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||||
|
self.emit(run_start, run_end, Kind::String);
|
||||||
|
// The info word is what the fence is a fence *of*, which is
|
||||||
|
// metadata about the block rather than part of it.
|
||||||
|
let indented = self.indented(run_end, end);
|
||||||
|
self.emit(indented, end, Kind::Metadata);
|
||||||
|
Some(self.code[run_start..run_end].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this line closes a fence opened by `open`: the same
|
||||||
|
/// character, at least as many of them, and nothing else on the line.
|
||||||
|
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||||
|
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.indented(run_end, end) == end
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One ordinary line: what its opening characters make it, and then its prose.
|
||||||
|
fn structure(&mut self, start: usize, end: usize) {
|
||||||
|
let mut at = start;
|
||||||
|
// Quote markers come before everything else and can be several
|
||||||
|
// deep, and what follows one is an ordinary line again -- a heading
|
||||||
|
// inside a quote is still a heading.
|
||||||
|
while at < end && self.code[at] == '>' {
|
||||||
|
at += 1;
|
||||||
|
self.emit(at - 1, at, Kind::Mark);
|
||||||
|
at = self.indented(at, end);
|
||||||
|
}
|
||||||
|
if at == end {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.heading(at, end) || self.thematic_break(at, end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text_start = self.bullet(at, end);
|
||||||
|
self.inline(text_start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#` to `######` and a space. Without the space it is a word
|
||||||
|
/// beginning with a hash.
|
||||||
|
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end && self.code[at] == '#' {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
let depth = at - start;
|
||||||
|
if !(1..=6).contains(&depth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.emit(start, end, Kind::Keyword);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line made of one repeated rule character and nothing else.
|
||||||
|
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||||
|
let marker = self.code[start];
|
||||||
|
if !RULE_MARKERS.contains(marker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut seen = 0usize;
|
||||||
|
for at in start..end {
|
||||||
|
let c = self.code[at];
|
||||||
|
if c == marker {
|
||||||
|
seen += 1;
|
||||||
|
} else if !c.is_whitespace() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen < if marker == '=' { 1 } else { 3 } {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.emit(start, end, Kind::Mark);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a list marker if the line opens with one, and answers where
|
||||||
|
/// the item's text starts.
|
||||||
|
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let marker = self.code[start];
|
||||||
|
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||||
|
self.emit(start, start + 1, Kind::Mark);
|
||||||
|
return self.indented(start + 1, end);
|
||||||
|
}
|
||||||
|
let mut digits = start;
|
||||||
|
while digits < end && self.code[digits].is_ascii_digit() {
|
||||||
|
digits += 1;
|
||||||
|
}
|
||||||
|
let delimiter = self.code.get(digits).copied();
|
||||||
|
if digits > start
|
||||||
|
&& (delimiter == Some('.') || delimiter == Some(')'))
|
||||||
|
&& self.space_or_end(digits + 1, end)
|
||||||
|
{
|
||||||
|
self.emit(start, digits + 1, Kind::Mark);
|
||||||
|
return self.indented(digits + 1, end);
|
||||||
|
}
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space_or_end(&self, at: usize, end: usize) -> bool {
|
||||||
|
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inline forms, left to right. Every branch answers a position
|
||||||
|
/// strictly after `start` of its call, so this terminates.
|
||||||
|
fn inline(&mut self, start: usize, end: usize) {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end {
|
||||||
|
let c = self.code[at];
|
||||||
|
at = if c == '\\' {
|
||||||
|
// A backslash takes the character after it out of the
|
||||||
|
// running entirely, which is how `\*` stays an asterisk
|
||||||
|
// rather than opening emphasis.
|
||||||
|
at + 2
|
||||||
|
} else if c == '`' {
|
||||||
|
self.code_span(at, end)
|
||||||
|
} else if c == '[' {
|
||||||
|
self.link(at, at, end)
|
||||||
|
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
|
||||||
|
self.link(at, at + 1, end)
|
||||||
|
} else if c == '<' {
|
||||||
|
self.autolink(at, end)
|
||||||
|
} else if EMPHASIS.contains(c) {
|
||||||
|
self.emphasis(at, end)
|
||||||
|
} else {
|
||||||
|
self.url(at, end).unwrap_or(at + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
||||||
|
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let mut open = start;
|
||||||
|
while open < end && self.code[open] == '`' {
|
||||||
|
open += 1;
|
||||||
|
}
|
||||||
|
let ticks = open - start;
|
||||||
|
let mut at = open;
|
||||||
|
while at < end {
|
||||||
|
if self.code[at] != '`' {
|
||||||
|
at += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut close = at;
|
||||||
|
while close < end && self.code[close] == '`' {
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
if close - at == ticks {
|
||||||
|
self.emit(start, close, Kind::String);
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
at = close;
|
||||||
|
}
|
||||||
|
// Nothing closes it on this line, so those were ordinary backticks.
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[text](destination)`, and the same with a leading `!` for an image.
|
||||||
|
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut close = bracket;
|
||||||
|
while close < end {
|
||||||
|
match self.code[close] {
|
||||||
|
'\\' => close += 1,
|
||||||
|
'[' => depth += 1,
|
||||||
|
']' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
if close >= end {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
let destination = close + 1;
|
||||||
|
if self.code.get(destination) != Some(&'(') {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
|
||||||
|
return start + 1;
|
||||||
|
};
|
||||||
|
let paren = destination + paren_rel;
|
||||||
|
if paren >= end {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
self.emit(start, bracket + 1, Kind::Mark);
|
||||||
|
self.inline(bracket + 1, close);
|
||||||
|
self.emit(close, destination, Kind::Mark);
|
||||||
|
self.emit(destination, paren + 1, Kind::Metadata);
|
||||||
|
paren + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
||||||
|
/// destination they are.
|
||||||
|
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let mut at = start + 1;
|
||||||
|
let mut addressed = false;
|
||||||
|
while at < end {
|
||||||
|
let c = self.code[at];
|
||||||
|
if c.is_whitespace() || c == '<' {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
if c == '>' {
|
||||||
|
if !addressed {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
self.emit(start, at + 1, Kind::Metadata);
|
||||||
|
return at + 1;
|
||||||
|
}
|
||||||
|
if c == ':' || c == '@' {
|
||||||
|
addressed = true;
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare `scheme://...` written in prose, or `None` if one does not
|
||||||
|
/// start here.
|
||||||
|
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||||
|
if start > 0 && is_word(self.code[start - 1]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut scheme = start;
|
||||||
|
while scheme < end && self.code[scheme].is_alphabetic() {
|
||||||
|
scheme += 1;
|
||||||
|
}
|
||||||
|
if scheme == start || !starts_with(&self.code, scheme, "://") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = scheme + 3;
|
||||||
|
let mut at = body;
|
||||||
|
let mut openers = 0i32;
|
||||||
|
let mut closers = 0i32;
|
||||||
|
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
|
||||||
|
if self.code[at] == '(' {
|
||||||
|
openers += 1;
|
||||||
|
} else if self.code[at] == ')' {
|
||||||
|
closers += 1;
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
while at > body {
|
||||||
|
let last = self.code[at - 1];
|
||||||
|
if URL_TRAILING.contains(last) {
|
||||||
|
at -= 1;
|
||||||
|
} else if last == ')' && closers > openers {
|
||||||
|
closers -= 1;
|
||||||
|
at -= 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at == body {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.emit(start, at, Kind::Metadata);
|
||||||
|
Some(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
||||||
|
/// all.
|
||||||
|
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let marker = self.code[start];
|
||||||
|
let mut open = start;
|
||||||
|
while open < end && self.code[open] == marker {
|
||||||
|
open += 1;
|
||||||
|
}
|
||||||
|
let length = open - start;
|
||||||
|
if marker == '~' && length != 2 {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
if length > 3 {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
if open == end || self.code[open].is_whitespace() {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
let mut at = open;
|
||||||
|
while at < end {
|
||||||
|
if self.code[at] == '\\' {
|
||||||
|
at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.code[at] != marker {
|
||||||
|
at += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut close = at;
|
||||||
|
while close < end && self.code[close] == marker {
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
let finish = at + length;
|
||||||
|
if close - at >= length
|
||||||
|
&& !self.code[at - 1].is_whitespace()
|
||||||
|
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
|
||||||
|
{
|
||||||
|
self.emit(start, finish, Kind::Literal);
|
||||||
|
return finish;
|
||||||
|
}
|
||||||
|
at = close;
|
||||||
|
}
|
||||||
|
open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
|
||||||
|
let token: Vec<char> = token.chars().collect();
|
||||||
|
if at + token.len() > code.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
code[at..at + token.len()] == token[..]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::{Kind, Language, span_text, spans_of};
|
||||||
|
|
||||||
|
fn spans(code: &str, kind: Kind) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = code.chars().collect();
|
||||||
|
spans_of(code, Language::Markdown)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.kind == kind)
|
||||||
|
.map(|s| span_text(&chars, &s))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
|
||||||
|
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
|
||||||
|
let code = "## Layout\nissue #12 is fixed\n#hashtag";
|
||||||
|
assert_spans(code, Kind::Keyword, &["## Layout"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seven_hashes_are_not_a_heading() {
|
||||||
|
assert_spans("####### deep", Kind::Keyword, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
|
||||||
|
let code = "text\n```kotlin\nval x = 1\n```\nmore";
|
||||||
|
assert_spans(code, Kind::Metadata, &["kotlin"]);
|
||||||
|
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
|
||||||
|
let code = "````\n```\n# not a heading\n````\nafter";
|
||||||
|
assert_spans(code, Kind::Keyword, &[]);
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Kind::String,
|
||||||
|
&["````", "```", "# not a heading", "````"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
|
||||||
|
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_markers_and_quote_markers_colour_without_their_text() {
|
||||||
|
let code = "- one\n2. two\n> quoted";
|
||||||
|
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_and_a_setext_underline_are_the_same_mark() {
|
||||||
|
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emphasis_needs_something_on_both_sides_of_it() {
|
||||||
|
assert_spans(
|
||||||
|
"**bold** and *thin*",
|
||||||
|
Kind::Literal,
|
||||||
|
&["**bold**", "*thin*"],
|
||||||
|
);
|
||||||
|
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_underscore_inside_a_word_emphasises_nothing() {
|
||||||
|
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_code_span_holds_a_backtick_when_opened_with_two() {
|
||||||
|
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unclosed_code_span_is_ordinary_text() {
|
||||||
|
assert_spans("a ` b", Kind::String, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_marks_its_brackets_and_colours_its_destination() {
|
||||||
|
let code = "see [the plan](PLAN.md) now";
|
||||||
|
assert_spans(code, Kind::Mark, &["[", "]"]);
|
||||||
|
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
|
||||||
|
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Kind::Mark,
|
||||||
|
&["|", "|", "|", "|---|---|", "|", "|", "|"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
|
||||||
|
let code = "a | b\n--- | ---\nnot a row";
|
||||||
|
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_autolink_colours_and_an_html_tag_does_not() {
|
||||||
|
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["<https://example.com>", "<a@b.com>", "http://x"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_url_gives_back_the_sentences_punctuation() {
|
||||||
|
assert_spans(
|
||||||
|
"see https://example.com/a., and ssh://host/x)",
|
||||||
|
Kind::Metadata,
|
||||||
|
&["https://example.com/a", "ssh://host/x"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bracket_a_url_opened_itself_stays_in_it() {
|
||||||
|
assert_spans(
|
||||||
|
"https://en.wikipedia.org/wiki/A_(b) here",
|
||||||
|
Kind::Metadata,
|
||||||
|
&["https://en.wikipedia.org/wiki/A_(b)"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
|
||||||
|
assert_spans(
|
||||||
|
"[x](https://example.com)",
|
||||||
|
Kind::Metadata,
|
||||||
|
&["(https://example.com)"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bracket_with_no_destination_after_it_is_left_plain() {
|
||||||
|
assert_spans("an [aside] here", Kind::Mark, &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
//! `code` read once, left to right, into the spans that carry a colour.
|
||||||
|
//! Ported from `app/.../Highlighter.kt`.
|
||||||
|
//!
|
||||||
|
//! One pass with a small state -- in a comment, in a string, or in ordinary
|
||||||
|
//! code -- rather than a locator per token kind over the whole text, which
|
||||||
|
//! is what the library this replaced did and is why it found comments
|
||||||
|
//! before it knew the language: a `#` inside a shell string, a `//` inside
|
||||||
|
//! a URL and a block-comment opener inside a shell glob each commented out
|
||||||
|
//! the rest of a line that was nothing of the sort.
|
||||||
|
//!
|
||||||
|
//! Every span is produced by advancing an index forward, so the result is
|
||||||
|
//! ordered, non-overlapping and inside the code by construction. Nothing
|
||||||
|
//! here panics: an unterminated string or comment runs to the end of the
|
||||||
|
//! code, which is also what it looks like while a fence is still being
|
||||||
|
//! written.
|
||||||
|
//!
|
||||||
|
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
||||||
|
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
||||||
|
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
||||||
|
//! back into the text it covers.
|
||||||
|
|
||||||
|
pub mod languages;
|
||||||
|
pub mod markdown;
|
||||||
|
|
||||||
|
pub use languages::{
|
||||||
|
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What a span of code is, in the terms a palette has a colour for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Kind {
|
||||||
|
Keyword,
|
||||||
|
String,
|
||||||
|
Literal,
|
||||||
|
Comment,
|
||||||
|
Metadata,
|
||||||
|
Punctuation,
|
||||||
|
Mark,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Span {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub kind: Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text a [`Span`] covers, for a caller working in char indices (every
|
||||||
|
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
|
||||||
|
pub fn span_text(code: &[char], span: &Span) -> String {
|
||||||
|
code[span.start..span.end].iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spans `language` colours in `code` -- the one way to ask, whatever
|
||||||
|
/// the language turns out to be made of. `None` draws plain.
|
||||||
|
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
|
||||||
|
if language == Language::Markdown {
|
||||||
|
markdown::scan_markdown(code)
|
||||||
|
} else {
|
||||||
|
scan(code, &rules_for(language))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `code` read into the spans [`Rules`] describes. Also reachable directly
|
||||||
|
/// for a caller that already has a [`Rules`] (there is currently only one:
|
||||||
|
/// [`spans_of`]), kept public because the Kotlin original exposed it the
|
||||||
|
/// same way.
|
||||||
|
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
||||||
|
Scanner::new(code, rules).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
||||||
|
/// the library this replaced used.
|
||||||
|
const PUNCTUATION: &str = ",.:;";
|
||||||
|
const MARKS: &str = "()={}<>-+[]|&";
|
||||||
|
|
||||||
|
struct Scanner<'a> {
|
||||||
|
code: Vec<char>,
|
||||||
|
rules: &'a Rules,
|
||||||
|
spans: Vec<Span>,
|
||||||
|
at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Scanner<'a> {
|
||||||
|
fn new(code: &str, rules: &'a Rules) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.chars().collect(),
|
||||||
|
rules,
|
||||||
|
spans: Vec::new(),
|
||||||
|
at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut self) -> Vec<Span> {
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
// Every branch that answers true has advanced `self.at`, so
|
||||||
|
// this terminates.
|
||||||
|
let consumed = self.block_comment()
|
||||||
|
|| self.line_comment()
|
||||||
|
|| self.raw_string()
|
||||||
|
|| self.character_or_lifetime()
|
||||||
|
|| self.string()
|
||||||
|
|| self.attribute()
|
||||||
|
|| self.number()
|
||||||
|
|| self.word()
|
||||||
|
|| self.single_character();
|
||||||
|
if !consumed {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.spans
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(&mut self, start: usize, kind: Kind) {
|
||||||
|
if self.at > start {
|
||||||
|
self.spans.push(Span {
|
||||||
|
start,
|
||||||
|
end: self.at,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starts(&self, token: &str) -> bool {
|
||||||
|
starts_with_at(&self.code, self.at, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a line comment token here opens one; see
|
||||||
|
/// [`Rules::line_comments_at_word_start`].
|
||||||
|
fn at_word_start(&self) -> bool {
|
||||||
|
self.at == 0
|
||||||
|
|| self.code[self.at - 1].is_whitespace()
|
||||||
|
|| ";|&(".contains(self.code[self.at - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether only whitespace stands between the start of this line and here.
|
||||||
|
fn at_line_start(&self) -> bool {
|
||||||
|
let mut back = self.at as isize - 1;
|
||||||
|
while back >= 0 && self.code[back as usize] != '\n' {
|
||||||
|
if !self.code[back as usize].is_whitespace() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
back -= 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_to_end_of_line(&mut self) {
|
||||||
|
while self.at < self.code.len() && self.code[self.at] != '\n' {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From an open bracket through the one that matches it, or to the end
|
||||||
|
/// if none does.
|
||||||
|
fn advance_to_matching_bracket(&mut self) {
|
||||||
|
let mut depth = 0i32;
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
match self.code[self.at] {
|
||||||
|
'[' => depth += 1,
|
||||||
|
']' => depth -= 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.at += 1;
|
||||||
|
if depth == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_comment(&mut self) -> bool {
|
||||||
|
let Some(comment) = self.rules.block_comment else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !self.starts(comment.open) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
self.at += comment.open.chars().count();
|
||||||
|
let mut depth = 1i32;
|
||||||
|
while self.at < self.code.len() && depth > 0 {
|
||||||
|
// The closer is tried first so that a language whose two
|
||||||
|
// delimiters are the same string -- CoffeeScript's `###` --
|
||||||
|
// closes rather than nesting forever.
|
||||||
|
if self.starts(comment.close) {
|
||||||
|
depth -= 1;
|
||||||
|
self.at += comment.close.chars().count();
|
||||||
|
} else if comment.nests && self.starts(comment.open) {
|
||||||
|
depth += 1;
|
||||||
|
self.at += comment.open.chars().count();
|
||||||
|
} else {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(start, Kind::Comment);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_comment(&mut self) -> bool {
|
||||||
|
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.rules.line_comments_at_word_start && !self.at_word_start() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
self.advance_to_end_of_line();
|
||||||
|
self.emit(start, Kind::Comment);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||||
|
fn raw_string(&mut self) -> bool {
|
||||||
|
if !self.rules.raw_strings {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut ahead = self.at;
|
||||||
|
if self.code.get(ahead) == Some(&'b') {
|
||||||
|
ahead += 1;
|
||||||
|
}
|
||||||
|
if self.code.get(ahead) != Some(&'r') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ahead += 1;
|
||||||
|
let mut hashes = 0usize;
|
||||||
|
while self.code.get(ahead) == Some(&'#') {
|
||||||
|
ahead += 1;
|
||||||
|
hashes += 1;
|
||||||
|
}
|
||||||
|
if self.code.get(ahead) != Some(&'"') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
let closer: String = std::iter::once('"')
|
||||||
|
.chain(std::iter::repeat_n('#', hashes))
|
||||||
|
.collect();
|
||||||
|
let closer_chars: Vec<char> = closer.chars().collect();
|
||||||
|
let closed = find_from(&self.code, ahead + 1, &closer_chars);
|
||||||
|
self.at = match closed {
|
||||||
|
Some(index) => index + closer_chars.len(),
|
||||||
|
None => self.code.len(),
|
||||||
|
};
|
||||||
|
self.emit(start, Kind::String);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
|
||||||
|
/// literal opens nothing.
|
||||||
|
fn character_or_lifetime(&mut self) -> bool {
|
||||||
|
if !self.rules.lifetimes || self.code[self.at] != '\'' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(&next) = self.code.get(self.at + 1) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
|
||||||
|
self.quoted(Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(&mut self) -> bool {
|
||||||
|
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
||||||
|
// than an empty string followed by a quote.
|
||||||
|
let mut quote: Option<Quote> = None;
|
||||||
|
for candidate in &self.rules.quotes {
|
||||||
|
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||||
|
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
|
||||||
|
quote = Some(*candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(quote) = quote else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.quoted(quote);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quoted(&mut self, quote: Quote) {
|
||||||
|
let start = self.at;
|
||||||
|
self.at += quote.open.chars().count();
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
|
||||||
|
self.at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.starts(quote.close) {
|
||||||
|
self.at += quote.close.chars().count();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
self.at = self.at.min(self.code.len());
|
||||||
|
self.emit(start, Kind::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute(&mut self) -> bool {
|
||||||
|
let start = self.at;
|
||||||
|
match self.rules.attributes {
|
||||||
|
Attributes::None => return false,
|
||||||
|
Attributes::AtWord => {
|
||||||
|
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.at += 1;
|
||||||
|
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Attributes::HashBracket => {
|
||||||
|
if self.code[self.at] != '#' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut ahead = self.at + 1;
|
||||||
|
if self.code.get(ahead) == Some(&'!') {
|
||||||
|
ahead += 1;
|
||||||
|
}
|
||||||
|
if self.code.get(ahead) != Some(&'[') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.at = ahead;
|
||||||
|
self.advance_to_matching_bracket();
|
||||||
|
}
|
||||||
|
Attributes::HashLine => {
|
||||||
|
if self.code[self.at] != '#' || !self.at_line_start() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.advance_to_end_of_line();
|
||||||
|
}
|
||||||
|
Attributes::LineBracket => {
|
||||||
|
if self.code[self.at] != '[' || !self.at_line_start() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.advance_to_matching_bracket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(start, Kind::Metadata);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A number is a run starting with a digit and carrying on through
|
||||||
|
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
|
||||||
|
/// and `3.14` without a grammar for any of them.
|
||||||
|
fn number(&mut self) -> bool {
|
||||||
|
if !self.code[self.at].is_ascii_digit() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
let c = self.code[self.at];
|
||||||
|
if c.is_alphanumeric() || c == '_' || c == '.' {
|
||||||
|
self.at += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(start, Kind::Literal);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word(&mut self) -> bool {
|
||||||
|
if !is_word_start(Some(self.code[self.at])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
let word: String = self.code[start..self.at].iter().collect();
|
||||||
|
if self.rules.keywords.contains(word.as_str()) {
|
||||||
|
self.emit(start, Kind::Keyword);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_character(&mut self) -> bool {
|
||||||
|
let kind = if PUNCTUATION.contains(self.code[self.at]) {
|
||||||
|
Kind::Punctuation
|
||||||
|
} else if MARKS.contains(self.code[self.at]) {
|
||||||
|
Kind::Mark
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.at += 1;
|
||||||
|
self.emit(self.at - 1, kind);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_start(c: Option<char>) -> bool {
|
||||||
|
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_part(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `code[at..]` starts with `token`, both read as chars.
|
||||||
|
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||||
|
let token: Vec<char> = token.chars().collect();
|
||||||
|
if at + token.len() > code.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
code[at..at + token.len()] == token[..]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first index at or after `from` where `code` contains `needle`, or
|
||||||
|
/// `None`.
|
||||||
|
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||||
|
if needle.is_empty() || from > code.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = code.chars().collect();
|
||||||
|
spans_of(code, language)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.kind == kind)
|
||||||
|
.map(|s| span_text(&chars, &s))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
|
||||||
|
assert_eq!(
|
||||||
|
spans(code, language, kind),
|
||||||
|
expected.to_vec(),
|
||||||
|
"{kind:?} in: {code}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quoted_glob_is_one_string_not_a_comment() {
|
||||||
|
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
|
||||||
|
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_find_with_globs_has_no_comment_in_it() {
|
||||||
|
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Shell,
|
||||||
|
Kind::String,
|
||||||
|
&["'*/.git/*'", "'*.kt'"],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
|
||||||
|
let code = "curl https://example.com/x && echo done";
|
||||||
|
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_inside_a_kotlin_string_stays_a_string() {
|
||||||
|
let code = "val url = \"https://example.com\"\nfun f() = 1";
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::String,
|
||||||
|
&["\"https://example.com\""],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
|
||||||
|
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
|
||||||
|
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
|
||||||
|
assert_spans(code, Language::Rust, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
|
||||||
|
let code = "#![allow(dead_code)]\nfn f() {}";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Rust,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["#![allow(dead_code)]"],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
|
||||||
|
let code = "#include <stdio.h>\nint main() { return 0; }";
|
||||||
|
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
|
||||||
|
assert_spans(code, Language::C, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kotlin_annotation_is_metadata() {
|
||||||
|
assert_spans(
|
||||||
|
"@Composable fun f() {}",
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["@Composable"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
|
||||||
|
let code = "val c = \"#FF0000\"\nval d = 1";
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
|
||||||
|
let code = "val a = \"don't\"\nval b = \"x\"";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::String,
|
||||||
|
&["\"don't\"", "\"x\""],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
|
||||||
|
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
|
||||||
|
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_escaped_quote_is_inside_the_rust_character_literal() {
|
||||||
|
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_raw_string_keeps_its_inner_quotes() {
|
||||||
|
let code = "let s = r#\"a \"quoted\" b\"#;";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Rust,
|
||||||
|
Kind::String,
|
||||||
|
&["r#\"a \"quoted\" b\"#"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kotlin_triple_quoted_string_is_one_string() {
|
||||||
|
assert_spans(
|
||||||
|
"val s = \"\"\"a \"b\" c\"\"\"",
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::String,
|
||||||
|
&["\"\"\"a \"b\" c\"\"\""],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shell_single_quoted_string_takes_no_escapes() {
|
||||||
|
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_and_kotlin_nest_block_comments() {
|
||||||
|
let code = "/* a /* b */ c */ x";
|
||||||
|
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::Comment,
|
||||||
|
&["/* a /* b */ c */"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn c_ends_a_block_comment_at_the_first_close() {
|
||||||
|
assert_spans(
|
||||||
|
"/* a /* b */ c */ x",
|
||||||
|
Language::C,
|
||||||
|
Kind::Comment,
|
||||||
|
&["/* a /* b */"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shell_comment_starts_only_at_a_word_boundary() {
|
||||||
|
let code = "${#x} $# a#b # real";
|
||||||
|
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_anywhere_is_a_python_comment() {
|
||||||
|
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
|
||||||
|
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
|
||||||
|
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
|
||||||
|
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
|
||||||
|
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
|
||||||
|
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ron_attribute_and_its_values_colour() {
|
||||||
|
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Ron,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["#![enable(implicit_some)]"],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
|
||||||
|
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_fence_language_is_none() {
|
||||||
|
assert_eq!(fence_language(Some("brainfuck")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_language_the_fence_table_knows_has_a_scanner() {
|
||||||
|
for language in Language::ALL {
|
||||||
|
spans_of("x", language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scanner must never panic and must never answer a span the code
|
||||||
|
/// does not contain: the library this replaced answered a reversed
|
||||||
|
/// range here, which crashed a card, and a fence still being written is
|
||||||
|
/// an unterminated string or comment on every keystroke.
|
||||||
|
#[test]
|
||||||
|
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||||
|
let nasty = [
|
||||||
|
"",
|
||||||
|
"'",
|
||||||
|
"\"",
|
||||||
|
"\"unterminated",
|
||||||
|
"/* unterminated",
|
||||||
|
"###",
|
||||||
|
"#",
|
||||||
|
"#.collect();
|
||||||
|
let spans = spans_of(code, language);
|
||||||
|
for s in &spans {
|
||||||
|
assert!(
|
||||||
|
s.start <= s.end && s.end <= chars.len(),
|
||||||
|
"{language:?} answered {s:?} for {code:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut sorted = spans.clone();
|
||||||
|
sorted.sort_by_key(|s| s.start);
|
||||||
|
assert_eq!(
|
||||||
|
spans, sorted,
|
||||||
|
"{language:?} answered spans out of order for {code:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! The app's pure logic, shared between the server and any Rust client --
|
||||||
|
//! see `docs/CLIENT_CORE.md` for what lives here and what does
|
||||||
|
//! not yet.
|
||||||
|
|
||||||
|
pub mod ansi;
|
||||||
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
|
pub mod event_stream;
|
||||||
|
pub mod highlight;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod sse;
|
||||||
|
pub mod transcript_cache;
|
||||||
|
pub mod transcript_fold;
|
||||||
|
|
||||||
|
pub use event_model::*;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||||
|
//! places, never both" describes. Ported from the parsing half of
|
||||||
|
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||||
|
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
|
||||||
|
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||||
|
//! `Notification`/`NotificationKind`).
|
||||||
|
//!
|
||||||
|
//! What is deliberately **not** here, because it is a decision rather than
|
||||||
|
//! logic: whether a given notification is shown at all (the session on
|
||||||
|
//! screen gets nothing), handed to the app as a banner, or posted to the
|
||||||
|
//! platform's own notification drawer. That three-way choice reads
|
||||||
|
//! process-wide state (what screen is open, whether the app is in front)
|
||||||
|
//! that has no meaning to a pure crate with no UI and no Android in it --
|
||||||
|
//! see `android-shell` for where it lives for this port.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::api::{ApiError, Transport};
|
||||||
|
use crate::sse::SseReader;
|
||||||
|
|
||||||
|
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
||||||
|
/// `Notification` field for field.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionNotification {
|
||||||
|
pub session_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub kind: NotificationKind,
|
||||||
|
/// Epoch seconds, so a phone that was asleep can say how long ago.
|
||||||
|
pub at: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
||||||
|
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
||||||
|
/// `"finished"` directly rather than through a string match.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum NotificationKind {
|
||||||
|
AwaitingInput,
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationKind {
|
||||||
|
/// What a notification asks of the reader, in the words they see --
|
||||||
|
/// ported verbatim from `Notifications.kt`'s `attentionLine`. One
|
||||||
|
/// function because the same fact is shown in two places (the
|
||||||
|
/// platform's drawer and the app's own banner) and two mappings of one
|
||||||
|
/// word drift.
|
||||||
|
pub fn attention_line(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
NotificationKind::AwaitingInput => "Waiting for you",
|
||||||
|
NotificationKind::Finished => "Finished",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows `/notifications`, calling `on_notification` for each frame until
|
||||||
|
/// the connection drops or the callback asks to stop (by returning
|
||||||
|
/// `false`). Reconnecting is the caller's job -- mirroring
|
||||||
|
/// `NotificationService.follow`'s retry loop, which is a platform policy
|
||||||
|
/// (how long to wait, whether to give up) rather than parsing logic.
|
||||||
|
pub fn follow_notifications(
|
||||||
|
transport: &dyn Transport,
|
||||||
|
mut on_notification: impl FnMut(SessionNotification) -> bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let body = transport.stream("/notifications")?;
|
||||||
|
let mut lines = BufReader::new(body).lines();
|
||||||
|
let mut reader = SseReader::new();
|
||||||
|
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
|
||||||
|
message: format!("Can't reach the server -- retrying. ({e})"),
|
||||||
|
status: None,
|
||||||
|
})? {
|
||||||
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if frame.data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let notification: SessionNotification =
|
||||||
|
serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||||
|
message: format!("The server sent a notification this build couldn't parse: {e}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
if !on_notification(notification) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::{Body, RawResponse};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
struct FixtureTransport {
|
||||||
|
body: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FixtureTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
_path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
unimplemented!("this fixture only serves a stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
|
||||||
|
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_notification_frame_parses_both_kinds() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\
|
||||||
|
data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||||
|
};
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
follow_notifications(&transport, |n| {
|
||||||
|
seen.push((n.session_id, n.kind));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
vec![
|
||||||
|
("s1".to_string(), NotificationKind::AwaitingInput),
|
||||||
|
("s2".to_string(), NotificationKind::Finished),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_caller_can_stop_early() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\
|
||||||
|
data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||||
|
};
|
||||||
|
let mut count = 0;
|
||||||
|
follow_notifications(&transport, |_| {
|
||||||
|
count += 1;
|
||||||
|
count < 1
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attention_line_matches_the_kotlin_original() {
|
||||||
|
assert_eq!(
|
||||||
|
NotificationKind::AwaitingInput.attention_line(),
|
||||||
|
"Waiting for you"
|
||||||
|
);
|
||||||
|
assert_eq!(NotificationKind::Finished.attention_line(), "Finished");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
|
||||||
|
//! `event:` lines accumulate until a blank line ends the frame, comments
|
||||||
|
//! start with `:`, and a frame is either named with no payload or a payload
|
||||||
|
//! with no name.
|
||||||
|
//!
|
||||||
|
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
|
||||||
|
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
|
||||||
|
//! caller here feeds lines from wherever they came from (a real connection,
|
||||||
|
//! a test fixture) and gets frames back with no I/O of its own -- which is
|
||||||
|
//! what lets this be tested with no server, per RUST.md's "pure logic
|
||||||
|
//! first" for this crate.
|
||||||
|
|
||||||
|
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Frame {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates lines into [`Frame`]s. One instance per connection --
|
||||||
|
/// `feed_line` is called for every line the transport reads (with line
|
||||||
|
/// endings already stripped), and answers a frame when a blank line closes
|
||||||
|
/// one.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SseReader {
|
||||||
|
data: String,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseReader {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds one line (no trailing `\n`). Answers the frame this line
|
||||||
|
/// completed, if any.
|
||||||
|
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||||
|
if line.is_empty() {
|
||||||
|
if self.name.is_some() || !self.data.is_empty() {
|
||||||
|
let frame = Frame {
|
||||||
|
name: self.name.take(),
|
||||||
|
data: std::mem::take(&mut self.data),
|
||||||
|
};
|
||||||
|
return Some(frame);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix("data:") {
|
||||||
|
self.data.push_str(rest.trim());
|
||||||
|
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||||
|
self.name = Some(rest.trim().to_string());
|
||||||
|
}
|
||||||
|
// `id:`, comments -- nothing to do.
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn frames(lines: &[&str]) -> Vec<Frame> {
|
||||||
|
let mut reader = SseReader::new();
|
||||||
|
lines.iter().filter_map(|l| reader.feed_line(l)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_data_only_frame_has_no_name() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&["data:hello", ""]),
|
||||||
|
vec![Frame {
|
||||||
|
name: None,
|
||||||
|
data: "hello".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_frame_with_no_payload_still_completes() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&["event:reset", ""]),
|
||||||
|
vec![Frame {
|
||||||
|
name: Some("reset".to_string()),
|
||||||
|
data: String::new()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_line_with_nothing_pending_yields_no_frame() {
|
||||||
|
assert_eq!(frames(&[""]), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_comment_and_an_id_line_are_ignored() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&[":keepalive", "id:5", "data:hi", ""]),
|
||||||
|
vec![Frame {
|
||||||
|
name: None,
|
||||||
|
data: "hi".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_frames_in_a_row_are_both_reported() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&["data:one", "", "data:two", ""]),
|
||||||
|
vec![
|
||||||
|
Frame {
|
||||||
|
name: None,
|
||||||
|
data: "one".to_string()
|
||||||
|
},
|
||||||
|
Frame {
|
||||||
|
name: None,
|
||||||
|
data: "two".to_string()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,942 @@
|
|||||||
|
//! What the transcript renders: the event stream folded into displayable
|
||||||
|
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
||||||
|
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
||||||
|
//!
|
||||||
|
//! Events are the only data source, and there is deliberately no second
|
||||||
|
//! shape for history to drift from: a page fetched backwards, a live
|
||||||
|
//! frame, and a line read out of the transcript cache are all the same
|
||||||
|
//! events through the same fold.
|
||||||
|
//!
|
||||||
|
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
||||||
|
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
||||||
|
//! exists to bound how much a lazy list composes per frame, which is a
|
||||||
|
//! fact about the UI framework drawing it, not about the transcript. See
|
||||||
|
//! `CLIENT_CORE.md`.
|
||||||
|
//!
|
||||||
|
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
||||||
|
//! deserializes straight into [`event_model::Event`], which has no
|
||||||
|
//! `Unknown` catch-all -- an event type this build does not recognise
|
||||||
|
//! fails to parse rather than degrading to a placeholder row. Closing that
|
||||||
|
//! gap means giving `event_model::Event` its own forward-compatible
|
||||||
|
//! variant, which is a shared-model decision for both sides of the wire
|
||||||
|
//! and is deliberately left for whoever picks this up next (see
|
||||||
|
//! `CLIENT_CORE.md`).
|
||||||
|
|
||||||
|
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||||
|
|
||||||
|
/// A question this build has already asked the reader about, with what was
|
||||||
|
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
||||||
|
/// be chosen.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct QuestionCard {
|
||||||
|
pub seq: u64,
|
||||||
|
pub id: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub header: Option<String>,
|
||||||
|
pub options: Vec<QuestionOption>,
|
||||||
|
pub multi_select: bool,
|
||||||
|
pub answers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
||||||
|
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
||||||
|
/// logic name it explicitly.
|
||||||
|
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||||
|
|
||||||
|
/// This item's identity in the list: a `Seq` for everything with no
|
||||||
|
/// identity of its own, `RunId` for a tool call (which keeps one across
|
||||||
|
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
||||||
|
/// in the Kotlin original.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ItemKey {
|
||||||
|
Seq(u64),
|
||||||
|
RunId(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
||||||
|
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
||||||
|
/// doc only says what changed in translation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TranscriptItem {
|
||||||
|
UserMsg {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
attachments: Vec<String>,
|
||||||
|
},
|
||||||
|
AssistantMsg {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
||||||
|
/// Kotlin doc for why the split it licenses matters.
|
||||||
|
settled: bool,
|
||||||
|
},
|
||||||
|
ToolRun {
|
||||||
|
seq: u64,
|
||||||
|
id: String,
|
||||||
|
run_id: String,
|
||||||
|
tool: String,
|
||||||
|
input: String,
|
||||||
|
output: String,
|
||||||
|
done: bool,
|
||||||
|
asks: Vec<QuestionCard>,
|
||||||
|
images: Vec<String>,
|
||||||
|
},
|
||||||
|
QuestionCard(QuestionCard),
|
||||||
|
ErrorMsg {
|
||||||
|
seq: u64,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
ImageItem {
|
||||||
|
seq: u64,
|
||||||
|
r#ref: String,
|
||||||
|
},
|
||||||
|
/// A message from another agent. `arrived` is this row's own identity
|
||||||
|
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
||||||
|
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
||||||
|
PeerNote {
|
||||||
|
seq: u64,
|
||||||
|
from: String,
|
||||||
|
text: String,
|
||||||
|
arrived: u64,
|
||||||
|
},
|
||||||
|
CommandRow {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// Placeholder for an event kind this build could not fold -- see the
|
||||||
|
/// module doc's "known gap".
|
||||||
|
Note {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ClearedNote {
|
||||||
|
seq: u64,
|
||||||
|
},
|
||||||
|
CompactedNote {
|
||||||
|
seq: u64,
|
||||||
|
pre_tokens: Option<u64>,
|
||||||
|
post_tokens: Option<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptItem {
|
||||||
|
pub fn seq(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::UserMsg { seq, .. }
|
||||||
|
| Self::AssistantMsg { seq, .. }
|
||||||
|
| Self::ToolRun { seq, .. }
|
||||||
|
| Self::ErrorMsg { seq, .. }
|
||||||
|
| Self::ImageItem { seq, .. }
|
||||||
|
| Self::PeerNote { seq, .. }
|
||||||
|
| Self::CommandRow { seq, .. }
|
||||||
|
| Self::Note { seq, .. }
|
||||||
|
| Self::ClearedNote { seq }
|
||||||
|
| Self::CompactedNote { seq, .. } => *seq,
|
||||||
|
Self::QuestionCard(card) => card.seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> ItemKey {
|
||||||
|
match self {
|
||||||
|
Self::ToolRun { run_id, .. } => ItemKey::RunId(run_id.clone()),
|
||||||
|
Self::PeerNote { arrived, .. } => ItemKey::Seq(*arrived),
|
||||||
|
other => ItemKey::Seq(other.seq()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_tool_run(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::ToolRun { id, .. } => Some(id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run a call joins: the one it lands next to, or a new one named
|
||||||
|
/// after itself. See the Kotlin `runIdFor`'s doc for why the name, once
|
||||||
|
/// picked, never changes.
|
||||||
|
fn run_id_for(items: &[TranscriptItem], id: &str, tool: &str) -> String {
|
||||||
|
let Some(TranscriptItem::ToolRun {
|
||||||
|
run_id,
|
||||||
|
tool: previous_tool,
|
||||||
|
..
|
||||||
|
}) = items.last()
|
||||||
|
else {
|
||||||
|
return id.to_string();
|
||||||
|
};
|
||||||
|
if tool == ASK_USER_QUESTION || previous_tool == ASK_USER_QUESTION {
|
||||||
|
id.to_string()
|
||||||
|
} else {
|
||||||
|
run_id.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_tool(
|
||||||
|
items: &[TranscriptItem],
|
||||||
|
id: &str,
|
||||||
|
change: impl Fn(&mut TranscriptItem),
|
||||||
|
) -> Vec<TranscriptItem> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut item| {
|
||||||
|
if item.as_tool_run() == Some(id) {
|
||||||
|
change(&mut item);
|
||||||
|
}
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a status means the session is still doing something, mirroring
|
||||||
|
/// `sessionWorking` in `Events.kt`.
|
||||||
|
pub fn session_working(status: SessionStatus) -> bool {
|
||||||
|
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A status saying the session stopped working is the moment its newest
|
||||||
|
/// reply is finished.
|
||||||
|
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||||
|
if session_working(status) {
|
||||||
|
return items.to_vec();
|
||||||
|
}
|
||||||
|
let Some(TranscriptItem::AssistantMsg { settled: false, .. }) = items.last() else {
|
||||||
|
return items.to_vec();
|
||||||
|
};
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
if let Some(TranscriptItem::AssistantMsg { settled, .. }) = items.last_mut() {
|
||||||
|
*settled = true;
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer message goes above the turn it started, not where it happened to
|
||||||
|
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
||||||
|
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
||||||
|
fn place_peer_note(
|
||||||
|
items: &[TranscriptItem],
|
||||||
|
seq: u64,
|
||||||
|
from: &str,
|
||||||
|
text: &str,
|
||||||
|
turn_start: Option<u64>,
|
||||||
|
) -> Vec<TranscriptItem> {
|
||||||
|
let Some(at) = turn_start else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::PeerNote {
|
||||||
|
seq,
|
||||||
|
from: from.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
arrived: seq,
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
let note = TranscriptItem::PeerNote {
|
||||||
|
seq: at,
|
||||||
|
from: from.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
arrived: seq,
|
||||||
|
};
|
||||||
|
let Some(index) = items.iter().position(|i| i.seq() > at) else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(note);
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
let behind = match index.checked_sub(1).and_then(|i| items.get(i)) {
|
||||||
|
Some(TranscriptItem::ToolRun { run_id, .. }) => Some(run_id.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut out = items[..index].to_vec();
|
||||||
|
out.push(note);
|
||||||
|
out.extend(split_run(&items[index..], behind.as_deref()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calls the note now sits in front of, renamed if they were sharing a
|
||||||
|
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
||||||
|
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||||
|
let Some(TranscriptItem::ToolRun {
|
||||||
|
run_id: first_run_id,
|
||||||
|
id: first_id,
|
||||||
|
..
|
||||||
|
}) = tail.first()
|
||||||
|
else {
|
||||||
|
return tail.to_vec();
|
||||||
|
};
|
||||||
|
let Some(behind) = behind else {
|
||||||
|
return tail.to_vec();
|
||||||
|
};
|
||||||
|
if first_run_id != behind {
|
||||||
|
return tail.to_vec();
|
||||||
|
}
|
||||||
|
let run_len = tail
|
||||||
|
.iter()
|
||||||
|
.take_while(|i| matches!(i, TranscriptItem::ToolRun { run_id, .. } if run_id == behind))
|
||||||
|
.count();
|
||||||
|
let mut out: Vec<TranscriptItem> = tail[..run_len]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut item| {
|
||||||
|
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
|
||||||
|
*run_id = first_id.clone();
|
||||||
|
}
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.extend(tail[run_len..].iter().cloned());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||||
|
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||||
|
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||||
|
/// at the parse layer).
|
||||||
|
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||||
|
let seq = entry.seq;
|
||||||
|
match &entry.event {
|
||||||
|
Event::UserMessage {
|
||||||
|
text, attachments, ..
|
||||||
|
} => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.clone(),
|
||||||
|
attachments: attachments.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
// `MessageTaken` is folded into `UserMessage` by the manager before
|
||||||
|
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
||||||
|
// (a raw transcript line, say), it reads the same way.
|
||||||
|
Event::MessageTaken {
|
||||||
|
text, attachments, ..
|
||||||
|
} => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.clone(),
|
||||||
|
attachments: attachments.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::AssistantText { delta } => {
|
||||||
|
// Deltas accumulate into the message they're streaming, which
|
||||||
|
// keeps the seq of the *first* of them: a row whose identity
|
||||||
|
// changed with every delta would be a new row every frame.
|
||||||
|
// "A message growing again is not finished" -- whatever a
|
||||||
|
// status said in between -- is why this always clears
|
||||||
|
// `settled` rather than preserving it.
|
||||||
|
if let Some(TranscriptItem::AssistantMsg {
|
||||||
|
seq: first_seq,
|
||||||
|
text,
|
||||||
|
..
|
||||||
|
}) = items.last()
|
||||||
|
{
|
||||||
|
let first_seq = *first_seq;
|
||||||
|
let text = format!("{text}{delta}");
|
||||||
|
let mut items = items[..items.len() - 1].to_vec();
|
||||||
|
items.push(TranscriptItem::AssistantMsg {
|
||||||
|
seq: first_seq,
|
||||||
|
text,
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
} else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: delta.clone(),
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ToolStart { id, tool, input } => {
|
||||||
|
let run_id = run_id_for(items, id, tool);
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: id.clone(),
|
||||||
|
run_id,
|
||||||
|
tool: tool.clone(),
|
||||||
|
input: input.to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::ToolUpdate { id, output } => update_tool(items, id, |item| {
|
||||||
|
if let TranscriptItem::ToolRun { output: out, .. } = item {
|
||||||
|
*out = output.clone();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Event::ToolEnd { id, output } => {
|
||||||
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
|
update_tool(items, id, |item| {
|
||||||
|
if let TranscriptItem::ToolRun {
|
||||||
|
output: out, done, ..
|
||||||
|
} = item
|
||||||
|
{
|
||||||
|
*out = output.clone();
|
||||||
|
*done = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let run_id = run_id_for(items, id, "tool");
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: id.clone(),
|
||||||
|
run_id,
|
||||||
|
tool: "tool".to_string(),
|
||||||
|
input: String::new(),
|
||||||
|
output: output.clone(),
|
||||||
|
done: true,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Question {
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
header,
|
||||||
|
options,
|
||||||
|
multi_select,
|
||||||
|
about,
|
||||||
|
} => {
|
||||||
|
let card = QuestionCard {
|
||||||
|
seq,
|
||||||
|
id: id.clone(),
|
||||||
|
prompt: prompt.clone(),
|
||||||
|
header: header.clone(),
|
||||||
|
options: options.clone(),
|
||||||
|
multi_select: *multi_select,
|
||||||
|
answers: Vec::new(),
|
||||||
|
};
|
||||||
|
let about_tool = about
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about)));
|
||||||
|
if about_tool {
|
||||||
|
let about = about.clone().unwrap();
|
||||||
|
update_tool(items, &about, move |item| {
|
||||||
|
if let TranscriptItem::ToolRun { asks, .. } = item {
|
||||||
|
asks.push(card.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::QuestionCard(card));
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Answered { id, answers } => items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|item| match item {
|
||||||
|
TranscriptItem::QuestionCard(mut card) if &card.id == id => {
|
||||||
|
card.answers = answers.clone();
|
||||||
|
TranscriptItem::QuestionCard(card)
|
||||||
|
}
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
mut asks,
|
||||||
|
seq,
|
||||||
|
id: tid,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
done,
|
||||||
|
images,
|
||||||
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
|
for ask in asks.iter_mut() {
|
||||||
|
if &ask.id == id {
|
||||||
|
ask.answers = answers.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: tid,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
done,
|
||||||
|
asks,
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Event::PeerMessage {
|
||||||
|
from,
|
||||||
|
text,
|
||||||
|
turn_start,
|
||||||
|
} => place_peer_note(items, seq, from, text, *turn_start),
|
||||||
|
Event::CommandSent { text, .. } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::CommandRow {
|
||||||
|
seq,
|
||||||
|
text: text.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
// Screen-level state, not transcript rows.
|
||||||
|
Event::CommandQueued { .. }
|
||||||
|
| Event::MessageQueued { .. }
|
||||||
|
| Event::MessageDropped { .. }
|
||||||
|
| Event::Settings { .. }
|
||||||
|
| Event::UsageDelta { .. } => items.to_vec(),
|
||||||
|
Event::Status { state } => settle_reply(items, *state),
|
||||||
|
Event::Error { message } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ErrorMsg {
|
||||||
|
seq,
|
||||||
|
message: message.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::Image { image, about } => {
|
||||||
|
let about_tool = about
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about)));
|
||||||
|
if about_tool {
|
||||||
|
let about = about.clone().unwrap();
|
||||||
|
let image = image.clone();
|
||||||
|
update_tool(items, &about, move |item| {
|
||||||
|
if let TranscriptItem::ToolRun { images, .. } = item {
|
||||||
|
images.push(image.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ImageItem {
|
||||||
|
seq,
|
||||||
|
r#ref: image.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Cleared => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ClearedNote { seq });
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::Compacted {
|
||||||
|
pre_tokens,
|
||||||
|
post_tokens,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::CompactedNote {
|
||||||
|
seq,
|
||||||
|
pre_tokens: *pre_tokens,
|
||||||
|
post_tokens: *post_tokens,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||||
|
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||||
|
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||||
|
/// of this crate.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TranscriptRow {
|
||||||
|
Single(TranscriptItem),
|
||||||
|
/// Two or more calls with nothing between them.
|
||||||
|
Tools(Vec<TranscriptItem>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptRow {
|
||||||
|
pub fn key(&self) -> ItemKey {
|
||||||
|
match self {
|
||||||
|
Self::Single(item) => item.key(),
|
||||||
|
Self::Tools(calls) => calls[0].key(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_seq(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Single(item) => item.seq(),
|
||||||
|
Self::Tools(calls) => calls[0].seq(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs of adjacent tool calls become one row; everything else passes
|
||||||
|
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
||||||
|
/// run each call names rather than by adjacency worked out here.
|
||||||
|
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||||
|
|
||||||
|
fn run_id_of(item: &TranscriptItem) -> Option<&str> {
|
||||||
|
match item {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => Some(run_id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flush = |run: &mut Vec<TranscriptItem>, rows: &mut Vec<TranscriptRow>| match run.len() {
|
||||||
|
0 => {}
|
||||||
|
1 => rows.push(TranscriptRow::Single(run.drain(..).next().unwrap())),
|
||||||
|
_ => rows.push(TranscriptRow::Tools(std::mem::take(run))),
|
||||||
|
};
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let joins = matches!(item, TranscriptItem::ToolRun { .. })
|
||||||
|
&& (run.is_empty() || run_id_of(&run[0]) == run_id_of(item));
|
||||||
|
if joins {
|
||||||
|
run.push(item.clone());
|
||||||
|
} else {
|
||||||
|
flush(&mut run, &mut rows);
|
||||||
|
if matches!(item, TranscriptItem::ToolRun { .. }) {
|
||||||
|
run.push(item.clone());
|
||||||
|
} else {
|
||||||
|
rows.push(TranscriptRow::Single(item.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut run, &mut rows);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
||||||
|
/// `Vec<Value>`) into the flat item list this module works over. A line
|
||||||
|
/// this build can't parse fails the whole page rather than being skipped --
|
||||||
|
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
|
||||||
|
/// silently dropping one event could hide, say, a user message that then
|
||||||
|
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
|
||||||
|
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
|
||||||
|
/// fold: "write the logic once" applies to any caller embedding
|
||||||
|
/// `transcript-ui` against a live server, not just the first one.
|
||||||
|
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for value in values {
|
||||||
|
let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| {
|
||||||
|
format!("the server sent a transcript line this build couldn't parse: {e}")
|
||||||
|
})?;
|
||||||
|
items = fold_event(&items, &event);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire `seq` a raw transcript line carries -- the live-stream resume
|
||||||
|
/// cursor after loading a page must be this, not a folded item's `seq()`.
|
||||||
|
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
|
||||||
|
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
|
||||||
|
/// re-deliver every delta already folded into it, duplicating the tail of
|
||||||
|
/// a reply that was mid-stream when the page was fetched -- found via a
|
||||||
|
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
|
||||||
|
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||||
|
value.get("seq")?.as_u64()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, event: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 1.0,
|
||||||
|
event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assistant_deltas_accumulate_into_one_message() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "hel".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "lo".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
settled: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_status_that_stopped_working_settles_the_newest_reply() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "hi".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
settled: true
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_working_status_does_not_settle_anything() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "hi".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
settled: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjacent_tool_calls_group_and_a_lone_one_does_not() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "a".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "b".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let rows = group_tool_runs(&items);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert!(matches!(&rows[0], TranscriptRow::Tools(calls) if calls.len() == 2));
|
||||||
|
|
||||||
|
let solo = fold_all(&[event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "a".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
let rows = group_tool_runs(&solo);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[0],
|
||||||
|
TranscriptRow::Single(TranscriptItem::ToolRun { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_end_with_no_matching_start_still_draws_a_row() {
|
||||||
|
let items = fold_all(&[event(
|
||||||
|
5,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "x".to_string(),
|
||||||
|
output: "done".to_string(),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 5,
|
||||||
|
id: "x".to_string(),
|
||||||
|
run_id: "x".to_string(),
|
||||||
|
tool: "tool".to_string(),
|
||||||
|
input: String::new(),
|
||||||
|
output: "done".to_string(),
|
||||||
|
done: true,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_question_about_a_tool_call_attaches_to_its_row_rather_than_drawing_its_own() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "a".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "run it?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption::plain("yes"), QuestionOption::plain("no")],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
match &items[0] {
|
||||||
|
TranscriptItem::ToolRun { asks, .. } => assert_eq!(asks.len(), 1),
|
||||||
|
other => panic!("expected a ToolRun, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn answering_resolves_a_bare_question_card() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "pick one".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption::plain("a")],
|
||||||
|
multi_select: false,
|
||||||
|
about: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
answers: vec!["a".to_string()],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
match &items[0] {
|
||||||
|
TranscriptItem::QuestionCard(card) => assert_eq!(card.answers, vec!["a".to_string()]),
|
||||||
|
other => panic!("expected a QuestionCard, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(seq: u64, json: serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut obj = json;
|
||||||
|
obj["seq"] = serde_json::json!(seq);
|
||||||
|
obj["ts"] = serde_json::json!(1.0);
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression for a bug a real `run-headless.sh` screenshot found
|
||||||
|
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
|
||||||
|
/// last *item's* seq re-delivers the deltas already folded into a
|
||||||
|
/// still-open assistant message, doubling its tail. `raw_seq` of the
|
||||||
|
/// last wire line must be the true high-water mark instead, which for a
|
||||||
|
/// run of deltas is higher than every item's own `seq()`.
|
||||||
|
#[test]
|
||||||
|
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "a"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "b"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
4,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "c"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let after = raw_seq(values.last().unwrap()).unwrap();
|
||||||
|
assert_eq!(after, 4);
|
||||||
|
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
let assistant_seq = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| matches!(i, TranscriptItem::AssistantMsg { .. }))
|
||||||
|
.unwrap()
|
||||||
|
.seq();
|
||||||
|
assert_eq!(assistant_seq, 2);
|
||||||
|
assert_ne!(
|
||||||
|
after, assistant_seq,
|
||||||
|
"the fixed bug: these must differ here"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_folds_into_one_settled_assistant_message() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "hel"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "lo"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
settled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_line_fails_the_whole_page() {
|
||||||
|
let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})];
|
||||||
|
let err = fold_page(&values).unwrap_err();
|
||||||
|
assert!(err.contains("couldn't parse"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# client-core
|
||||||
|
|
||||||
|
`client-core/` is the app's pure logic held once instead of twice, per
|
||||||
|
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
|
||||||
|
framework dependency of any kind, so it can outlive whichever one the app
|
||||||
|
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
|
||||||
|
`event-model/` is its sibling: the wire shape both this crate and `server/`
|
||||||
|
share, extracted from `server/src/session/driver.rs` and
|
||||||
|
`session/transcript.rs` on 2026-09-04.
|
||||||
|
|
||||||
|
Neither crate is wired into anything yet. `server/` re-exports `event-model`
|
||||||
|
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
|
||||||
|
has no caller -- it exists for whichever experiment in RUST.md picks it up
|
||||||
|
next (a Masonry or iris transcript screen, most likely).
|
||||||
|
|
||||||
|
## What's here, and what Kotlin file it replaces
|
||||||
|
|
||||||
|
| `client-core/src/…` | Kotlin original | Status |
|
||||||
|
|------------------------------------------|-------------------------------------------|--------|
|
||||||
|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
||||||
|
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
||||||
|
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
||||||
|
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
||||||
|
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
||||||
|
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
||||||
|
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||||
|
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||||
|
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
||||||
|
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||||
|
| *(not started)* | `TranscriptSource.kt` | Not started |
|
||||||
|
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||||
|
|
||||||
|
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||||
|
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||||
|
cases ported alongside it, plus new tests for the pieces that had none
|
||||||
|
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
||||||
|
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
||||||
|
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||||
|
them via `session::transcript`'s round-trip coverage).
|
||||||
|
|
||||||
|
## Correspondence notes worth knowing before touching either side
|
||||||
|
|
||||||
|
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
||||||
|
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
||||||
|
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
||||||
|
non-overlapping spans. Whatever UI framework ends up consuming this
|
||||||
|
crate maps `Style` onto its own text-styling type; nothing here should
|
||||||
|
change to accommodate a particular one.
|
||||||
|
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
||||||
|
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
||||||
|
strings. `highlight::span_text` turns a `Span` back into text for a
|
||||||
|
caller working the same way; a caller that wants byte offsets into a
|
||||||
|
`&str` has to convert.
|
||||||
|
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
||||||
|
translation bug while it was being written: an early draft let a
|
||||||
|
*damaged* chunk (one file unreadable, discard just this session) and a
|
||||||
|
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
||||||
|
the same `Err` from one closure, which would have disabled every
|
||||||
|
session's cache over a single corrupt chunk. Fixed by checking a
|
||||||
|
thread-local "was this damage" flag before deciding which failure mode
|
||||||
|
it was -- see the comment on `guard` and the commit message for
|
||||||
|
`transcript_cache.rs`.
|
||||||
|
|
||||||
|
## What `api.rs` covers, and what it does not yet
|
||||||
|
|
||||||
|
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
||||||
|
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
||||||
|
fake transport and no server). `UreqTransport` is the only real
|
||||||
|
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
||||||
|
(blocking, already a project dependency, no extra TLS crate needed since
|
||||||
|
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
||||||
|
|
||||||
|
Covered: session list/read, message send, unqueue, answer, interrupt,
|
||||||
|
stop, start, rename, cwd, model, permission-mode, notify, command,
|
||||||
|
compact, delete, and one transcript page.
|
||||||
|
|
||||||
|
**Not covered, and each is real work rather than a stub to fill in:**
|
||||||
|
setups (`/setups*`, machine and provider discovery), the file explorer
|
||||||
|
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
||||||
|
(`/models*`, HuggingFace browsing and downloads), attachments
|
||||||
|
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
||||||
|
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
||||||
|
the full table to work from when one of these is next.
|
||||||
|
|
||||||
|
## What `transcript_fold.rs` covers, and what it does not yet
|
||||||
|
|
||||||
|
`fold_event` covers every `Event` variant server/ can produce today,
|
||||||
|
including tool-call/question/image attachment and peer-message placement.
|
||||||
|
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||||
|
|
||||||
|
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
||||||
|
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
||||||
|
merges a tool call split across two fetched pages and re-merges a run a
|
||||||
|
boundary cut through. This matters the moment paging backward through
|
||||||
|
history is exercised; it is deliberately left rather than rushed, since
|
||||||
|
it is exactly the kind of boundary logic this project's own "things that
|
||||||
|
have bitten" section warns reads fine and is wrong at the edges.
|
||||||
|
|
||||||
|
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||||
|
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||||
|
A server newer than this build that adds an event type will fail to parse
|
||||||
|
that line rather than degrading to a placeholder row. Closing this means
|
||||||
|
deciding how `event_model` itself represents "a shape I don't recognise"
|
||||||
|
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
||||||
|
fix, so it is recorded here rather than silently worked around.
|
||||||
|
|
||||||
|
## `config.rs`: `EnrolledServer`
|
||||||
|
|
||||||
|
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
||||||
|
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
||||||
|
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
||||||
|
parses on the phone -- so any Rust client enrols from the same text a
|
||||||
|
phone would scan as a QR, with no second format invented for it (RUST.md's
|
||||||
|
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
||||||
|
persisted or under what file permissions -- a phone seals its token in the
|
||||||
|
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
||||||
|
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
||||||
|
caller-specific (the code rules' "ask for the least you need"). Its only
|
||||||
|
caller today is `desktop-app`; a future Android build of this crate would
|
||||||
|
be a second one, not a reason to move the type.
|
||||||
|
|
||||||
|
## What is not started at all
|
||||||
|
|
||||||
|
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||||
|
from the transcript cache or the server, and stitches the two. Needs
|
||||||
|
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
||||||
|
which exist now, so this is unblocked whenever picked up.
|
||||||
|
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
||||||
|
colours a `.md` file or fence for the highlighter, but does not build the
|
||||||
|
block tree (headings, lists, tables, fences as distinct nodes) that a
|
||||||
|
renderer walks to lay out prose versus code versus a table.
|
||||||
|
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
||||||
|
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
||||||
|
UI layer will want its own block parser or a crate for it, decided
|
||||||
|
alongside the framework choice in RUST.md.
|
||||||
|
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||||
|
it flattens a row into bounded units for a *specific* lazy-list
|
||||||
|
framework's composition cost, which is a fact about that framework
|
||||||
|
rather than about the transcript.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||||
|
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||||
|
same way it always has). From `client-core/` directly: `cargo test`,
|
||||||
|
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Decisions taken for Iris to review
|
||||||
|
|
||||||
|
Short list of design choices made by the design agent without asking, so
|
||||||
|
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||||
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-05
|
||||||
|
|
||||||
|
- **iris no longer asks every device for compute-shader limits it never
|
||||||
|
uses.** `adapter.request_device` (both `iris/src/android/render.rs` and
|
||||||
|
`iris/src/default/render.rs`) used `Limits::default()` plus an override
|
||||||
|
for `max_buffer_size`, and `Limits::default()` unconditionally requests
|
||||||
|
desktop-tier compute limits (`max_compute_workgroups_per_dimension:
|
||||||
|
65535`, per `wgpu_types`) even though nothing in `iris`/`iris-core`
|
||||||
|
creates a `ComputePipeline` or writes a `@compute` shader stage —
|
||||||
|
confirmed by grepping the whole tree, not assumed. That crashed
|
||||||
|
`request_device` outright on the Android emulator's software GL path
|
||||||
|
(`EMU_GPU=software`, `--features force-gles`): SwiftShader's GL reports
|
||||||
|
itself as OpenGL ES 3.0, which has no compute shaders at all, so the
|
||||||
|
adapter's real limit is 0 against the unconditional request for 65535 —
|
||||||
|
`RUST.md`'s "Software mode ... crashes for a third, different reason,"
|
||||||
|
2026-09-05, earlier today. The same would happen on any real
|
||||||
|
GLES-3.0-only Android device, not just the emulator. Fixed by a new
|
||||||
|
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`), shared by
|
||||||
|
both platform backends so the two requests cannot drift, that zeros the
|
||||||
|
six `max_compute_*` fields explicitly rather than switching to a
|
||||||
|
downlevel `Limits` preset — `Limits::downlevel_webgl2_defaults()` was
|
||||||
|
considered and rejected: it also zeros
|
||||||
|
`max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex stage
|
||||||
|
reads four `var<storage>` buffers (rects, glyphs, masks, move_offsets),
|
||||||
|
so that preset would trade the compute crash for a bind-group-layout
|
||||||
|
one on the same downlevel hardware this is meant to support. No
|
||||||
|
capability check or fallback path was needed since nothing is being
|
||||||
|
disabled — the request is simply narrowed to what the pipeline actually
|
||||||
|
uses. `rigs/gpu-probe`'s own mirrored limits (it is deliberately its own
|
||||||
|
crate, not a workspace member, so it cannot call `device_limits()`
|
||||||
|
directly) were updated to match, and confirm `IRIS DEVICE: ok` against
|
||||||
|
this VM's own Vulkan and GL adapters. **Not verified this pass**: the
|
||||||
|
specific SwiftShader-ES-3.0 crash this fixes, on-device — the
|
||||||
|
`EMU_GPU=software` cold boot this needs would have force-restarted this
|
||||||
|
checkout's emulator while another session was actively running its own
|
||||||
|
app on it (`com.example.aiapp` had window focus at the time), so it was
|
||||||
|
left for a pass when the emulator is free rather than disrupting that
|
||||||
|
session. Everything reachable without the emulator is clean: `cargo
|
||||||
|
fmt`/`clippy --workspace --all-targets`/`test --workspace`, `cargo ndk
|
||||||
|
build`/`clippy` for `iris-android-app` with `force-gles`, and
|
||||||
|
`gpu-probe` against this VM's own Vulkan and GL(ES 3.2, which still has
|
||||||
|
compute and so would not have reproduced the crash even before this
|
||||||
|
fix — not a substitute for the real ES-3.0 test).
|
||||||
|
|
||||||
|
- **P0's Compose half is built and smoke-tested on the emulator** — the
|
||||||
|
`bench` build type, the shared `app/bench-fixture/` transcript, and an
|
||||||
|
in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that
|
||||||
|
answers `TranscriptSource`/`EventStream` from an in-memory event log
|
||||||
|
instead of a real server, so the fold and paging under test are the real
|
||||||
|
ones. Full account, the smoke run's report, and what is deliberately
|
||||||
|
left (the iris half, the real on-phone runs) are in RUST.md's P0 box.
|
||||||
|
Not a decision to review so much as the gate itself now being runnable —
|
||||||
|
flagged here because it is the first half of something Iris explicitly
|
||||||
|
asked to see before P1.
|
||||||
|
|
||||||
|
- **P0's iris half is also built and smoke-tested on the emulator,
|
||||||
|
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
|
||||||
|
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
|
||||||
|
asset pipeline needed), the same 24-swipe scroll loop animated through
|
||||||
|
`List::scroll` and the same 400-event/20s streaming phase through
|
||||||
|
`fold_event`, "Run benchmark"/"Copy report" as named accessible
|
||||||
|
controls, and the same three added report fields (process CPU time,
|
||||||
|
peak RSS, battery current) via direct JNI calls
|
||||||
|
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
|
||||||
|
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
|
||||||
|
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
|
||||||
|
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
|
||||||
|
`GlobalRef` it can call Java through from any thread. Packaged with a
|
||||||
|
new `release` build type on `iris-android-app`'s own Gradle project
|
||||||
|
(there was previously only `debug`), signed with the same key
|
||||||
|
`app/build-apk.sh` generates. Smoke run and the full report are in
|
||||||
|
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
|
||||||
|
Iris's pass/fail call, which is the actual gate.
|
||||||
|
|
||||||
|
- **The intermittent touch-scroll dropout is root-caused and fixed: a
|
||||||
|
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
|
||||||
|
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
|
||||||
|
touch event, `DragArbiter` state transition and `Selection::drag`
|
||||||
|
dispatch (removed once confirmed), reproduced on this checkout's own
|
||||||
|
emulator against a real sandbox session. The trace showed the actual
|
||||||
|
mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually
|
||||||
|
is, which is not guaranteed to fall inside the same row-local sensor
|
||||||
|
region a later `ACTION_MOVE` in the same gesture lands in (a row's own
|
||||||
|
padding/gap, or its non-selectable sender-name header, is
|
||||||
|
pointer-transparent to `iris::sense::CursorSense`). When that happens,
|
||||||
|
the widget that ends up handling the gesture never saw `PressStart`, so
|
||||||
|
`DragArbiter` sits in `Idle` — which answers every subsequent frame with
|
||||||
|
`Undecided` and has no way to tell "no press is happening" from "a press
|
||||||
|
is happening but I missed its start," so it never recovers on its own
|
||||||
|
for the rest of that gesture. One real trace showed exactly this: touch
|
||||||
|
`Down`/`Move`/`Up` all delivered correctly, but zero `PressStart`
|
||||||
|
reaching the arbiter, `state=Idle` unchanged from first frame to last.
|
||||||
|
Fixed at the call site that has the context to recover
|
||||||
|
(`iris::transcript_ui::selection::Selection::drag`,
|
||||||
|
`iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()`
|
||||||
|
(`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the
|
||||||
|
arbiter still `Idle` — which can only mean a missed `PressStart`, since a
|
||||||
|
`Pressing` sense requires the button to genuinely be down — and start the
|
||||||
|
press there instead of where it was missed. Three new unit tests in
|
||||||
|
`sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s
|
||||||
|
`selection::tests` (the latter fails on the code before this fix).
|
||||||
|
Commit follows. Not the same failure the earlier pass's `DECISIONS.md`
|
||||||
|
DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping
|
||||||
|
slop detection) — that hypothesis is now ruled out; the arbiter's own
|
||||||
|
slop/long-press logic was never wrong. RUST.md's I5 box,
|
||||||
|
"Touch-scroll dropout root-caused, 2026-09-05" has the full trace.
|
||||||
|
- **P0, a phone benchmark gate before any porting, asked for by Iris
|
||||||
|
2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress
|
||||||
|
test on my own phone ... If it doesn't match compose reasonably well then
|
||||||
|
I don't think I'd wanna continue." Design (RUST.md's P0 box has the
|
||||||
|
detail): the same embedded synthetic fixture in both apps with no server
|
||||||
|
needed; the same scripted scroll loop then a streaming phase, run
|
||||||
|
programmatically since the phone has no usable system tracing and no
|
||||||
|
agent can drive it; the same report from both (frames, janky %, p50/p90/
|
||||||
|
p99, process CPU time, peak RSS, battery current where readable) with a
|
||||||
|
copy button; the iris app under its own id and the Compose one as a new
|
||||||
|
`bench` build type with an id suffix, so neither replaces her production
|
||||||
|
install; two arm64 APKs plus instructions delivered under `~/host/bench/`.
|
||||||
|
The gate is hers: iris within a reasonable margin of Compose release on
|
||||||
|
p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the
|
||||||
|
port stops.
|
||||||
|
- **The rest of the port is one UI crate, `iris/app-ui`, grown out of
|
||||||
|
`iris/transcript-ui` rather than started beside it.** It holds a
|
||||||
|
`Screen` enum plus a back stack — the Rust equivalent of `AppRoot.kt`'s
|
||||||
|
`when` — and `iris/desktop-app`/`iris/android-app` become thin entry
|
||||||
|
points over it. Chosen over a fresh crate because `transcript-ui`
|
||||||
|
already has the right generic shape (`Rsc: HasEvents` +
|
||||||
|
`Rsc::State: FocusHost`) and the `client-core`/`event-model` path
|
||||||
|
dependencies every later screen needs, so growing it in place is the
|
||||||
|
smaller diff. Platform-only code (notification service, share target,
|
||||||
|
QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5
|
||||||
|
Java shell (`android-shell/` + `app/shellApp`) rather than moving into
|
||||||
|
this crate, since none of it is a screen. The Android APK is built by
|
||||||
|
`cargo xtask apk` (E5), merging the app-ui cdylib into the E3 shell so
|
||||||
|
there is one app rather than a demo shell plus a service shell.
|
||||||
|
`app/androidApp` (the Compose app) stays untouched and is the baseline
|
||||||
|
every step is measured against, until parity is reached (P7 decides
|
||||||
|
the switch, and is itself a load-bearing decision left to Iris). Order
|
||||||
|
is by risk to the daily-use path: session screen first (P1, where
|
||||||
|
every hard behaviour already lives), then the shell merge and a real
|
||||||
|
phone install (P2), then root tabs (P3), the explorer (P4),
|
||||||
|
settings/enrolment (P5), desktop parity (P6), and the cutover itself
|
||||||
|
(P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)".
|
||||||
|
- **iris gets its own measured frame report, rather than waiting on a
|
||||||
|
`dumpsys`/`gfxinfo` answer that cannot see a `SurfaceView`'s GPU-drawn
|
||||||
|
frames.** `iris_core::FrameReport` (`iris/core/src/render/frame_report.rs`)
|
||||||
|
times each frame's wall clock from the same point `render()`'s redraw
|
||||||
|
starts to just after `queue.submit` + `present()` — the span Compose's
|
||||||
|
own render report and `gfxinfo` both count — into a fixed 4096-entry
|
||||||
|
ring (no allocation per frame; `report()` is the only place that
|
||||||
|
allocates, and only on a button tap). The report gives total frames,
|
||||||
|
janky % over the same 16.7ms budget `gfxinfo` uses, P50/P90/P99 and the
|
||||||
|
worst, plus a reset. Exposed the way the Compose app's copy-button
|
||||||
|
report already is: two named controls ("Frame report", "Reset frame
|
||||||
|
report") on the transcript screen, tappable by accessibility name via
|
||||||
|
`ui-trace`, logging under this crate's fixed `android_logger` tag
|
||||||
|
(`iris-android-app`) so a script can grep `"iris frame report"` the way
|
||||||
|
`transcript-bench.sh` greps `"ai-app render report"`. The report's own
|
||||||
|
`Display` line says plainly that it measures up to the `present()` call
|
||||||
|
returning, not GPU/compositor completion — wgpu's `present()` is not
|
||||||
|
fenced against either, so presenting that span as "time to reach the
|
||||||
|
screen" would be a measured-looking number that is actually inferred,
|
||||||
|
which the standing UI rule forbids.
|
||||||
|
- **`ui-trace` gains a hold-then-drag gesture, additive, in
|
||||||
|
`emulator-tools`.** Neither of its two existing actions can produce
|
||||||
|
"hold stationary for `LONG_PRESS`, then move without lifting" — `tap`
|
||||||
|
has no hold and `swipe X1 Y1 X2 Y2 MS` interpolates motion across its
|
||||||
|
whole duration from t=0. A new action presses, waits, then moves to a
|
||||||
|
second point and releases as one continuous touch (raw
|
||||||
|
`sendevent`/`MotionEvent` injection, extending whatever mechanism the
|
||||||
|
existing `swipe` already uses), so `DragArbiter`'s pan-vs-select rule
|
||||||
|
(`iris/src/sense.rs`, already covered by 8 unit tests against a
|
||||||
|
synthetic clock) can finally be driven on a real device instead of only
|
||||||
|
in a test harness.
|
||||||
|
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
||||||
|
drag pans the list immediately; a stationary press held 500 ms starts a
|
||||||
|
text selection which further dragging extends; a horizontal drag while
|
||||||
|
something is already selected extends that selection without the wait.
|
||||||
|
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
||||||
|
"text layer always wins" or "list always wins" rule because either loses
|
||||||
|
one of the two gestures a reader expects.
|
||||||
|
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
||||||
|
holding `transcript-ui`'s screen beside a session list, talking to a real
|
||||||
|
`ai-server` through `client-core`. It enrols by pasting the same
|
||||||
|
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
||||||
|
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
||||||
|
pinned CA is a path given on the command line, not baked in. Chosen so
|
||||||
|
the phone and desktop share one enrolment format and no second one is
|
||||||
|
invented.
|
||||||
|
- **I5's Android integration extends `iris-android-app` (I2's shell)
|
||||||
|
behind a Cargo feature (`transcript-screen`), rather than a third
|
||||||
|
shell crate.** That project already has the Gradle module, the
|
||||||
|
`IrisView`/`MainActivity` Java, and the JNI registration; the only
|
||||||
|
thing a second screen needs on top is a different `AndroidAppState`,
|
||||||
|
the same axis `tabs_ui::build`/`transcript_ui::build` already vary
|
||||||
|
along on the winit side. `tabs-screen`/`transcript-screen` are
|
||||||
|
mutually exclusive and each pulls in only its own deps, so the plain
|
||||||
|
tabs build (I2/I4) is untouched.
|
||||||
|
- **Order of remaining work, updated 2026-09-05**: the two in-flight
|
||||||
|
pieces and I5's Android integration are all done; next is giving iris
|
||||||
|
its own frame-timing report so item 3 below can be decided by a number.
|
||||||
|
- **DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was
|
||||||
|
the calibration.** Her words: "I think iris definitely makes more sense
|
||||||
|
based on the limitations we've found." The limitations: Masonry has no
|
||||||
|
touch scroll on Android (E2), no per-span rich text and no cross-row
|
||||||
|
selection on the pinned commit (E2), and its keyboard bridge is a TODO
|
||||||
|
(E1); iris carries the same screen under the Compose baseline on the
|
||||||
|
host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What
|
||||||
|
follows: the E-steps are closed as calibration, and the port proceeds
|
||||||
|
on iris — screens, the shell (E3/E5), and `client-core` underneath.
|
||||||
|
The item below is kept as the record of what she decided from.
|
||||||
|
- **Was DEFERRED — whether to commit to iris over Masonry for `ai-app`.**
|
||||||
|
Updated 2026-09-05 with the clean comparison the recommendation wanted:
|
||||||
|
same sandbox session content, same emulator, `EMU_GPU=software`, one
|
||||||
|
session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison,
|
||||||
|
2026-09-05," has the full table and every caveat):
|
||||||
|
|
||||||
|
| app | build | frames | janky % | p50 | p90 | p99 | worst |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Compose (in-app report) | debug | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
|
||||||
|
| Compose (`dumpsys gfxinfo`) | debug | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (p99) | -- |
|
||||||
|
| iris (`FrameReport`) | **release** | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
|
||||||
|
| iris (`FrameReport`, repeat) | **release** | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
|
||||||
|
|
||||||
|
**Not a clean apples-to-apples reading, stated plainly rather than
|
||||||
|
smoothed over**: iris had to be built **release** (debug `SIGSEGV`s on
|
||||||
|
this emulator's Vulkan loader, I4's finding) against Compose's mandated
|
||||||
|
**debug** build, so this asymmetry likely *understates* iris's gap
|
||||||
|
rather than the reverse; the three frame-time sources measure different
|
||||||
|
things (Compose's own phase accounting vs. Android's HWUI deadline-miss
|
||||||
|
definition vs. iris's redraw-start-to-present window, the last of which
|
||||||
|
`dumpsys gfxinfo` cannot see at all for iris's `SurfaceView`); and both
|
||||||
|
figures are emulator numbers under software rasterisation, which
|
||||||
|
Compose's *own* in-app report shows already costs 20-34ms/frame in
|
||||||
|
`swap`+`gpu` alone under this GPU mode, so a same-mode iris number well
|
||||||
|
above 16.7ms was expected going in for either app. A second pair under
|
||||||
|
`-gpu host` was not taken this pass. The earlier session's suspected
|
||||||
|
intermittent touch-delivery dropout was **not reproduced** this pass —
|
||||||
|
the zero-frame results this time traced to this pass's own script bug
|
||||||
|
(a `cd` that changed which emulator `ui-trace` targeted), not the
|
||||||
|
emulator; a CPU-load rise during the gesture was observed by a sampler
|
||||||
|
running throughout, but did not correlate with any failure, so the
|
||||||
|
original candidate is neither confirmed nor ruled out.
|
||||||
|
The choice in front of Iris, updated: decide now on the
|
||||||
|
structural-plus-functional case already made (iris works end-to-end
|
||||||
|
where Masonry's scroll gesture doesn't exist at all on Android) plus
|
||||||
|
this table — reading the two build profiles and three jank definitions
|
||||||
|
with the caveats above rather than as a single number — or ask for a
|
||||||
|
same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full
|
||||||
|
account.
|
||||||
|
|
||||||
|
**Updated 2026-09-05, the `-gpu host` pair taken.** Real GPU rendering
|
||||||
|
(`force-gles` -- the default Vulkan backend has no adapter at all under
|
||||||
|
plain host-GPU boot, confirmed by the exact `wgpu` error) reverses the
|
||||||
|
software-mode shape:
|
||||||
|
|
||||||
|
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
||||||
|
| iris (`FrameReport`) | **release**, `force-gles` | host (virgl) | 62 | 41.94% | 15.0ms | 21.8ms | 37.1ms | 37.1ms | 0.2ms | 12.9ms |
|
||||||
|
|
||||||
|
Under real GPU rendering iris's median frame is *faster* than
|
||||||
|
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
||||||
|
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
||||||
|
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
||||||
|
0.2ms -- almost the entire frame is time spent handing the frame to the
|
||||||
|
driver, not in iris's layout/text/primitive code. This is consistent
|
||||||
|
with the earlier software-mode gap being mostly SwiftShader's CPU
|
||||||
|
rasterisation cost rather than an iris-specific slowness, but is not
|
||||||
|
proof of it: a same-mode software `force-gles` run to isolate the
|
||||||
|
backend crashed for an unrelated reason (SwiftShader's GL path reports
|
||||||
|
itself as OpenGL ES 3.0, which has no compute shaders, and iris's device
|
||||||
|
request assumes them unconditionally) — real scope to fix, not done
|
||||||
|
here — and the two apps' frame populations still differ in kind the same
|
||||||
|
way the software-mode caveats describe. A real intermittent touch-
|
||||||
|
scroll dropout was also reproduced this pass (six consecutive swipes
|
||||||
|
produced zero redraws while taps kept working; an identical retry then
|
||||||
|
succeeded) and is not explained. RUST.md's I5 box, "Where iris's frame
|
||||||
|
time goes, 2026-09-05, the `-gpu host` pass," has the full account. The
|
||||||
|
iris-vs-Masonry choice itself is still Iris's to make.
|
||||||
File renamed without changes.
+439
@@ -0,0 +1,439 @@
|
|||||||
|
# iris: notable public API changes
|
||||||
|
|
||||||
|
For Iris to read on her own time. Each entry is a change to iris's public
|
||||||
|
surface that a widget author or app author would notice: a trait method
|
||||||
|
added, removed or re-shaped; a type that callers construct differently; a
|
||||||
|
capability that moved. Small and trivial changes do not go here.
|
||||||
|
|
||||||
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
|
||||||
|
|
||||||
|
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
|
||||||
|
(`iris/src/android/view.rs`), called once from `new_peer` right after `new`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default does nothing, so every existing implementor (`Client`,
|
||||||
|
`TranscriptClient`) is unaffected. It exists for a caller that needs to call
|
||||||
|
into Java itself beyond what a `RequestRedraw` handle already covers --
|
||||||
|
P0's bench build (`iris-android-app`'s new `bench` feature,
|
||||||
|
`bench_client.rs`/`bench_jni.rs`) uses it to hold a `JavaVM` + `GlobalRef`
|
||||||
|
to the view so its "Copy report" control and once-a-second battery sampler
|
||||||
|
can call `BatteryManager`/`ClipboardManager` through the view's own
|
||||||
|
`Context`, from a background tokio task as well as the UI thread. `new`
|
||||||
|
itself was not extended with these two parameters: most implementors need
|
||||||
|
nothing here, and `new`'s job is building the widget tree, not holding a
|
||||||
|
platform handle. `vm`/`view` are independent handles from the ones
|
||||||
|
`new_peer` keeps for its own `RequestRedraw` (a fresh `get_java_vm`/
|
||||||
|
`new_global_ref` each), so storing them has no effect on that mechanism.
|
||||||
|
|
||||||
|
## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits
|
||||||
|
|
||||||
|
New public function, `iris_core::device_limits() -> wgpu::Limits`. Why:
|
||||||
|
`adapter.request_device`'s `required_limits` was `Limits::default()` plus
|
||||||
|
a `max_buffer_size` override in both platform backends, and
|
||||||
|
`Limits::default()` requests desktop-tier compute-shader limits
|
||||||
|
unconditionally (`max_compute_workgroups_per_dimension: 65535`) even
|
||||||
|
though nothing in `iris`/`iris-core` uses a `ComputePipeline` — that
|
||||||
|
crashed device creation outright on a downlevel GL adapter reporting
|
||||||
|
OpenGL ES 3.0 (no compute shaders at all: the Android emulator's
|
||||||
|
`EMU_GPU=software` path, and any real GLES-3.0-only Android device).
|
||||||
|
`device_limits()` is what both `android::render::AndroidRenderer::new`
|
||||||
|
and `default::render::UiRenderer::new` now build their `required_limits`
|
||||||
|
from, so the request cannot drift between the two backends.
|
||||||
|
|
||||||
|
Before: `Limits { max_buffer_size: 1 << 30, ..Default::default() }`
|
||||||
|
inlined in each backend. After: `iris_core::device_limits()`, which is
|
||||||
|
the same thing with the six `max_compute_*` fields additionally zeroed.
|
||||||
|
A caller building its own `DeviceDescriptor` outside these two backends
|
||||||
|
(there are none today, but a third platform backend would want this)
|
||||||
|
should call `device_limits()` rather than reaching for
|
||||||
|
`Limits::default()` directly, unless it genuinely adds a compute pass —
|
||||||
|
in which case it wants the specific compute limits that pass needs, not
|
||||||
|
the desktop-tier default for everything.
|
||||||
|
|
||||||
|
## 2026-09-05 (later the same day): `iris_core::FrameReport` (RUST.md's I5 box)
|
||||||
|
|
||||||
|
New public type, `iris_core::FrameReport` (re-exported from `iris_core`'s
|
||||||
|
`render` module alongside `FrameStats` and `JANK_THRESHOLD`). Why: `dumpsys
|
||||||
|
gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all, so a
|
||||||
|
`wgpu`-rendered iris screen had no way to ask "was this smooth" the way
|
||||||
|
Compose's own in-app render report already can -- item 3 of RUST.md's
|
||||||
|
recommendation was stuck on a one-sided number for exactly this reason.
|
||||||
|
|
||||||
|
`FrameReport::record(elapsed: Duration)` is called once per frame (wired
|
||||||
|
into `android/view.rs`'s `render()`, wrapping the same span from redraw
|
||||||
|
start to after `queue.submit`+`present()` that Compose's report and
|
||||||
|
`gfxinfo` both count) and writes into a fixed 4096-entry ring -- no
|
||||||
|
allocation on the hot path. `FrameReport::report() -> Option<FrameStats>`
|
||||||
|
gives total frames, janky % (over `JANK_THRESHOLD`, the same 16.7ms 60Hz
|
||||||
|
budget `gfxinfo` uses), P50/P90/P99 and the worst; `None` if nothing has
|
||||||
|
been recorded since the last `reset()`, not a zeroed report that would
|
||||||
|
read as a real measurement. `FrameStats`'s `Display` line says plainly
|
||||||
|
that it measures up to `present()` being called, not GPU/compositor
|
||||||
|
completion, since wgpu's `present()` isn't fenced against either.
|
||||||
|
|
||||||
|
`AndroidUiState` gained a `pub frame_report: FrameReport` field --
|
||||||
|
anything with `HasAndroidUiState` can now read or reset it. Before this,
|
||||||
|
there was no way to ask iris's own render path how long a frame took at
|
||||||
|
all, on any backend.
|
||||||
|
|
||||||
|
Before/after, for a caller that already has `ui_state: &AndroidUiState`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before: no such question could be asked
|
||||||
|
// after:
|
||||||
|
match ui_state.frame_report.report() {
|
||||||
|
Some(stats) => log::info!("iris frame report: {stats}"),
|
||||||
|
None => log::info!("iris frame report: no frames recorded yet"),
|
||||||
|
}
|
||||||
|
ui_state.frame_report.reset(); // via android_state_mut()
|
||||||
|
```
|
||||||
|
|
||||||
|
`iris-android-app`'s transcript screen exposes this as two named,
|
||||||
|
tappable controls ("Frame report", "Reset frame report") rather than
|
||||||
|
requiring a caller to wire its own UI -- see `transcript_client.rs`'s
|
||||||
|
`frame_report_controls`.
|
||||||
|
|
||||||
|
## 2026-09-05: `Tasks::redraw_handle` (RUST.md's I5 Android integration)
|
||||||
|
|
||||||
|
New public method on `iris::task::Tasks`, `redraw_handle(&self) ->
|
||||||
|
Arc<dyn RequestRedraw>`. Why: a caller running its own long-lived loop
|
||||||
|
*inside* one spawned task (a live SSE follow, the Android transcript
|
||||||
|
client's `select_session`) has no other way to ask for a frame after each
|
||||||
|
`TaskCtx::update` -- `Tasks::spawn`'s own wrapper only requests one, after
|
||||||
|
the whole async closure finishes, which fits a single request-then-update
|
||||||
|
but not a stream that needs to be seen redrawing after *each* event. This
|
||||||
|
is the same gap `iris/desktop-app`'s module doc names for why it uses
|
||||||
|
winit's `Proxy<AppEvent>` instead of `Tasks` -- android-view has no
|
||||||
|
`Proxy`, so this is what closes it there.
|
||||||
|
|
||||||
|
**A real bug this uncovered, not a hypothetical**: calling the returned
|
||||||
|
handle's `request_redraw()` from the background thread crashed the process
|
||||||
|
(`SIGABRT`, `Result::unwrap() on an Err value: JavaException`) the first
|
||||||
|
time an Android transcript fetch called it a second time. `android/render.rs`'s
|
||||||
|
`AndroidRedrawHandle` was already attaching the calling thread to the JVM
|
||||||
|
correctly, but its `request_redraw` called `View::post_frame_callback`,
|
||||||
|
whose Java side calls `Choreographer.getInstance()` -- which throws unless
|
||||||
|
the *calling* thread already has a `Looper`, and a tokio worker thread,
|
||||||
|
even freshly JNI-attached, has none. Fixed by routing through
|
||||||
|
`View::post_delayed(0)` instead (Android's own thread-safe "queue work onto
|
||||||
|
this View's UI thread" primitive, needing no caller-side `Looper`), landing
|
||||||
|
on a new `IrisViewPeer::delayed_callback` override that drains tasks and
|
||||||
|
renders -- same body as `do_frame`, on the UI thread where
|
||||||
|
`post_frame_callback` is safe again. Any future caller of `redraw_handle()`
|
||||||
|
from a background thread gets this for free; nothing about the fix is
|
||||||
|
specific to the transcript screen.
|
||||||
|
|
||||||
|
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
|
||||||
|
|
||||||
|
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
|
||||||
|
as its last step, which is right for a window that *is* the transcript
|
||||||
|
screen (the winit example, an eventual Android cdylib) and wrong for the
|
||||||
|
desktop app, which puts a session list beside it. `build_tree` is `build`
|
||||||
|
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
|
||||||
|
of just `TranscriptScreen`, and the caller decides where the tree goes —
|
||||||
|
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
|
||||||
|
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
|
||||||
|
`build_tree` and doing the `set_root` itself, so existing callers are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before, and still available, for a caller that wants to *be* the window:
|
||||||
|
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
|
||||||
|
|
||||||
|
// new, for a caller embedding the screen beside something else:
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
some_widget_ptr(rsc).set(tree);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
||||||
|
|
||||||
|
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
||||||
|
registers both a list-level pan and a row-level drag-to-select on the same
|
||||||
|
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
|
||||||
|
`run_sensors` always gives the innermost layer first refusal, so the inner
|
||||||
|
one wins every frame it is pressed, not just the frame the press started
|
||||||
|
(this is exactly what left transcript-ui's touch-drag panning unreachable
|
||||||
|
until now). `DragArbiter` is one small state machine, one instance per
|
||||||
|
gesture surface (a whole list, not per row), that a caller drives with its
|
||||||
|
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
|
||||||
|
(so it is unit-testable without a real clock or a render harness). It
|
||||||
|
decides the way Android itself does: an ordinary vertical drag pans
|
||||||
|
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
|
||||||
|
selection, which any further drag then extends; a horizontal drag while
|
||||||
|
something is already selected extends it immediately, skipping the wait.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// One per list, held alongside whatever state coordinates the rows:
|
||||||
|
let mut arbiter = DragArbiter::new();
|
||||||
|
|
||||||
|
// On press-down:
|
||||||
|
arbiter.press_start(pos, Instant::now(), already_selected);
|
||||||
|
// Every frame the button/finger stays down:
|
||||||
|
match arbiter.update(pos, Instant::now()) {
|
||||||
|
DragOutcome::Pan(dy) => list.scroll(-dy),
|
||||||
|
DragOutcome::SelectStart => selection.begin(...),
|
||||||
|
DragOutcome::SelectExtend => selection.extend(...),
|
||||||
|
DragOutcome::Undecided => {}
|
||||||
|
}
|
||||||
|
// On release:
|
||||||
|
arbiter.release();
|
||||||
|
```
|
||||||
|
|
||||||
|
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
|
||||||
|
the reference caller: every row's `CursorSense::click_or_drag() |
|
||||||
|
CursorSense::unclick()` handler routes through one `Selection`-owned
|
||||||
|
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
|
||||||
|
on a row's own rendered text now pans the list correctly instead of
|
||||||
|
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
|
||||||
|
`drag_arbiter_tests` module.
|
||||||
|
|
||||||
|
### 2026-09-05, later: `DragArbiter::is_idle()`, recovering a missed `press_start`
|
||||||
|
|
||||||
|
Follow-up to the above, from a real touch-scroll dropout: a gesture's
|
||||||
|
`ACTION_DOWN` can land on a caller's own dead space (a row's padding, a
|
||||||
|
gap, a header with no handler) that never calls `press_start`, so the
|
||||||
|
first frame the arbiter actually sees is a `Pressing`-shaped `update`
|
||||||
|
with no matching start. Before this, `update`'s `Idle` arm had no way to
|
||||||
|
tell that apart from "nothing is happening" and answered `Undecided`
|
||||||
|
forever for the rest of that gesture. `is_idle(&self) -> bool` lets a
|
||||||
|
caller notice the gap and recover: if `is_idle()` is true on a frame the
|
||||||
|
caller knows a press is genuinely down (its own `Pressing`/equivalent
|
||||||
|
sense fired), call `press_start` right there instead of assuming one
|
||||||
|
already happened. `transcript-ui`'s `Selection::drag` is the reference
|
||||||
|
caller — one new match arm, checked before the ordinary `update`-only
|
||||||
|
case. Any other `DragArbiter` caller with the same "one sensor per
|
||||||
|
sub-region, no fallback for dead space" shape has the same gap and wants
|
||||||
|
the same recovery.
|
||||||
|
|
||||||
|
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
|
||||||
|
|
||||||
|
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
|
||||||
|
family, ...) for its whole string, applied via `push_default` into parley's
|
||||||
|
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
|
||||||
|
whichever of colour/family/font size/bold/italic/underline it overrides,
|
||||||
|
pushed with parley's own `push(property, range)` instead. Why: a transcript
|
||||||
|
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
|
||||||
|
wrapped paragraph needs each to carry its own look while the paragraph
|
||||||
|
still wraps and selects as a single buffer — the thing `masonry`'s
|
||||||
|
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
|
||||||
|
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
|
||||||
|
existed at all.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
|
||||||
|
wtext(text)
|
||||||
|
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.add(rsc);
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things a widget author should know before reaching for it:
|
||||||
|
|
||||||
|
- **Call `.spans()` before or after `.editable()`, both work** — the field
|
||||||
|
lives on `TextBuilder` itself, not either output type, and both
|
||||||
|
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
|
||||||
|
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
|
||||||
|
third `TextBuilderOutput` impl without also calling `set_spans` there
|
||||||
|
reproduces the exact bug this box shipped once already (spans silently
|
||||||
|
dropped for `TextEdit`, found only by screenshotting, not by any test —
|
||||||
|
`markdown.rs`'s own unit tests check string/range logic, which is
|
||||||
|
correct in isolation and proves nothing about whether the render path
|
||||||
|
ever sees it).
|
||||||
|
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
|
||||||
|
`color: UiColor` field (from parley's own per-run `Style::brush`), and
|
||||||
|
`Painter::glyphs` draws each glyph in its own colour instead of
|
||||||
|
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
|
||||||
|
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
|
||||||
|
tint a cursor) but no longer drives what a glyph actually renders as.
|
||||||
|
|
||||||
|
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
|
||||||
|
|
||||||
|
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
|
||||||
|
is now load-bearing: it's the one thing that puts a widget in the AccessKit
|
||||||
|
tree `iris_core::ui::access::AccessTree` builds and both backends push
|
||||||
|
out. A widget author who wants a control to be findable by name (and
|
||||||
|
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
|
||||||
|
on it; nothing else is required, and a widget nobody labels is invisible
|
||||||
|
to this system at zero cost, not just zero UI.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let button = rect(Color::LIME)
|
||||||
|
.on(CursorSense::click(), move |_, rsc| { ... })
|
||||||
|
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
|
||||||
|
```
|
||||||
|
|
||||||
|
Two new things a widget author might touch directly:
|
||||||
|
|
||||||
|
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
|
||||||
|
Override it if your widget has a real platform equivalent —
|
||||||
|
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
|
||||||
|
Only consulted for a widget that also has a `.label()`; an unlabelled
|
||||||
|
widget's `access_role` is never called.
|
||||||
|
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
|
||||||
|
with an explicit label, for anything else that wants to walk the same
|
||||||
|
set `AccessTree` does.
|
||||||
|
|
||||||
|
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
|
||||||
|
this sits entirely beside them, reading `resolved_region`'s output rather
|
||||||
|
than participating in producing it.
|
||||||
|
|
||||||
|
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
|
||||||
|
|
||||||
|
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
|
||||||
|
module doc first), for the transcript's kind of screen: variable-height
|
||||||
|
rows, keyed by a `u64`, composed only while visible, moved rather than
|
||||||
|
re-laid-out on scroll, a scroll anchor that survives a row inserted above
|
||||||
|
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
|
||||||
|
a row's height changes (`note_tap`, resolved in the layout pass).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
list.push_back(ListRow::new(key, row_widget)); // O(1)
|
||||||
|
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
|
||||||
|
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
|
||||||
|
list.note_tap(viewport_y); // before mutating a row's height
|
||||||
|
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
|
||||||
|
```
|
||||||
|
|
||||||
|
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
|
||||||
|
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
|
||||||
|
no new mechanism was added to the render core for it. One correctness
|
||||||
|
lesson worth reading even for other widgets: a row that fills whatever
|
||||||
|
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
|
||||||
|
a throwaway oversized region and then merely `reposition`ed into place --
|
||||||
|
`reposition` only ever writes an offset, never a size, so the oversized
|
||||||
|
primitive stays oversized. `List` fixes this by caching each row's real
|
||||||
|
height once measured and placing an already-known row directly at its
|
||||||
|
exact box; see `list.rs`'s `place` for the full reasoning and
|
||||||
|
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
|
||||||
|
|
||||||
|
## 2026-09-05: a second backend (android-view), and what moved to make room for it
|
||||||
|
|
||||||
|
RUST.md's I2. Three changes a widget or app author would notice, all in
|
||||||
|
service of the same thing: `default` (winit) and the new `android`
|
||||||
|
(android-view) backends sharing what does not depend on windowing.
|
||||||
|
|
||||||
|
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
|
||||||
|
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
|
||||||
|
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
|
||||||
|
implements `FocusHost` for anything that has it — so a winit app's
|
||||||
|
existing code is unaffected. An Android app implements `FocusHost` via
|
||||||
|
`HasAndroidUiState` instead. Affects only an app that referenced
|
||||||
|
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
|
||||||
|
rather than through `.attr::<Selectable>(())`, which nothing in-tree
|
||||||
|
does.
|
||||||
|
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
|
||||||
|
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
|
||||||
|
`fn request_redraw(&self)`; `winit::window::Window` implements it
|
||||||
|
(`default/render.rs`), so `Tasks::init(window)` at a call site is
|
||||||
|
unchanged by inference. Only matters if something constructed a `Tasks`
|
||||||
|
directly rather than through `DefaultRsc`/`AndroidRsc`.
|
||||||
|
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
|
||||||
|
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
|
||||||
|
exist on Android; `android/input.rs` drives the same primitives
|
||||||
|
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
|
||||||
|
`ndk::event::Keycode` directly instead. New unconditional getters on the
|
||||||
|
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
|
||||||
|
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
|
||||||
|
`android/ime.rs`'s `InputConnection` bridge needed and that were not
|
||||||
|
previously exposed publicly.
|
||||||
|
|
||||||
|
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
|
||||||
|
|
||||||
|
A widget used to implement three methods (`draw`, `desired_width`,
|
||||||
|
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
|
||||||
|
Painter) -> Size`, which draws into `painter.region()` and returns how much
|
||||||
|
of it was used. Why: the two extra methods routinely re-simulated what
|
||||||
|
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
|
||||||
|
loop to get cross-axis sizing right) — one visit per widget per frame
|
||||||
|
instead of up to three. A container that needs a child's size before
|
||||||
|
placing it (alignment, centering) draws the child once at a provisional
|
||||||
|
region, reads the returned `Size`, and calls the new `Painter::reposition`
|
||||||
|
to move it into its final spot — an O(1) offset write, not a second draw. A
|
||||||
|
widget whose drawn output never depends on the size it's given (a
|
||||||
|
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
|
||||||
|
is_size_independent(&self) -> bool { false }` to `true`, which skips
|
||||||
|
redrawing it when only its offered region changes shape.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||||
|
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
|
||||||
|
// after
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
|
design, the move-offset mechanism this shipped alongside, and the file
|
||||||
|
list.
|
||||||
|
|
||||||
|
## 2026-09-04: texture pipeline rebuilt off the binding array
|
||||||
|
|
||||||
|
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
|
||||||
|
changed shape. Why: the old pipeline bound every texture ever drawn in one
|
||||||
|
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
|
||||||
|
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
|
||||||
|
and it failed outright on the Android emulator's software Vulkan. See
|
||||||
|
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
|
||||||
|
|
||||||
|
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
|
||||||
|
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
|
||||||
|
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
|
||||||
|
&queue, &config)`. Nothing replaces it — there are no more
|
||||||
|
binding-array limits to size.
|
||||||
|
- **`src/default/render.rs`'s device request asks for no features and no
|
||||||
|
binding-array limits.** Before: `required_features:
|
||||||
|
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
|
||||||
|
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
|
||||||
|
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
|
||||||
|
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
|
||||||
|
never about the binding array.
|
||||||
|
- **`TextureHandle` has no `primitive()` method any more**; a caller
|
||||||
|
outside `iris` shouldn't have been calling it (it fed the old renderer's
|
||||||
|
internals), but if something did: use `image_index()` for a standalone
|
||||||
|
image's bind-group index. There is no equivalent for a page — a page has
|
||||||
|
no bind group of its own now, see below.
|
||||||
|
- **`GlyphPrimitive` has no public constructor from a struct literal.**
|
||||||
|
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
|
||||||
|
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
|
||||||
|
flags)` — one `layer` (the shared atlas array's layer) instead of a
|
||||||
|
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
|
||||||
|
texture rather than its own bound texture.
|
||||||
|
- **A widget author drawing images is unaffected**: `Painter::texture`/
|
||||||
|
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
|
||||||
|
What changed underneath is that each standalone image now gets its own
|
||||||
|
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
|
||||||
|
invisible from the widget API, visible only in `UiRenderNode`'s internals
|
||||||
|
and in `iris`'s device requirements.
|
||||||
|
|
||||||
|
## 2026-09-05: `FrameReport` splits each frame at `queue.submit`
|
||||||
|
|
||||||
|
`FrameStats` gains two fields, and `FrameReport` gains a second recording
|
||||||
|
method, to answer "is a slow frame iris's own CPU work or the driver/GPU"
|
||||||
|
with a number instead of a guess (RUST.md's I5 box).
|
||||||
|
|
||||||
|
- **`FrameReport::record_split(total, submit_to_present)`** is a second way
|
||||||
|
to record a frame, alongside the existing `record(total)` (unchanged,
|
||||||
|
and still what a caller with no split should use — it now reads as
|
||||||
|
`cpu_p50 == total`, `gpu_wait_p50 == 0`, rather than fabricating a
|
||||||
|
number for a half it never measured).
|
||||||
|
- **`FrameStats` gains `cpu_p50` and `gpu_wait_p50`**: medians of
|
||||||
|
redraw-start-to-submit and submit-to-after-`present()` respectively,
|
||||||
|
independent of each other and of the existing `p50`/`p90`/`p99`/`worst`
|
||||||
|
(which are unchanged, and still over the whole frame). The Android
|
||||||
|
renderer's `draw()` now returns the `submit_to_present` `Duration` it
|
||||||
|
measured, which `android::view::render()` passes to `record_split`.
|
||||||
|
- **Caveat carried in both doc comments**: `submit_to_present` is not
|
||||||
|
fenced against the GPU actually finishing — it is "how long the CPU was
|
||||||
|
blocked handing the frame to the driver," not a confirmed GPU-completion
|
||||||
|
time. Enough to separate "iris is slow building the frame" from "iris is
|
||||||
|
slow handing it off," not enough to claim an exact GPU budget.
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# iris: known problems and things still to build
|
||||||
|
|
||||||
|
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
||||||
|
matters, so the agents working through RUST.md pick these up in a sensible
|
||||||
|
order rather than rediscovering them. Each item says where it sits in the
|
||||||
|
order and what "done" looks like. Tick and date them in place.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
- [x] **`request_device` asked for compute-shader limits it never uses
|
||||||
|
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
||||||
|
and `iris/src/default/render.rs`) requests desktop-tier compute limits
|
||||||
|
unconditionally, even though nothing in `iris`/`iris-core` creates a
|
||||||
|
`ComputePipeline` or writes a `@compute` shader stage — confirmed by
|
||||||
|
grepping the whole tree, not assumed. That crashed device creation
|
||||||
|
outright on the Android emulator's software GL path (`EMU_GPU=software`,
|
||||||
|
`--features force-gles`): SwiftShader's GL reports itself as OpenGL ES
|
||||||
|
3.0, which has no compute shaders, so the adapter's real limit is 0
|
||||||
|
against the unconditional request for 65535 — the same would happen on
|
||||||
|
any real GLES-3.0-only Android device. Fixed by a new, shared
|
||||||
|
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`) that zeros
|
||||||
|
exactly the six `max_compute_*` fields rather than switching to a
|
||||||
|
downlevel `Limits` preset — `downlevel_webgl2_defaults()` also zeros
|
||||||
|
`max_storage_buffers_per_shader_stage`, which `shader.wgsl`'s vertex
|
||||||
|
stage needs (four `var<storage>` buffers), so that preset would trade
|
||||||
|
this crash for a bind-group-layout one on the same hardware.
|
||||||
|
`rigs/gpu-probe`'s own hand-mirrored `Limits` (it is deliberately its
|
||||||
|
own crate, not able to call `device_limits()` directly) was updated to
|
||||||
|
match. See `DECISIONS.md` and RUST.md's I5 box for the account,
|
||||||
|
including what could not be re-verified on-device this pass (the
|
||||||
|
emulator was in concurrent use by another session).
|
||||||
|
|
||||||
|
- [x] **Input does not fall through by input type (2026-09-04).**
|
||||||
|
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
||||||
|
stop checking lower layers" from mere hover — a widget registered for
|
||||||
|
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
||||||
|
it, since "the cursor is over this widget" and "this widget handled the
|
||||||
|
event" were the same check. Fixed by judging consumption per input
|
||||||
|
kind: with no button transition and no scroll happening this frame
|
||||||
|
("momentary" activity), the topmost hovered widget still wins, same as
|
||||||
|
before; when something momentary *is* happening, only a widget whose
|
||||||
|
registered senses actually include a matching non-hover one (checked
|
||||||
|
via a new `TypeEventManager::registered`, which lists what a widget
|
||||||
|
registered without running anything) consumes it, so a widget with only
|
||||||
|
`Hovering`/click handlers can no longer block a scroll from reaching a
|
||||||
|
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
||||||
|
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
||||||
|
directions: a scroll over the button reaches the list, and a real click
|
||||||
|
still reaches the button — confirmed to fail on the pre-fix code and
|
||||||
|
pass after.
|
||||||
|
|
||||||
|
- [x] **Appending one image to an already-loaded list rebuilds every other
|
||||||
|
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
||||||
|
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
||||||
|
triggered `rebuild_image_bind_groups` — a loop over *every live
|
||||||
|
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
||||||
|
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
||||||
|
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
||||||
|
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
||||||
|
every widget gets one on first draw) could be exactly what grows that
|
||||||
|
buffer. So one new message with one new image, appended to a transcript
|
||||||
|
that already has N images loaded, did not cost O(1): it cost one
|
||||||
|
`create_image` for the new image plus one `make_image_bind_group` per
|
||||||
|
*existing* image, because the new widget's own move slot pushed the
|
||||||
|
arena past its capacity. Measured directly in
|
||||||
|
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
||||||
|
already-settled ones reported **1,001** bind-group creates for that one
|
||||||
|
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
||||||
|
|
||||||
|
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
||||||
|
bind group (group 2) in the first place — the group also holds that
|
||||||
|
image's own texture view, which is the only thing that is genuinely
|
||||||
|
per-image, so a buffer shared by *everything* forced a rebuild of
|
||||||
|
*every* group the moment it moved. Gave masks/move_offsets their own
|
||||||
|
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
||||||
|
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
||||||
|
once per draw call, instead of duplicating them into every per-image
|
||||||
|
group. `GpuTextures` and its image bind groups now know nothing about
|
||||||
|
either buffer — `rebuild_image_bind_groups` is called only from
|
||||||
|
`grow_array` (the atlas array texture growing, which genuinely does
|
||||||
|
change what every image's own bind group must reference) — so a
|
||||||
|
masks/move_offsets resize now touches exactly one bind group, ever,
|
||||||
|
regardless of how many images are live. Numbers after the fix, same
|
||||||
|
benchmark and command:
|
||||||
|
|
||||||
|
./run-bench.sh images
|
||||||
|
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
||||||
|
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1 (was 1001)
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
||||||
|
confirming the bind-group restructuring changed nothing about what is
|
||||||
|
drawn.
|
||||||
|
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
||||||
|
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
||||||
|
loading 1,000 images cold used to report 1,000 creates on frame 1
|
||||||
|
(expected — `create_image`, one per new image) *and again* 1,000 on
|
||||||
|
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
||||||
|
firing a second time for the same masks/move-offsets buffer-growth
|
||||||
|
reason as the item above, confirming the guess recorded here — the two
|
||||||
|
were exactly the same root cause measured two different ways. Frame 2
|
||||||
|
now reports 0 (see the numbers above); not a separate fix.
|
||||||
|
|
||||||
|
- [ ] **A read-only text display has no widget of its own — P0's bench
|
||||||
|
report area is a `TextEdit` standing in for one (2026-09-05).** The only
|
||||||
|
way to get selectable text on screen today is `.editable(...)` plus
|
||||||
|
`.attr::<Selectable>(())` (`Selectable` is only implemented for
|
||||||
|
`TextEdit`, `iris/src/attr.rs`), which also makes the field focusable —
|
||||||
|
tapping the bench report opens the soft keyboard over text nothing lets
|
||||||
|
you type into. Harmless for a bench-only debug screen (not fixed this
|
||||||
|
pass), but a real "selectable, not editable" text primitive would
|
||||||
|
remove the keyboard side effect and is worth having before another
|
||||||
|
screen wants the same thing (P1's own transcript rows already read
|
||||||
|
their content from a `TextEdit` for the same reason).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||||
|
`benches/` or a script under `iris/`, never in `cargo test`). The
|
||||||
|
scenario that matters most is a **message list** — chat apps and this
|
||||||
|
app's transcript alike — stressed with many messages and many images.
|
||||||
|
One case in particular: **resizing an input box** (typing enough text to
|
||||||
|
grow it) that pushes a long list of messages above it must stay very
|
||||||
|
fast and recalculate almost nothing — a move of everything above, not a
|
||||||
|
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
||||||
|
benchmark is what proves it. Done when the numbers are in this file with
|
||||||
|
the command, and the input-box case reports draws re-run, not just frame
|
||||||
|
time.
|
||||||
|
|
||||||
|
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
||||||
|
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
||||||
|
most of this runs as an ordinary binary — the same property
|
||||||
|
`layout_tests.rs` relies on):
|
||||||
|
|
||||||
|
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
||||||
|
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
||||||
|
the file's own header for why (short version — every scenario here
|
||||||
|
reduces to a *count* `UiRenderState::take_counters` already produces,
|
||||||
|
which criterion's statistical machinery adds nothing to and which a
|
||||||
|
new dependency is not worth pulling in for). Covers (a) first-frame
|
||||||
|
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
||||||
|
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
||||||
|
of scrolling that list, 200 ticks; (c) the input-box case — a
|
||||||
|
fixed-height field at the bottom of the screen growing by a line 40
|
||||||
|
times, with the message list above it filling the rest of the screen.
|
||||||
|
Run: `cd iris && cargo bench --bench message_list` (always release —
|
||||||
|
`cargo bench` builds the `bench` profile, which is optimized).
|
||||||
|
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
||||||
|
through `iris/run-headless.sh bench_images`, printing
|
||||||
|
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
||||||
|
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
||||||
|
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
||||||
|
image rows, checked both cold (does bind-group creation reach zero
|
||||||
|
once loaded) and after appending one more image once settled (does
|
||||||
|
*that* stay cheap) — the second question is what actually matters for
|
||||||
|
a live transcript and is what turned up the two Fix items above.
|
||||||
|
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
||||||
|
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
||||||
|
chain, or `GpuTextures`.
|
||||||
|
|
||||||
|
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
||||||
|
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
||||||
|
|
||||||
|
cd iris && cargo bench --bench message_list
|
||||||
|
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
||||||
|
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
||||||
|
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
||||||
|
(b) scroll, N=100/1000/10000, 200 ticks each:
|
||||||
|
draws=200 rewrites=0 moves=200 (identical at every N)
|
||||||
|
per-tick average: 0.0002ms (identical at every N)
|
||||||
|
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
||||||
|
draws=320 rewrites=40 moves=160 (identical at every N)
|
||||||
|
per-line average: 0.0012-0.0013ms (identical at every N)
|
||||||
|
|
||||||
|
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
||||||
|
frame=1 bind_group_creates=1000 (cold load)
|
||||||
|
frame=2 bind_group_creates=1000 (see Fix item above)
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1001 (see Fix item above)
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
||||||
|
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
||||||
|
frame=2 bind_group_creates=0
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
||||||
|
never-before-seen text rows — and scales with N as it must, ~10x cost
|
||||||
|
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
||||||
|
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
||||||
|
move chain holds for both scrolling and for a growing input box pushing
|
||||||
|
the message list — draws/moves per tick or per line do not grow with
|
||||||
|
list size, and the per-operation cost (a fraction of a microsecond) is
|
||||||
|
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
||||||
|
behave as designed; its *append* half did not, until the fix above moved
|
||||||
|
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
||||||
|
the same way (b) and (c) are.
|
||||||
|
|
||||||
|
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
|
||||||
|
left, each recorded at the point in the code it would go rather than
|
||||||
|
silently dropped. See RUST.md's I5 box for the full account of what
|
||||||
|
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
|
||||||
|
composer).**
|
||||||
|
- [x] **Android integration for this screen — done, 2026-09-05.**
|
||||||
|
`iris-android-app`'s `transcript-screen` Cargo feature
|
||||||
|
(`transcript_client.rs`) runs this screen against a real `ai-server`
|
||||||
|
through `client-core`, confirmed on-device: real scrolling, real
|
||||||
|
touch-drag panning, tap-by-name on the composer. Two real bugs found
|
||||||
|
and fixed along the way (a missing `INTERNET` permission; a
|
||||||
|
background-thread redraw request that crashed via a `Looper`
|
||||||
|
requirement, fixed by routing through `View::post_delayed` — see
|
||||||
|
`IRIS.md`'s `Tasks::redraw_handle` entry). See RUST.md's I5 box,
|
||||||
|
"The Android integration, done 2026-09-05" for the full account.
|
||||||
|
- [x] **A render-time number for iris, comparable to Compose's
|
||||||
|
`transcript-bench.sh` report — instrumentation done and a real number
|
||||||
|
obtained, 2026-09-05 (later the same day); the clean comparable loop
|
||||||
|
is not.** `iris_core::FrameReport` (`iris/core/src/render/
|
||||||
|
frame_report.rs`, `IRIS.md`'s new entry) times every frame from
|
||||||
|
`render()`'s redraw start to after `queue.submit`+`present()`, exposed
|
||||||
|
as two named on-screen controls ("Frame report", "Reset frame
|
||||||
|
report"). Driven against a real on-device touch-drag it read
|
||||||
|
`frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
|
||||||
|
worst=98.1ms` — real, not inferred, but accumulated across several
|
||||||
|
gestures rather than one clean 24-swipe loop, because of the new
|
||||||
|
finding below. See RUST.md's I5 box, "Update, 2026-09-05, later the
|
||||||
|
same day" for the full account.
|
||||||
|
- [ ] **New, 2026-09-05: intermittent touch delivery to iris's
|
||||||
|
`SurfaceView` under this checkout's `EMU_GPU=software` emulator.**
|
||||||
|
The same swipe coordinates, confirmed (by scanning a screenshot
|
||||||
|
column for the first non-black pixel) to sit over real row text,
|
||||||
|
sometimes produced 30+ real frames and a screenshot diff and
|
||||||
|
sometimes produced zero of either, across otherwise-identical
|
||||||
|
`ui-trace` invocations. Not the already-understood "already at that
|
||||||
|
scroll edge" case (reproduced with content confirmed taller than the
|
||||||
|
viewport, in both directions). Leading candidate, not yet confirmed:
|
||||||
|
this checkout's emulator was independently observed at ~78% of one
|
||||||
|
CPU core, continuously, while idle on-screen — SwiftShader's software
|
||||||
|
rasterisation is CPU-bound by design, and a synthetic touch competing
|
||||||
|
with that load for delivery is plausible but unmeasured *during* a
|
||||||
|
failing gesture (the standing rule against diagnosing from
|
||||||
|
after-the-fact measurements applies here). Needs a sampler (load,
|
||||||
|
`dumpsys input`, a `-i 0` `ui-trace` capture) running while a failing
|
||||||
|
gesture is driven, and ideally a comparison under `-gpu host` (real
|
||||||
|
Vulkan) to see whether it is specific to software rendering. This is
|
||||||
|
what blocks the clean, comparable 24-swipe loop above.
|
||||||
|
- [x] **Long-press-then-drag-to-select — confirmed on-device, 2026-09-05
|
||||||
|
(later the same day).** `ui-trace` gained a `holddrag X1 Y1 X2 Y2
|
||||||
|
HOLD_MS MOVE_MS` action (`emulator-tools`, additive, extends the same
|
||||||
|
`MotionEvent`/`injectInputEvent` mechanism `swipe` already used):
|
||||||
|
press, hold past `LONG_PRESS`, move, release, as one continuous touch.
|
||||||
|
Driven against a real row (`holddrag 300 1850 300 2050 600 300`) it
|
||||||
|
produced `iris selection: begin at row ...` then a sequence of
|
||||||
|
`iris selection: extend to row ...` log lines
|
||||||
|
(`transcript-ui/src/selection.rs`, a new small `log` dependency since
|
||||||
|
selection has no accessibility label of its own yet — see the next
|
||||||
|
item), and a screenshot taken right after shows the expected
|
||||||
|
highlighted selection spanning multiple rows. `DragArbiter`'s own
|
||||||
|
unit tests already covered this sequence against a synthetic clock;
|
||||||
|
this is the first time it has been driven by a real device touch.
|
||||||
|
- [x] **Touch-drag panning over a row's own rendered text — done,
|
||||||
|
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
|
||||||
|
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
|
||||||
|
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
|
||||||
|
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
||||||
|
arbitration on every frame it was pressed, not just the frame the
|
||||||
|
press started, so a list pan gesture registered on `List` itself never
|
||||||
|
got a turn while a row was under the finger. Fixed with
|
||||||
|
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
||||||
|
machine per list deciding pan vs. select the way Android does (a
|
||||||
|
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
||||||
|
(500ms) starts a selection which further drag extends; a horizontal
|
||||||
|
drag while something is already selected extends immediately) —
|
||||||
|
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
||||||
|
every row's drag now routes through. 8 new unit tests
|
||||||
|
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
||||||
|
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
||||||
|
clean; `run-headless.sh` screenshot byte-identical to before the
|
||||||
|
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
||||||
|
- [x] **Intermittent touch-scroll dropout — root-caused and fixed,
|
||||||
|
2026-09-05.** Not the coalesced-`ACTION_MOVE` hypothesis the earlier
|
||||||
|
pass suspected (ruled out): a gesture's `ACTION_DOWN` can land on a
|
||||||
|
row's own padding/gap or its header, which no `CursorSense` covers,
|
||||||
|
so `DragArbiter` never gets `press_start` and sits in `Idle`
|
||||||
|
(answers `Undecided` forever) for that whole gesture. Fixed via a new
|
||||||
|
`DragArbiter::is_idle()` that `Selection::drag`
|
||||||
|
(`transcript-ui/src/selection.rs`) checks to recover a missed press
|
||||||
|
on the next `Pressing` frame. Four new unit tests. See RUST.md's I5
|
||||||
|
box, "Touch-scroll dropout root-caused, 2026-09-05", for the trace and
|
||||||
|
what a peer session sharing this checkout's emulator mid-pass
|
||||||
|
prevented from being re-verified end-to-end (the aggregate
|
||||||
|
`iris-scroll.sh` three-run confirmation and a re-taken FrameReport
|
||||||
|
row) — a future pass should finish that once the emulator is free.
|
||||||
|
- [ ] **Row-level accessibility names.** The composer carries
|
||||||
|
`.label("Message")`; transcript rows do not carry a `.label()` of
|
||||||
|
their own yet, so `Widgets::named()` (I4) does not include them —
|
||||||
|
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
||||||
|
stable per row (its sender + a short excerpt, matching what a screen
|
||||||
|
reader announcing a chat message would say).
|
||||||
|
- [ ] **A tappable link and a background chip behind inline code.**
|
||||||
|
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
||||||
|
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
||||||
|
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
||||||
|
would take (the same primitive `TextEdit::draw`'s own selection
|
||||||
|
highlight already uses internally,
|
||||||
|
`iris/src/widget/text/edit.rs:99`).
|
||||||
|
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||||
|
is selected in full (`select_all`) the moment the drag leaves it,
|
||||||
|
rather than "from the click point to whichever edge points away from
|
||||||
|
the drag" — needs the same private `layout()` access as the item
|
||||||
|
above. `selection.rs`'s module doc has the exact reasoning.
|
||||||
|
- [ ] **No syntax highlighting inside a fenced code block.**
|
||||||
|
`client_core::highlight` exists (built for the file explorer) and
|
||||||
|
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
||||||
|
in was not attempted this pass.
|
||||||
|
|
||||||
|
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||||
|
by something *and also* applies mask B — a mask can reference a parent
|
||||||
|
mask, the way the move chain references a parent offset. Today masks
|
||||||
|
are independent regions. Design it beside the move chain (same shape:
|
||||||
|
a parent index and a bounded walk in the shader); do it when a real
|
||||||
|
widget needs it, not before.
|
||||||
|
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
||||||
|
rejected, letting a scroll update one float rather than positions:
|
||||||
|
input handling cares about most elements in a list, so absolute
|
||||||
|
positions must be computed on the CPU anyway. LAYOUT.md's design
|
||||||
|
already lands here (GPU walks the chain, CPU resolves on demand for
|
||||||
|
hit tests). Keep the CPU resolution lazy and per query; do not
|
||||||
|
materialise every row's absolute position per frame.
|
||||||
|
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
||||||
|
**modular — a piece of the library rather than a core part forced into
|
||||||
|
everything, the same way input is**. Whatever the mechanism, a widget
|
||||||
|
that does not animate must pay nothing and import nothing for it.
|
||||||
|
|
||||||
|
## Build (for the port)
|
||||||
|
|
||||||
|
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||||
|
iris does not have yet, one entry per gap, named against the P-step that
|
||||||
|
first needs it. Move an entry up to "Fix" or tick it in place once built;
|
||||||
|
do not duplicate it there.
|
||||||
|
|
||||||
|
- [ ] **A history-paging cushion measured in on-screen viewports, not a
|
||||||
|
row count.** (**P1**.) `iris::widget::List` has no equivalent of the
|
||||||
|
Compose app's `HISTORY_SCREENS` — AGENTS.md's "Things that have
|
||||||
|
bitten" is explicit that a fixed row count under-fills a screen on a
|
||||||
|
tool-heavy transcript and over-fills one on a text-heavy one, so
|
||||||
|
whatever loads the next page has to ask the list how many viewports
|
||||||
|
are actually on screen, not assume a constant.
|
||||||
|
- [ ] **A scaled thumbnail/image widget for an in-transcript image.**
|
||||||
|
(**P1**.) `SessionImage.kt`'s bitmap decode-and-downscale has no iris
|
||||||
|
counterpart; iris's own image widget (used by `bench_images.rs`) draws
|
||||||
|
a loaded texture but does nothing about sourcing or scaling one from a
|
||||||
|
server-produced attachment.
|
||||||
|
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
|
||||||
|
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
|
||||||
|
equivalent, and the delete-with-`deleteForeign` confirmation with its
|
||||||
|
toggle switch. Build once, wherever it is first needed, rather than
|
||||||
|
once per screen that wants one.
|
||||||
|
- [ ] **A horizontal gauge/bar widget.** (**P1**.) For
|
||||||
|
`SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction,
|
||||||
|
nothing fancier.
|
||||||
|
- [ ] **A `BusyItem` equivalent: a dimmed row carrying an operation
|
||||||
|
label that does not block its list's own scroll/drag.** (**P3**.) The
|
||||||
|
Compose version tried an overlay first and it swallowed the drag along
|
||||||
|
with the tap (AGENTS.md's "Shared appearance") — worth not repeating
|
||||||
|
that attempt in iris before building the row-level version directly.
|
||||||
|
- [ ] **A toggle switch.** (**P3**.) For the delete dialog's
|
||||||
|
`deleteForeign` control; iris has no switch/checkbox widget yet as far
|
||||||
|
as this pass found.
|
||||||
|
|
||||||
|
## Reconsider
|
||||||
|
|
||||||
|
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
||||||
|
to compose a widget from others (a button is the main case). With
|
||||||
|
sizing folded into `draw`, composing may be easy enough that `View` is
|
||||||
|
redundant. Decide after the layout change lands, by writing a button
|
||||||
|
both ways and keeping the one that is shorter to explain; delete the
|
||||||
|
other rather than keeping two ways.
|
||||||
+897
@@ -0,0 +1,897 @@
|
|||||||
|
# iris: one `draw` that reports a size
|
||||||
|
|
||||||
|
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
||||||
|
any design or code so that it survives a cleared session. **Status: implemented
|
||||||
|
2026-09-04, against every pass condition in §8** (measured, not assumed — see
|
||||||
|
that section). Every widget listed in §7 was migrated in one change; none
|
||||||
|
kept `desired_width`/`desired_height`. Five points needed correction or
|
||||||
|
refinement beyond what this file originally specified — see "Deviations
|
||||||
|
found during implementation" below, added right before "For IRIS.md" — read
|
||||||
|
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
|
||||||
|
move-slot lifecycle in `render_state.rs`, since each of those five is a real
|
||||||
|
bug this file's first draft would have reproduced if implemented literally.
|
||||||
|
|
||||||
|
## What Iris asked for
|
||||||
|
|
||||||
|
> I don't like that widgets need both a draw and size functions. I'd much
|
||||||
|
> rather them have a single draw that reports a size, and if it needs to be
|
||||||
|
> moved then that can be done after the fact efficiently, or resized just
|
||||||
|
> done after as well. This should be done efficiently like everything else
|
||||||
|
> tries to do right now.
|
||||||
|
|
||||||
|
She added, a few minutes later: "single draw is not a requirement. It
|
||||||
|
just seems more efficient from what I've heard. Feel free to override any
|
||||||
|
decision I've made if you can find a genuinely better & still clean
|
||||||
|
alternative." So the single-draw model is the default to design against,
|
||||||
|
and the design below may reject it, but only with a written comparison
|
||||||
|
showing the alternative does less work per frame and is no harder to use.
|
||||||
|
|
||||||
|
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
|
||||||
|
little processing as possible per frame, but the model must cover every
|
||||||
|
layout need a real app has (the transcript's virtualised list, wrapped
|
||||||
|
text whose height depends on width, rows and columns that size to their
|
||||||
|
children, overlays, masks).
|
||||||
|
|
||||||
|
## What exists today
|
||||||
|
|
||||||
|
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
|
||||||
|
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
|
||||||
|
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
|
||||||
|
is memoised per widget id and axis in `Cache.size` keyed on the outer
|
||||||
|
size, then places the child with `Painter::widget_within(region)`. So a
|
||||||
|
child is visited twice (sized, then drawn), every widget implements sizing
|
||||||
|
twice (one per axis), and a widget whose size depends on what it draws
|
||||||
|
(wrapped text, a laid-out paragraph) does the layout in the size pass and
|
||||||
|
again in the draw pass unless it caches by hand.
|
||||||
|
|
||||||
|
Primitives are already positioned by `UiRegion` values whose scalars have
|
||||||
|
a `rel` and an `abs` part, resolved against the window in the vertex
|
||||||
|
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
|
||||||
|
exists to rewrite one instance's region in place. That is the mechanism a
|
||||||
|
"move after the fact" can build on.
|
||||||
|
|
||||||
|
## What the design must answer
|
||||||
|
|
||||||
|
1. **Parent-before-child ordering.** A row has to know each child's width
|
||||||
|
to place the next one, but under "one draw" the child's size only
|
||||||
|
exists after it has drawn. The answer is meant to be: the child draws
|
||||||
|
at a provisional origin, reports its size, and the parent *moves* it.
|
||||||
|
The move must be O(1) per moved subtree, not O(primitives in the
|
||||||
|
subtree). One way: every instance carries an index into a small
|
||||||
|
per-widget offset buffer, so moving a widget writes one entry and the
|
||||||
|
vertex shader adds it. Other ways may be better; the design should say
|
||||||
|
what was considered.
|
||||||
|
2. **Move vs resize are different costs and must be kept apart.** A move
|
||||||
|
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
|
||||||
|
whose size input changed, and a widget whose output does not depend on
|
||||||
|
its size (an icon, a fixed rect) must be able to say so and be skipped.
|
||||||
|
3. **Size-dependent content.** Wrapped text is the hard case: its height
|
||||||
|
is a function of its width. A single `draw` receives the available
|
||||||
|
size (what `SizeCtx.outer` is today) and reports what it used, so the
|
||||||
|
two-pass "measure then draw" collapses into one for the common case.
|
||||||
|
The design must say what happens when a parent wants the child's
|
||||||
|
height *before* deciding the width it will offer (rare; say whether it
|
||||||
|
is supported, or is done by drawing twice as an explicit, opt-in cost).
|
||||||
|
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
|
||||||
|
replacement should memoise the whole draw result by (id, available
|
||||||
|
size) so that an unchanged subtree costs nothing on the next frame,
|
||||||
|
which is what makes a virtualised list cheap.
|
||||||
|
5. **Everything currently written against `desired_width`/`desired_height`
|
||||||
|
moves over in one change**, per the code rules: two names for one
|
||||||
|
concept is not an intermediate state to leave behind. The widgets are
|
||||||
|
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
|
||||||
|
whatever else is there when the change is made).
|
||||||
|
|
||||||
|
## Order relative to the texture work
|
||||||
|
|
||||||
|
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
|
||||||
|
`Primitives`, `Painter`'s texture calls). This change touches the widget
|
||||||
|
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
|
||||||
|
mechanism the vertex shader needs. They overlap in `Painter` and the
|
||||||
|
shader, so they are done **in sequence, textures first**, and the layout
|
||||||
|
design here is written (not implemented) while the texture work is in
|
||||||
|
progress, then implemented on top of it.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. The new `Widget` trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Widget: Any {
|
||||||
|
/// Draw within `painter.region()` (the space the parent offered) and
|
||||||
|
/// report how much of it was actually used, per axis.
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
|
|
||||||
|
/// True if `draw`'s output (both the primitives it writes and the
|
||||||
|
/// `Size` it returns) is the same for any `painter.region()` of the
|
||||||
|
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
||||||
|
/// image at its natural size. Default `false` (redraw on any change to
|
||||||
|
/// the offered region) because assuming independence wrongly produces
|
||||||
|
/// a stale draw; a widget must opt in.
|
||||||
|
fn is_size_independent(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `available` parameter: `Painter` already carries the region the parent
|
||||||
|
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
||||||
|
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
||||||
|
size (`output_size()`, `:152`). Passing it again would be the same value
|
||||||
|
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
||||||
|
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
||||||
|
deprecated, not kept as a fallback — because a widget that implements both
|
||||||
|
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
||||||
|
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
||||||
|
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
||||||
|
own comment: "this literally copies draw so that the lengths are correctly
|
||||||
|
set in the context, which makes this slow and not cool." Folding sizing into
|
||||||
|
`draw` deletes that duplicate simulation, not just moves it.
|
||||||
|
|
||||||
|
**No single-draw alternative was found that does less work per frame.** The
|
||||||
|
two-method trait was checked against three properties a real screen needs —
|
||||||
|
a row placing children in sequence, a widget centering on its own content,
|
||||||
|
and wrapped text — and in every one, `draw` already has to visit the child
|
||||||
|
to get a size that is *this specific one's* answer, which today's
|
||||||
|
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
||||||
|
the same layout the draw pass will do again. So the two-method trait is not
|
||||||
|
"measure once, draw once" in the general case; it is "measure once per axis,
|
||||||
|
then draw once," i.e. up to three visits per widget per frame, against one
|
||||||
|
under the design here. The single-draw model is therefore adopted as
|
||||||
|
proposed, not merely accepted as a preference.
|
||||||
|
|
||||||
|
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
||||||
|
|
||||||
|
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
||||||
|
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
||||||
|
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
||||||
|
`active.region.size() == region.size()` after excluding the exact-match
|
||||||
|
case). It rewrites every primitive's `region` field via
|
||||||
|
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
||||||
|
widget's own primitives, then recurses into every child — O(primitives in
|
||||||
|
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
||||||
|
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
||||||
|
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
||||||
|
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
||||||
|
re-touches every glyph in every visible row, every frame of the drag, and
|
||||||
|
I3's target is 800 rows on screen.
|
||||||
|
|
||||||
|
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
||||||
|
resolved in the vertex shader.**
|
||||||
|
|
||||||
|
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
||||||
|
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
||||||
|
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
||||||
|
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
||||||
|
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
||||||
|
add nothing more"). A pure abs-pixel translation, not a general
|
||||||
|
`UiRegion` remap — sufficient for every existing call site (above).
|
||||||
|
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
||||||
|
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
||||||
|
kind of per-instance handle.
|
||||||
|
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
||||||
|
assigned **when the widget is first drawn** (`draw_inner`, beside
|
||||||
|
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
||||||
|
`Painter` threads a `move_slot` field down exactly as it already threads
|
||||||
|
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
||||||
|
correct from its first frame — nothing is ever retrofitted onto an
|
||||||
|
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
||||||
|
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
||||||
|
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
||||||
|
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
||||||
|
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
||||||
|
recursion, since descendants already reference this slot transitively.
|
||||||
|
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
||||||
|
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
||||||
|
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
||||||
|
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
||||||
|
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
||||||
|
paid every frame regardless of whether anything moved — negligible next
|
||||||
|
to the per-fragment texture sampling TEXTURES.md already measures this
|
||||||
|
GPU as not bound by.
|
||||||
|
|
||||||
|
**Why the chain, not the flatter thing first proposed.** Iris's own
|
||||||
|
phrasing — "every instance carries an index into a small per-widget offset
|
||||||
|
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
||||||
|
no parent link. It breaks the moment two such subtrees nest — a row inside
|
||||||
|
a scrolling list, itself later given its own animated offset (a
|
||||||
|
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
||||||
|
pick one slot and lose the other's contribution. The chain costs one extra
|
||||||
|
field and a bounded shader loop in exchange for no such gap, and since
|
||||||
|
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
||||||
|
no more at the common depth of one than the flat version would.
|
||||||
|
|
||||||
|
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
||||||
|
O(primitives in the subtree) — the cost this section removes — but kept
|
||||||
|
for a resize that changes a region's `rel` component (a genuine reflow,
|
||||||
|
§3) and for a size-independent widget's resize (§3), where the content's
|
||||||
|
shape doesn't change and one field write already suffices.
|
||||||
|
|
||||||
|
### 2b. Two more readers of "where is this widget," and masks
|
||||||
|
|
||||||
|
Moving the offset into the vertex shader means `ActiveData.region` is no
|
||||||
|
longer the on-screen truth once a widget has been moved — it is where the
|
||||||
|
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
||||||
|
if it still were, and both must move to a resolved query or they silently
|
||||||
|
answer with the pre-move position: a click landing on a scrolled row would
|
||||||
|
be routed to whatever used to be there, with nothing on screen to say so —
|
||||||
|
exactly the "wrong answer that looks like a right one" case the code rules
|
||||||
|
single out.
|
||||||
|
|
||||||
|
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
||||||
|
does the actual pointer routing, and line 170 is the read in question:
|
||||||
|
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
||||||
|
immediately turned into pixels and tested against the cursor at `:171-172`.
|
||||||
|
Under this design that region must be resolved through the same chain the
|
||||||
|
GPU walks before it means anything. Add to `UiRenderState`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||||
|
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
||||||
|
/// walk, over the same arena, so the two cannot disagree about where a
|
||||||
|
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
||||||
|
/// `move_offsets`, bounded by the same constant the shader loop uses
|
||||||
|
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
||||||
|
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
||||||
|
/// const across the language boundary).
|
||||||
|
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
||||||
|
```
|
||||||
|
|
||||||
|
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
||||||
|
coordinate query already used outside hit-testing
|
||||||
|
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
||||||
|
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
||||||
|
before `.to_px(...)` instead of reading `.region` directly — one change
|
||||||
|
covers both call sites listed there. `sense.rs:170` changes to
|
||||||
|
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
||||||
|
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
||||||
|
`resolved_region` costs one arena read to find that out, so there is no
|
||||||
|
version of this design where skipping the fix is a legitimate
|
||||||
|
optimization — it is a correctness gap, not a performance one.
|
||||||
|
|
||||||
|
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
||||||
|
painter's *current* region into a `Mask` pushed onto
|
||||||
|
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
||||||
|
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
||||||
|
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
||||||
|
widget that called `set_mask` — `Masked::draw`,
|
||||||
|
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
||||||
|
is itself later moved, its clip rectangle stays where it was drawn while
|
||||||
|
its content moves out from under it: a visibly wrong clip, immediately on
|
||||||
|
screen, not a latency question.
|
||||||
|
|
||||||
|
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
||||||
|
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
||||||
|
the mask-owning widget's own primitives already get (§2), not a second
|
||||||
|
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
||||||
|
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
||||||
|
currently computes the mask's `top_left`/`bot_right` inline from
|
||||||
|
`masks[in.mask_idx]`; that computation is extended to walk the same
|
||||||
|
move-offset chain §2 added, via one shared function —
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
||||||
|
for its mask's corners, so the walk is written once and the two stages
|
||||||
|
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
||||||
|
hand-copied second one in the other shader stage).
|
||||||
|
|
||||||
|
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
||||||
|
primitive's mask is frequently owned by a *different* widget than the
|
||||||
|
primitive itself — often several levels up a subtree, with its own,
|
||||||
|
independent move slot — so a primitive's resolved offset and its mask's
|
||||||
|
resolved offset are two different chain sums, both needed, and only the
|
||||||
|
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
||||||
|
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
||||||
|
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
||||||
|
mean, for every `mov()` call, walking forward to every mask instance the
|
||||||
|
moved widget's slot could affect and rewriting its raw region — exactly
|
||||||
|
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
||||||
|
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
||||||
|
frame (`:148`); one more arena read to resolve its chain costs nothing
|
||||||
|
extra in kind.
|
||||||
|
|
||||||
|
**The scroll-container case, checked rather than assumed.** A masked,
|
||||||
|
scrollable region is built as a `Masked` wrapping a `Scroll`
|
||||||
|
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
||||||
|
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
||||||
|
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
||||||
|
every frame the user drags. Because each widget's move slot is its own
|
||||||
|
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
||||||
|
its own, stationary slot, while the scrolled content underneath references
|
||||||
|
a separate, deeper slot whose `parent` chain passes through — but does not
|
||||||
|
write to — the viewport's slot. Moving the content therefore never touches
|
||||||
|
the mask's resolved position, and the mask staying still while its content
|
||||||
|
slides past it is what this design already produces with no special case,
|
||||||
|
not an extra rule that had to be added for it.
|
||||||
|
|
||||||
|
### 3. Resize scope
|
||||||
|
|
||||||
|
A resize is "the region a widget's parent offers it changes such that the
|
||||||
|
widget's draw might produce different output" — as opposed to a move, which
|
||||||
|
by construction cannot (§2 is scoped to pure translation). Two independent
|
||||||
|
narrowings apply, and both are real, measured properties of the code as it
|
||||||
|
stands rather than new machinery:
|
||||||
|
|
||||||
|
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||||
|
`shader.wgsl:105-106` recomputes every primitive's pixel position from
|
||||||
|
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
||||||
|
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
||||||
|
call to `px_size()`, `output_size()`, or anything else that reads a
|
||||||
|
concrete pixel count) is therefore already correct after a resize with zero
|
||||||
|
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
||||||
|
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
||||||
|
tree on every `resized`, which was the safe default while sizing and
|
||||||
|
drawing were two passes; it should be narrowed to only the widgets that
|
||||||
|
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
||||||
|
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
||||||
|
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
||||||
|
pixel-dependent by calling through `Painter` methods that read
|
||||||
|
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
||||||
|
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
||||||
|
walks only that set instead of unconditionally setting `resized = true`
|
||||||
|
for a full `redraw_all`. This turns "every resize redraws everything" into
|
||||||
|
"every resize redraws what depends on pixels" — a real behavior change
|
||||||
|
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
||||||
|
resize regression (that fix depended on `redraw_all`'s completeness)
|
||||||
|
before narrowing this.
|
||||||
|
|
||||||
|
**(b) A widget's `available` (its parent's offered region) can change
|
||||||
|
without the widget's *content* changing — this is what
|
||||||
|
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
||||||
|
(a sibling grew or shrank, changing this widget's offered box), a widget
|
||||||
|
that returns `true` from `is_size_independent` is not redrawn: its
|
||||||
|
primitives are unaffected by size, only by placement, so the parent
|
||||||
|
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
||||||
|
the primitive's `region` fields directly via `region_mut` if the box
|
||||||
|
changed shape too (still O(primitives owned directly by this widget, not
|
||||||
|
its subtree, since a size-independent widget by definition has no
|
||||||
|
size-dependent descendants worth distinguishing — in practice this is
|
||||||
|
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
||||||
|
`false` (the default) is redrawn in full whenever `available` changes,
|
||||||
|
which is correct always, just not free.
|
||||||
|
|
||||||
|
**Ancestor propagation** (a resized child changing its own reported size,
|
||||||
|
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
||||||
|
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
||||||
|
ancestors whose cached size differs from the new one and stops as soon as
|
||||||
|
a size is unchanged (`:274-286`). That loop moves from consulting
|
||||||
|
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
||||||
|
|
||||||
|
### 4. Wrapped text, and "needs child height before choosing width"
|
||||||
|
|
||||||
|
**Wrapped text is not a special case any more; it already reads as one
|
||||||
|
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
||||||
|
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
||||||
|
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
||||||
|
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
||||||
|
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
||||||
|
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
||||||
|
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
||||||
|
`self.view.draw(painter)` once, which internally still calls `render`
|
||||||
|
once, hits its own cache, and returns the size it already computed. No
|
||||||
|
new caching is needed here; the two now-redundant call sites
|
||||||
|
(`desired_width`/`desired_height` each separately calling `render`) simply
|
||||||
|
disappear, which is a second `render` avoided per frame per text widget
|
||||||
|
that is being measured by a parent.
|
||||||
|
|
||||||
|
**"Parent wants the child's height before deciding the width it will
|
||||||
|
offer"** — the genuinely circular case named in the brief, e.g. a column
|
||||||
|
that sizes its own width to its widest child, where that child is wrapped
|
||||||
|
text whose height (which the column's *own* height depends on) depends on
|
||||||
|
the width the column has not yet decided. This is not solvable in one pass
|
||||||
|
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
||||||
|
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
||||||
|
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
||||||
|
by an explicit second, throwaway pass (its own comment: "this literally
|
||||||
|
copies draw ... which makes this slow and not cool"). The design keeps that
|
||||||
|
resolution, made explicit rather than accidental: `Painter` gets
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Draw `child` at a provisional region to learn its size under one
|
||||||
|
/// axis's worth of assumption, discard everything it wrote, then draw it
|
||||||
|
/// again at the region that assumption produced. For the rare parent that
|
||||||
|
/// cannot pick an offered size without already knowing the answer.
|
||||||
|
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
||||||
|
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
||||||
|
```
|
||||||
|
|
||||||
|
implemented as: draw at `first`, record `Size`, remove the widget and its
|
||||||
|
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
||||||
|
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
||||||
|
which already frees the old primitives before redrawing) — reusing that
|
||||||
|
path rather than adding a second one — draw again at `second(size)`, return
|
||||||
|
the final `Size`. It is opt-in and named for its cost, so a widget only
|
||||||
|
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
||||||
|
call site converted to it, replacing the hand-rolled duplicate loop.
|
||||||
|
|
||||||
|
### 5. Caching and invalidation
|
||||||
|
|
||||||
|
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
||||||
|
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
||||||
|
answer, independent of drawing) no longer exists as a separate query, so
|
||||||
|
there is nothing left to cache at that layer. What already provides "an
|
||||||
|
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
||||||
|
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
||||||
|
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
||||||
|
immediately — no `Painter` constructed, no primitive touched, no shader
|
||||||
|
work beyond what the GPU already redraws from the unchanged instance
|
||||||
|
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
||||||
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
||||||
|
available size)" once size *is* what a region change means.
|
||||||
|
|
||||||
|
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
||||||
|
returned, stored the moment it is (`draw_inner`, alongside building the
|
||||||
|
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
||||||
|
widget for a second frame without redrawing it (because nothing changed)
|
||||||
|
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
||||||
|
size question without a full draw" with "read the size of the last actual
|
||||||
|
draw," which is always available because `draw_inner`'s skip path is only
|
||||||
|
reachable once the widget has been drawn at least once. `Cache::remove`/
|
||||||
|
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
||||||
|
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
||||||
|
`render_state.rs:171-198`, freed with the widget).
|
||||||
|
|
||||||
|
### 6. Before / after
|
||||||
|
|
||||||
|
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
impl Widget for Rect {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
|
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||||
|
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||||
|
}
|
||||||
|
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||||
|
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// after
|
||||||
|
impl Widget for Rect {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||||
|
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||||
|
Size::REST // fills whatever it was given -- used == available
|
||||||
|
}
|
||||||
|
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A container that needs the child's size before placing it,
|
||||||
|
`iris/src/widget/position/align.rs`**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
impl Widget for Aligned {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
|
let region = match self.align.tuple() {
|
||||||
|
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
||||||
|
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||||||
|
UiRegion::new(x, UiSpan::FULL) }
|
||||||
|
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||||||
|
UiRegion::new(UiSpan::FULL, y) }
|
||||||
|
(None, None) => UiRegion::FULL,
|
||||||
|
};
|
||||||
|
painter.widget_within(&self.inner, region);
|
||||||
|
}
|
||||||
|
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
||||||
|
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// after
|
||||||
|
impl Widget for Aligned {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
let full = painter.region();
|
||||||
|
// Draw once at the full region to learn the child's real size --
|
||||||
|
// this placement is provisional and corrected below without a
|
||||||
|
// second draw.
|
||||||
|
let used = painter.widget_within(&self.inner, full);
|
||||||
|
let region = match self.align.tuple() {
|
||||||
|
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
||||||
|
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
||||||
|
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||||||
|
(None, None) => full,
|
||||||
|
};
|
||||||
|
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||||||
|
used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||||||
|
return type from `()` to `Size`, carrying the child's `draw` result back —
|
||||||
|
the only signature change needed to let a parent see what its child used.
|
||||||
|
`Painter::reposition` is new, computing the delta between where a child
|
||||||
|
was actually drawn and where it belongs and calling the O(1) `mov` from
|
||||||
|
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||||||
|
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||||||
|
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||||||
|
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
||||||
|
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
||||||
|
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||||||
|
|
||||||
|
### 7. Migration — every file and widget that changes
|
||||||
|
|
||||||
|
One change, in dependency order (rename-and-move-together, per the code
|
||||||
|
rules — no intermediate state with both trait shapes):
|
||||||
|
|
||||||
|
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
||||||
|
`WidgetAxisFns`, update `impl Widget for ()`.
|
||||||
|
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
||||||
|
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
||||||
|
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
||||||
|
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
||||||
|
`len_axis`; `primitive_at` writes `move_idx`.
|
||||||
|
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
||||||
|
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
||||||
|
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
||||||
|
instead of `Cache.size`.
|
||||||
|
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
||||||
|
`move_slot: MoveIdx`.
|
||||||
|
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
||||||
|
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
||||||
|
new `MoveOffset` struct.
|
||||||
|
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
||||||
|
and `Primitives::write`, matching `mask_idx`.
|
||||||
|
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
||||||
|
(group 2, beside `masks`) and its update path.
|
||||||
|
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
||||||
|
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
||||||
|
function (§2b) called from both `vs_main` (a primitive's own corners)
|
||||||
|
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
||||||
|
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
||||||
|
and `window_region` (`:264-267`) reimplemented on top of it.
|
||||||
|
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
||||||
|
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
||||||
|
(§2b) — the pointer-routing fix this design requires, not an optional
|
||||||
|
follow-up.
|
||||||
|
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
||||||
|
`move_idx: u32` (§2b).
|
||||||
|
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
||||||
|
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
||||||
|
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
||||||
|
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
||||||
|
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
||||||
|
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
||||||
|
never depends on the region it is offered, same as `Rect`),
|
||||||
|
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
||||||
|
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
||||||
|
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
||||||
|
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
||||||
|
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
||||||
|
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
||||||
|
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
||||||
|
`iris/src/widget/position/pad.rs` (`Pad`),
|
||||||
|
`iris/src/widget/position/stack.rs` (`Stack`),
|
||||||
|
`iris/src/widget/position/offset.rs` (`Offset`),
|
||||||
|
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
||||||
|
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
||||||
|
`iris/src/widget/position/sized.rs` (`Sized`).
|
||||||
|
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
||||||
|
across `core/` and `src/`; re-run it before starting, since it is the
|
||||||
|
authoritative check that nothing was missed, not this paragraph.
|
||||||
|
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
||||||
|
`impl Widget` found in any example (verified by the same grep); they use
|
||||||
|
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
||||||
|
source change, which is itself part of the pass condition below.
|
||||||
|
|
||||||
|
### 8. Pass conditions
|
||||||
|
|
||||||
|
1. **Every example under `iris/examples` renders identically.** Run
|
||||||
|
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
||||||
|
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
||||||
|
"looks right," since a subtle wrap or alignment regression is exactly
|
||||||
|
what a diff catches and a glance does not.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
|
||||||
|
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
|
||||||
|
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
|
||||||
|
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
|
||||||
|
file bytes (`cmp` alone is not conclusive across two separately-encoded
|
||||||
|
PNGs, though it happened to agree here for `minimal`). Before-shots were
|
||||||
|
taken with `git stash` at the pre-change commit; `tabs` needed two real
|
||||||
|
fixes (deviations 1 and 2 below) before it stopped differing — the other
|
||||||
|
three matched on the first try.
|
||||||
|
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
|
||||||
|
the existing `debug_layers`/`active_widgets` instrumentation
|
||||||
|
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
|
||||||
|
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
|
||||||
|
one example (`tabs`, since it already has multiple widgets and an
|
||||||
|
interactive element) through one frame with nothing changed and report
|
||||||
|
both counts — the pass condition is **0 draws and 0 primitive rewrites**
|
||||||
|
for a frame in which nothing was marked dirty, resized, or moved.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass, 0 and 0.** Implemented as
|
||||||
|
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
|
||||||
|
rewrites, `move_offsets` writes — a third counter, for condition 3
|
||||||
|
below), reset on read. Measured in
|
||||||
|
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
|
||||||
|
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
|
||||||
|
see the note on condition 3 for why this runs as a plain unit test
|
||||||
|
instead).
|
||||||
|
3. **Single-moved-child cost, measured.** Same counters, one frame in
|
||||||
|
which exactly one widget is moved (not resized) with N primitives in its
|
||||||
|
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
|
||||||
|
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
|
||||||
|
the case with a `tabs`-style example holding a deliberately large text
|
||||||
|
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
|
||||||
|
an O(N) regression would show up as a non-trivial write count rather
|
||||||
|
than being lost in noise.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
|
||||||
|
write, N = 500.** Built with rects rather than glyphs
|
||||||
|
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
|
||||||
|
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
|
||||||
|
this runs as a plain `cargo test`, not through `run-headless.sh` — a
|
||||||
|
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
|
||||||
|
faster and more precise than reading counters out of a real example's
|
||||||
|
stderr. Getting a clean single move took two follow-up fixes beyond the
|
||||||
|
design as written (deviation 3, the `parent_move_slot` threading; and
|
||||||
|
the `Scroll` design decision below about offering last frame's content
|
||||||
|
length) — without either, the count was in the thousands (every rect in
|
||||||
|
the subtree redrawing) rather than 1.
|
||||||
|
4. **Hit-testing follows the move, not just the render.** In the same
|
||||||
|
scrolled-`tabs` construction as condition 3, scroll the content, then
|
||||||
|
send a synthetic cursor position over a widget that moved and assert
|
||||||
|
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
|
||||||
|
id, not to whatever is now at its pre-scroll coordinates or to nothing.
|
||||||
|
This is a correctness check, not a timing one — §2b's fix is required
|
||||||
|
before §2 can ship at all, and this is what would fail silently
|
||||||
|
(nothing on screen indicates a missed or misrouted hit) if it were
|
||||||
|
skipped.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass**, but checked one level below
|
||||||
|
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
|
||||||
|
scrolls a widget and asserts `UiRenderState::resolved_region` (the
|
||||||
|
query `run_sensors`'s hit-test and `window_region` both now go through,
|
||||||
|
per §2b) reports the moved, not the pre-scroll, position — within
|
||||||
|
0.01px of the exact expected delta. `run_sensors` itself needs a
|
||||||
|
`HasEvents`/window/cursor-state harness this pass did not build; the
|
||||||
|
coverage that matters (does the position query the router uses reflect
|
||||||
|
the move) is exercised directly instead.
|
||||||
|
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
|
||||||
|
both before and after scrolling it (`iris/run-headless.sh` against a
|
||||||
|
small purpose-built example, or an addition to `tabs`), and diff the
|
||||||
|
two frames: the clipped edge of the content must have moved with the
|
||||||
|
scroll while the viewport's own border (drawn by `Masked`, not moved)
|
||||||
|
stays put — the specific case worked through in §2b. A mask rectangle
|
||||||
|
that stayed at its pre-scroll position while its content slid past it
|
||||||
|
is the regression this checks for, and it is visible in a single
|
||||||
|
screenshot, not just in a counter.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass, checked numerically rather than by
|
||||||
|
screenshot.** No example in this repository builds a `Masked`-wrapped
|
||||||
|
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
|
||||||
|
scrolling, not this widget), so there was nothing to screenshot without
|
||||||
|
first authoring a new example. Checked instead in
|
||||||
|
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
|
||||||
|
on the exact data the fragment shader's `resolve_move` reads: the
|
||||||
|
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
|
||||||
|
and after scrolling its content, because `Masked` is never itself the
|
||||||
|
target of a move — only its child is, on a separate, deeper slot in the
|
||||||
|
chain (§2b's "scroll-container case, checked rather than assumed"). A
|
||||||
|
pixel-level screenshot check of this remains open; see RUST.md's next
|
||||||
|
step.
|
||||||
|
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
|
||||||
|
stay clean at the defaults (iris has no tests today per I0b, so this is
|
||||||
|
presently only clippy/fmt; add the first real widget-layer tests here if
|
||||||
|
the move-offset chain or `draw_twice` are non-trivial enough to want
|
||||||
|
one, per "match the codebase's testing posture" — judge that once the
|
||||||
|
code exists rather than pre-committing to a number of tests here).
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
|
||||||
|
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
|
||||||
|
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
|
||||||
|
`winit` future-incompatibility, from dependencies, not this change).
|
||||||
|
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
|
||||||
|
ones in `iris/src/layout_tests.rs` (conditions 2–5 above), 18 passed, 0
|
||||||
|
failed — the move-offset chain turned out non-trivial enough (three real
|
||||||
|
bugs found only by writing it) to clearly clear the "match the testing
|
||||||
|
posture" bar this section left open.
|
||||||
|
|
||||||
|
### 9. Rejected, and why
|
||||||
|
|
||||||
|
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
||||||
|
phrasing — rejected in §2 for breaking under nested independent moves
|
||||||
|
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
||||||
|
chain is the same mechanism with one more field.
|
||||||
|
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
|
||||||
|
as the steady-state path (O(primitives in subtree), exactly what a
|
||||||
|
transcript scroll must not pay every frame) but kept for resize-shaped
|
||||||
|
changes (§3) where the content's own region field, not an ancestor
|
||||||
|
chain, is what has to change.
|
||||||
|
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
||||||
|
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
||||||
|
implement to skip a draw when a cheap answer exists) — considered and
|
||||||
|
rejected: it reintroduces exactly the "two names for one concept" split
|
||||||
|
this change removes, for a saving `is_size_independent` (§1, §3b)
|
||||||
|
already covers for the cases where it would actually help (fixed-size
|
||||||
|
leaves). A widget whose size is cheap to compute but whose *drawing* is
|
||||||
|
not (unlikely in this codebase's widget set, but conceivable) is better
|
||||||
|
served by that widget caching its own draw output internally — exactly
|
||||||
|
the pattern `TextView::render` already uses (§4) — than by a second
|
||||||
|
trait method every implementor has to reason about.
|
||||||
|
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
||||||
|
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
||||||
|
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
||||||
|
which already carries the same information into every widget that needs
|
||||||
|
it; adding a parameter would just be a second route to a value already
|
||||||
|
reachable, and would invite the two drifting apart.
|
||||||
|
- **Eagerly propagating a moved widget's delta into every descendant's own
|
||||||
|
offset value** (rather than chaining and resolving in the shader) —
|
||||||
|
rejected as O(descendant widgets), which is smaller than O(primitives)
|
||||||
|
but still not O(1), and the shader-side chain costs nothing extra to get
|
||||||
|
the better bound.
|
||||||
|
|
||||||
|
## Deviations found during implementation (2026-09-04)
|
||||||
|
|
||||||
|
Five corrections this file's first draft did not anticipate, each found by
|
||||||
|
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
|
||||||
|
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
|
||||||
|
`draw_inner`/`reposition` — not by reasoning about the design in the
|
||||||
|
abstract. Recorded here rather than silently fixed in place, per the code
|
||||||
|
rules' escape-hatch requirement.
|
||||||
|
|
||||||
|
1. **`Aligned`'s provisional draw must call `painter.widget`, not
|
||||||
|
`widget_within(&self.inner, painter.region())`.** §6's original text drew
|
||||||
|
the sample as the latter. `widget_within` composes its `region` argument
|
||||||
|
as *local*, `UiRegion::FULL`-relative coordinates against
|
||||||
|
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
|
||||||
|
self.region` relies on); handing it `painter.region()` itself —
|
||||||
|
already-resolved, window-relative coordinates — composes that frame a
|
||||||
|
second time. For the root widget this is silently the identity (its
|
||||||
|
region already is `[0,1]`), which is why it can look correct in a
|
||||||
|
trivial case and only breaks once something is nested — i.e. always, in
|
||||||
|
practice. Symptom: a centered child rendered at a wildly wrong offset
|
||||||
|
nested more than one level deep. Fixed by using `painter.widget`, which
|
||||||
|
hands the child `self.region` unmodified, with no second composition.
|
||||||
|
|
||||||
|
2. **A widget that reports a size smaller than its offered region must
|
||||||
|
actually paint at that size, anchored top-left of what it was given —
|
||||||
|
not fill the full offered region while merely *reporting* a smaller
|
||||||
|
number.** `Sized` and `MaxSize` both had exactly this bug: their
|
||||||
|
`desired_width`/`desired_height` predecessors capped the *reported*
|
||||||
|
value but their `draw` bodies called `painter.widget(&self.inner)`
|
||||||
|
unconstrained, which was harmless under the old two-pass model (a parent
|
||||||
|
always queried the size *before* drawing, so by the time `draw` ran the
|
||||||
|
offered region already matched) but wrong under `Aligned`'s new
|
||||||
|
provisional-draw-then-reposition pattern, which offers the *whole*
|
||||||
|
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
|
||||||
|
rendered stretched to fill its whole row instead of a 100×100 square.
|
||||||
|
Fixed by having both widgets carve the declared sub-region (`UiSpan`
|
||||||
|
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
|
||||||
|
they were offered before drawing the child in it. `Image` needed the
|
||||||
|
same treatment from the start (`texture_within` at its own natural size,
|
||||||
|
not `texture()` at the full offered region) and was written that way in
|
||||||
|
the first pass, once this was understood; `Rect`'s "fill whatever I'm
|
||||||
|
given" is the one case where painting the *whole* offered region really
|
||||||
|
is the declared behavior, so it needed no change.
|
||||||
|
|
||||||
|
3. **The move-offset chain's `parent` link cannot be found by looking up
|
||||||
|
the parent's `ActiveData` in `draw_inner`, because the parent's
|
||||||
|
`ActiveData` does not exist yet while its own `Widget::draw` is still
|
||||||
|
running.** `ActiveData` is inserted only after `draw` returns
|
||||||
|
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
|
||||||
|
through its parent's `draw` body — the ordinary case, since every
|
||||||
|
composite widget draws its children from inside its own `draw` — would
|
||||||
|
always read "no parent" from `self.active`, silently orphaning it at the
|
||||||
|
root of the chain. Fixed by threading the parent's `move_slot` down
|
||||||
|
through `Painter` (it already carries `mask`/`layer` the same way) and
|
||||||
|
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
|
||||||
|
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
|
||||||
|
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
|
||||||
|
target's parent genuinely is already active at that call site — the
|
||||||
|
doc comment on it says which is which. Symptom: `reposition` computed
|
||||||
|
the right delta and wrote it to the right slot, but the shader never
|
||||||
|
saw it, because the primitive doing the actual painting chained to
|
||||||
|
`u32::MAX` one level too early.
|
||||||
|
|
||||||
|
4. **`Painter::reposition` cannot reuse `active.region` as "where the
|
||||||
|
widget currently is," because for a widget offered more room than it
|
||||||
|
used, `active.region` is the *offered* box, not the *painted* one.**
|
||||||
|
This only matters for `reposition` (used by `Aligned`); `mov` (used by
|
||||||
|
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
|
||||||
|
and `Offset`) has no such gap, because there the offered region *is*
|
||||||
|
the visual footprint — content is sized to fill exactly what it is
|
||||||
|
given. `reposition` instead reconstructs "from" as `active.size`
|
||||||
|
(already tracked, per §5) anchored at `AxisAlign::Neg` within
|
||||||
|
`active.region` — i.e. it assumes the child painted itself top-left of
|
||||||
|
whatever it was offered, per point 2's convention — and **overwrites**
|
||||||
|
the slot's delta rather than accumulating it the way `mov` does, since
|
||||||
|
"from" is recomputed fresh from stable inputs every call and repeating
|
||||||
|
the same `reposition` (an unrelated redraw elsewhere re-running this
|
||||||
|
widget's parent) must not drift further each time. The one shape this
|
||||||
|
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
|
||||||
|
`reposition` may have moved its content away from top-left already. No
|
||||||
|
widget or example in this codebase builds that today; if one needs to,
|
||||||
|
`reposition` would need the child to report *where* it painted, not
|
||||||
|
just how big, which is a larger change than this pass's scope.
|
||||||
|
|
||||||
|
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
|
||||||
|
draw, and reused in place — never reallocated — for every later redraw
|
||||||
|
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
|
||||||
|
spelled out in §2's original text, which only said slots are assigned
|
||||||
|
"when the widget is first drawn." Reallocating a fresh slot on every
|
||||||
|
redraw would leave any *retained* (not-redrawn) descendant's `parent`
|
||||||
|
link pointing at a now-orphaned old slot — a permanent leak, and worse,
|
||||||
|
a descendant that silently stops tracking its ancestor's future moves.
|
||||||
|
Resetting the delta on reuse (rather than carrying it forward) is
|
||||||
|
required because a full redraw bakes the widget's correct absolute
|
||||||
|
position into the fresh `region` argument directly; a stale delta left
|
||||||
|
over from before the redraw would double-offset it.
|
||||||
|
|
||||||
|
Two further points worth recording because they were *design decisions*
|
||||||
|
made while implementing, not bugs — `LAYOUT.md`'s own text left them
|
||||||
|
unspecified rather than getting them wrong:
|
||||||
|
|
||||||
|
- **`Scroll` offers its content a region sized by the *previous* frame's
|
||||||
|
measured content length, not a fresh one.** A fresh measurement would
|
||||||
|
require drawing the content once to learn its size and — since that
|
||||||
|
provisional size essentially never matches the previously active one —
|
||||||
|
redrawing it a second time at the real size, on every single scroll
|
||||||
|
tick, which is exactly the cost §2 exists to remove. Using the stale
|
||||||
|
length means an ordinary scroll (position changes, content does not)
|
||||||
|
offers the same *size* as last frame, only shifted, which is what makes
|
||||||
|
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
|
||||||
|
change lags one frame before the container's scroll range reflects it,
|
||||||
|
self-correcting the frame after (the content length itself, read from
|
||||||
|
what was actually drawn, is never stale — only the offered *region* used
|
||||||
|
for placement is). No example in this repository builds a `Scroll` yet,
|
||||||
|
so this could not be checked against a pixel diff; it is covered instead
|
||||||
|
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
|
||||||
|
build a tree and drive `UiRenderState` directly with no GPU or window
|
||||||
|
needed.
|
||||||
|
- **`redraw()`'s parent-relayout check draws the widget first, then
|
||||||
|
compares the fresh `ActiveData.size` the draw produced against the size
|
||||||
|
from before removal** — the mirror image of the old code's "query size,
|
||||||
|
compare, decide whether to draw," which no longer has a size query to
|
||||||
|
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
|
||||||
|
along with `desired_width`/`desired_height`). This can occasionally draw
|
||||||
|
a widget once more than the old code would have (if the parent it
|
||||||
|
bubbles up to ends up redrawing the same widget again as part of its own
|
||||||
|
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
|
||||||
|
redundancy for free, and this path is not one of §8's measured
|
||||||
|
conditions, so the remaining slack was accepted rather than chased
|
||||||
|
further.
|
||||||
|
|
||||||
|
## For IRIS.md
|
||||||
|
|
||||||
|
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||||
|
|
||||||
|
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
|
||||||
|
> `desired_height` are gone.** A widget used to implement three methods
|
||||||
|
> (`draw`, `desired_width`, `desired_height`); it now implements one,
|
||||||
|
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
|
||||||
|
> `painter.region()` and returns how much of it was used. Why: the two
|
||||||
|
> extra methods routinely re-simulated what `draw` was about to do anyway
|
||||||
|
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
|
||||||
|
> right) — one visit per widget per frame instead of up to three. A
|
||||||
|
> container that needs a child's size before placing it (alignment,
|
||||||
|
> centering) draws the child once at a provisional region, reads the
|
||||||
|
> returned `Size`, and calls the new `Painter::reposition` to move it into
|
||||||
|
> its final spot — an O(1) offset write, not a second draw. A widget whose
|
||||||
|
> drawn output never depends on the size it's given (a fixed-size `Rect`,
|
||||||
|
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
|
||||||
|
> bool { false }` to `true`, which skips redrawing it when only its
|
||||||
|
> offered region changes shape.
|
||||||
|
>
|
||||||
|
> ```rust
|
||||||
|
> // before
|
||||||
|
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||||
|
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
>
|
||||||
|
> // after
|
||||||
|
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
|
> list.
|
||||||
File renamed without changes.
+4034
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,496 @@
|
|||||||
|
# How iris should render an unbounded number of images
|
||||||
|
|
||||||
|
## Status (2026-09-04)
|
||||||
|
|
||||||
|
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
|
||||||
|
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
|
||||||
|
what landed, what differs from the proposal below and why, and what was
|
||||||
|
verified versus merely reasoned about. The short version: the binding array
|
||||||
|
is gone, `request_device` asks for no features and no binding-array limits,
|
||||||
|
and that is now proven on the emulator's software Vulkan
|
||||||
|
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
|
||||||
|
is resolved.
|
||||||
|
|
||||||
|
Iris (the person) asked whether iris's (the library's) approach to
|
||||||
|
"draw however many images happen to be on screen" — relevant here because a
|
||||||
|
transcript can hold an unbounded number of attached screenshots — actually
|
||||||
|
works on mobile, her recollection being that it does not. Checked rather
|
||||||
|
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
|
||||||
|
file is that investigation and the resulting recommendation, written for a
|
||||||
|
second agent to review before anything in iris's render core changes — no
|
||||||
|
code has been written against this yet.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Every texture iris ever creates — every `Image` widget
|
||||||
|
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
||||||
|
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
||||||
|
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
||||||
|
array by index: `core/src/render/shader.wgsl:56` declares
|
||||||
|
`var views: binding_array<texture_2d<f32>>`, sized by
|
||||||
|
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
||||||
|
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
||||||
|
features — `TEXTURE_BINDING_ARRAY`,
|
||||||
|
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
||||||
|
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
||||||
|
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
||||||
|
|
||||||
|
A transcript with an unbounded number of image attachments is exactly the
|
||||||
|
case that grows this array without bound: each attachment becomes its own
|
||||||
|
`Image` widget, which takes its own permanent array slot until dropped.
|
||||||
|
|
||||||
|
## What was measured
|
||||||
|
|
||||||
|
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
|
||||||
|
and limits with no window and no APK — a plain executable pushed with
|
||||||
|
`adb push` and run from `/data/local/tmp`. It has two parts:
|
||||||
|
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
||||||
|
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
||||||
|
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
||||||
|
detect it."
|
||||||
|
|
||||||
|
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
||||||
|
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
||||||
|
discrete desktop GPU — but it is why the design was never checked before
|
||||||
|
now: it always worked in the one place it was tried.
|
||||||
|
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
||||||
|
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
||||||
|
`request_device` **fails** —
|
||||||
|
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
||||||
|
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
||||||
|
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
||||||
|
driver itself reporting all seven descriptor-indexing sub-features as
|
||||||
|
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
||||||
|
being more conservative than the driver here, for a reason not chased
|
||||||
|
further (a likely instance-version negotiation gap, since the extension
|
||||||
|
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
||||||
|
not the finding that matters, and is **not** why this design is rejected.
|
||||||
|
|
||||||
|
**The finding that matters is about real phones, sourced rather than
|
||||||
|
recalled:**
|
||||||
|
|
||||||
|
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
||||||
|
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
||||||
|
of October 2025
|
||||||
|
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
||||||
|
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
||||||
|
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
||||||
|
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
||||||
|
unrelated to bindless) and stops there; true of the 2021 and 2022
|
||||||
|
profiles as well.
|
||||||
|
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
||||||
|
is supported on all Valhall and 5th Gen GPUs"**
|
||||||
|
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
||||||
|
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
||||||
|
for Bifrost, Midgard or Utgard, which are still common in budget and
|
||||||
|
older Android phones that are still in daily use.
|
||||||
|
- A search engine's summarized claim of "1% support on Android" for this
|
||||||
|
extension was checked against its cited source (an Arm blog post from
|
||||||
|
2021) and **was not actually there** — that number does not appear in
|
||||||
|
any primary source found and should not be repeated. The baseline-
|
||||||
|
profile finding above is the one with an attributable source; use it
|
||||||
|
instead.
|
||||||
|
|
||||||
|
So this is not a software-renderer artifact. A real, currently-shipping
|
||||||
|
share of the Android fleet lacks the feature iris's texture pipeline asks
|
||||||
|
for unconditionally, and neither the emulator's failure nor the current
|
||||||
|
official hardware baseline gives any reason to expect that to change soon.
|
||||||
|
|
||||||
|
## What growth already costs today, before any redesign
|
||||||
|
|
||||||
|
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
|
||||||
|
because "does this redesign make things worse" needs the current baseline
|
||||||
|
first:
|
||||||
|
|
||||||
|
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
|
||||||
|
rebuilt for any reason related to texture count — its bind group
|
||||||
|
*layouts* declare fixed slot counts (`limits.max_textures`,
|
||||||
|
`limits.max_samplers`) up front and that never changes at runtime. Growth
|
||||||
|
was never at risk of recreating the pipeline, in the current design or
|
||||||
|
any redesign discussed below.
|
||||||
|
- What **does** get rebuilt: `UiRenderNode::update` calls
|
||||||
|
`self.textures.update(&mut ui.textures)`, and if that reports any change,
|
||||||
|
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
|
||||||
|
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
|
||||||
|
fresh over **every currently-live texture**, plus the sampler array and
|
||||||
|
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
|
||||||
|
— an image added anywhere in the whole app rebuilds one shared structure
|
||||||
|
referencing every other image too.
|
||||||
|
- The one path already excluded from this, on purpose, is a `Patch` —
|
||||||
|
writing into an existing texture's pixels without changing which
|
||||||
|
textures exist. The code says why directly
|
||||||
|
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
|
||||||
|
changes texture contents, not the binding array, so it must not report
|
||||||
|
`changed` — rebuilding the bind group per glyph is the cost this exists
|
||||||
|
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
|
||||||
|
growing an existing atlas page costs a `write_texture` into a sub-rect,
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
So today, growth that stays inside an existing texture (glyphs added to an
|
||||||
|
atlas page) is already free. Growth that adds a *new* texture — a new atlas
|
||||||
|
page, or any standalone image — already rebuilds the one shared array
|
||||||
|
regardless of how the array is populated, before any change discussed
|
||||||
|
below. That existing cost is O(live texture count) in CPU work to collect
|
||||||
|
the view list and in however expensive the driver finds a
|
||||||
|
descriptor-set-sized-for-N-descriptors to be.
|
||||||
|
|
||||||
|
## Prior art, checked rather than assumed
|
||||||
|
|
||||||
|
Two independent projects were checked to see whether "atlas for images"
|
||||||
|
is actually how this is normally done, rather than a guess:
|
||||||
|
|
||||||
|
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
|
||||||
|
closest prior art to iris — an immediate-mode wgpu-backed UI library that
|
||||||
|
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
|
||||||
|
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
|
||||||
|
no array, no descriptor indexing of any kind. Draw calls are batched by
|
||||||
|
texture id and the bind group is switched between batches within the
|
||||||
|
render pass.
|
||||||
|
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
|
||||||
|
through — hit the identical problem and wrote down why in their own
|
||||||
|
roadmap document
|
||||||
|
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
|
||||||
|
*"The number of images that may appear in a scene is not bounded, which
|
||||||
|
is not a good fit for the basic descriptor binding model... Until then,
|
||||||
|
we'll do a workaround of having a single atlas image containing all the
|
||||||
|
images in the scene."* Their reason is broader than Android — WebGPU 1.0
|
||||||
|
has no descriptor indexing at all — but it reaches the same conclusion
|
||||||
|
for the same shape of problem: atlas, not a bigger bindless array.
|
||||||
|
|
||||||
|
**This is also a live hazard, not a solved one.** Vello's own changelog
|
||||||
|
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
|
||||||
|
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
|
||||||
|
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
|
||||||
|
in the renderer Masonry is built on. The same release added
|
||||||
|
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
|
||||||
|
utilization, fragmentation) because growth needed instrumenting in
|
||||||
|
production, not because it turned out to be free.
|
||||||
|
|
||||||
|
## Recommendation (not yet implemented)
|
||||||
|
|
||||||
|
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
|
||||||
|
downscaled attachment previews, icons — go through a shared atlas, the
|
||||||
|
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
|
||||||
|
Adding one to an existing page is a `Patch`, already free per the
|
||||||
|
section above.
|
||||||
|
2. **Large or one-off images** — a photo attachment opened at full
|
||||||
|
resolution, anything that would fragment a shared page — get their
|
||||||
|
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
|
||||||
|
is O(1): it references only itself, and does not touch any other
|
||||||
|
texture's binding, unlike today's shared array where every push
|
||||||
|
rebuilds a structure listing everything.
|
||||||
|
3. **Opening a new atlas page** is the one case that still resembles
|
||||||
|
today's rebuild — infrequent (bounded by how many *pages* are needed,
|
||||||
|
not by how many images have ever been attached) but not free, and
|
||||||
|
Vello's Mali-G52 fix says this specifically deserves care: it should
|
||||||
|
never be allowed to block a frame, and it is worth having the
|
||||||
|
equivalent of Vello's atlas diagnostics before trusting it under load.
|
||||||
|
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
|
||||||
|
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
||||||
|
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
|
||||||
|
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
|
||||||
|
This is also what fixes the emulator failure measured above, regardless
|
||||||
|
of the unresolved wgpu-hal question: a device that never asks for the
|
||||||
|
feature cannot be refused for lacking it.
|
||||||
|
|
||||||
|
## What this touches, and what is still open
|
||||||
|
|
||||||
|
Implementing this reworks iris's rendering core: the shader's binding
|
||||||
|
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
|
||||||
|
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
|
||||||
|
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
|
||||||
|
batching (today one draw call can reference any texture by index; the
|
||||||
|
per-texture-bind-group path needs draws grouped by which bind group they
|
||||||
|
use). Nothing has been started.
|
||||||
|
|
||||||
|
Open questions a reviewer should weigh in on:
|
||||||
|
|
||||||
|
- **The size threshold** between "goes in an atlas page" and "gets its own
|
||||||
|
bind group." Too low and ordinary attachment thumbnails end up as
|
||||||
|
one-off bind groups, losing the batching benefit the atlas exists for;
|
||||||
|
too high and a page fragments on a handful of medium images.
|
||||||
|
- **Eviction policy** for atlas pages once the working set does not fit —
|
||||||
|
today's `GlyphAtlas` never evicts, because a font's glyph set is small
|
||||||
|
and bounded; images are not. An LRU at the page level, or at the
|
||||||
|
individual-image level within a page, has not been designed.
|
||||||
|
- **Whether iris should keep any binding array at all**, even a small
|
||||||
|
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
|
||||||
|
or whether every atlas page should also be its own ordinary bind group
|
||||||
|
like standalone images — the array's only remaining justification would
|
||||||
|
be avoiding a bind-group-per-draw-call switch cost that has not been
|
||||||
|
measured on this project's actual target hardware.
|
||||||
|
- **How this interacts with I2/E2's virtualised list** (I3): a
|
||||||
|
bottom-anchored transcript composes only visible rows, so the live
|
||||||
|
texture set should already be bounded by what is on screen rather than
|
||||||
|
by the whole conversation — worth confirming that invariant holds before
|
||||||
|
relying on it to keep atlas/bind-group churn small.
|
||||||
|
|
||||||
|
## Review, 2026-09-04
|
||||||
|
|
||||||
|
A second pass over the file above against the code, done before anything
|
||||||
|
is implemented. Iris's worry going in: a bind group per texture means a
|
||||||
|
draw call per image, and she wants this as efficient as it can be.
|
||||||
|
|
||||||
|
### What checked out
|
||||||
|
|
||||||
|
Every code reference above is accurate as of this commit: the 100,000 /
|
||||||
|
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
|
||||||
|
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
|
||||||
|
asks for the three features is `iris/src/default/render.rs:96`, which the
|
||||||
|
text above does not name. egui-wgpu and Vello are described correctly.
|
||||||
|
|
||||||
|
### The emulator refusal is a wgpu-hal gap, now located
|
||||||
|
|
||||||
|
The file guessed "a likely instance-version negotiation gap." It is
|
||||||
|
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
|
||||||
|
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
|
||||||
|
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
|
||||||
|
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
|
||||||
|
that has descriptor indexing as core need not list the extension, and
|
||||||
|
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
|
||||||
|
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
|
||||||
|
The properties query beside it (line 1486) correctly accepts
|
||||||
|
`device_api_version >= 1.2 || extension`; the features query does not.
|
||||||
|
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
|
||||||
|
1872 and 2036). Worth an upstream issue, but not a reason to keep the
|
||||||
|
design: on real phones the gate that matters is stricter still.
|
||||||
|
|
||||||
|
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
|
||||||
|
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
|
||||||
|
sampled images, storage images and storage buffers, all together, because
|
||||||
|
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
|
||||||
|
"the extension is supported on Valhall" is necessary but not sufficient;
|
||||||
|
a driver with sampled-image indexing and without storage-buffer
|
||||||
|
update-after-bind is refused too. That widens the excluded set beyond
|
||||||
|
what the Arm quote suggests and strengthens the conclusion.
|
||||||
|
|
||||||
|
### A live bug in the current code, found on the way
|
||||||
|
|
||||||
|
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
|
||||||
|
"a patch must not report changed" as `changed = false`, unconditionally,
|
||||||
|
which also **cancels a `Push` earlier in the same batch**. That ordering is
|
||||||
|
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
|
||||||
|
pushes the page and `insert` patches it in the same frame, so the bind
|
||||||
|
group is not rebuilt and the new page's view is not bound until some
|
||||||
|
unrelated texture change happens to rebuild it. It is hidden today only
|
||||||
|
because the masks path also sets `changed`. The fix is one line
|
||||||
|
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
|
||||||
|
the redesign since that code is being replaced, and it is recorded here
|
||||||
|
so it is not rediscovered.
|
||||||
|
|
||||||
|
### In-layer draw order is already undefined
|
||||||
|
|
||||||
|
Relevant to any batching redesign: `Primitives::apply_free`
|
||||||
|
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
|
||||||
|
order within a layer is permuted whenever anything is freed. Overlap order
|
||||||
|
inside one layer is therefore not something the renderer promises today;
|
||||||
|
ordering is done with layers. That means grouping a layer's draws by
|
||||||
|
texture, or drawing a layer's images after its rects and glyphs, loses
|
||||||
|
nothing that currently exists. It should be written down as an invariant
|
||||||
|
when the redesign lands, because the new code will depend on it.
|
||||||
|
|
||||||
|
### On "a draw call per image"
|
||||||
|
|
||||||
|
Two corrections to the worry. First, it is a draw per *distinct texture per
|
||||||
|
layer*, not per image primitive: every glyph quad in a layer shares the
|
||||||
|
atlas and stays one instanced draw, and a thumbnail atlas would do the same
|
||||||
|
for previews. Second, the count is bounded by what is on screen, which I3's
|
||||||
|
virtualised transcript already bounds, and a mobile GPU is not draw-call
|
||||||
|
bound at tens of draws per frame; egui ships exactly this on Android. What
|
||||||
|
does cost is per-frame *bind group creation* and per-frame *sorting*, and
|
||||||
|
the current code already creates a `primitive_group` bind group every time
|
||||||
|
a layer updates (`render/mod.rs:103`), so one more per new image is not a
|
||||||
|
regression in kind.
|
||||||
|
|
||||||
|
### Recommended shape (proposal, for Iris to accept or change)
|
||||||
|
|
||||||
|
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
|
||||||
|
|
||||||
|
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
|
||||||
|
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
|
||||||
|
constraint an array texture imposes. A layer index is an ordinary
|
||||||
|
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
|
||||||
|
(and any future atlased-image primitive) carries a layer instead of a
|
||||||
|
`view_idx` and all of a layer's text stays **one draw**. This answers
|
||||||
|
the open question above about keeping a small binding array: no. Cost
|
||||||
|
of opening a page: recreate the array with one more layer and
|
||||||
|
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
|
||||||
|
with headroom (double) so it is rare. wgpu's default
|
||||||
|
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
|
||||||
|
rather than the API.
|
||||||
|
2. **Every standalone image is its own texture with its own bind group**,
|
||||||
|
and its instances live in a **separate per-layer instance list**, not
|
||||||
|
the main one. Then the main instance buffer never contains an image,
|
||||||
|
there is nothing to sort, no handle remapping beyond what
|
||||||
|
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
|
||||||
|
its bind group set first. Group 2's layout becomes `{atlas array,
|
||||||
|
one image texture, sampler, masks}`; the main draw binds a 1x1 null
|
||||||
|
image in the image slot, each image draw binds its own. One pipeline,
|
||||||
|
one shader, one layout.
|
||||||
|
3. **No thumbnail atlas in the first version.** With images on their own
|
||||||
|
textures, the threshold and eviction questions above disappear: an
|
||||||
|
image is freed when the row that owns its `TextureHandle` scrolls out.
|
||||||
|
Add an image atlas only if a measured screen shows enough small images
|
||||||
|
to matter, which a transcript rarely does.
|
||||||
|
4. **Drop the three features and the two `max_binding_array_*` limits from
|
||||||
|
`src/default/render.rs`**, and the `UiLimits` counts with them.
|
||||||
|
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
|
||||||
|
downscaled attachment will alias. Either request a filtering sampler
|
||||||
|
for the image slot or downscale on the CPU before upload; decide when
|
||||||
|
the image widget is touched, not as part of this.
|
||||||
|
|
||||||
|
What this costs against the file's original recommendation: `Textures`
|
||||||
|
needs to know an image from a page (two kinds of handle, or a kind on
|
||||||
|
`TextureHandle`), and `Primitives` gets a second instance list per layer.
|
||||||
|
What it saves: the sort, the size threshold, the eviction policy, and any
|
||||||
|
per-page bind group switch.
|
||||||
|
|
||||||
|
## Implemented, 2026-09-04
|
||||||
|
|
||||||
|
The shape above, built as proposed with one structural addition the proposal
|
||||||
|
didn't need to spell out and one bug it predicted made moot rather than
|
||||||
|
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
||||||
|
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
||||||
|
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
||||||
|
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
||||||
|
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
||||||
|
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
||||||
|
`rigs/gpu-probe/src/main.rs`.
|
||||||
|
|
||||||
|
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
||||||
|
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
||||||
|
(`grow_array`): a new texture is created at twice the layer capacity, the
|
||||||
|
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
||||||
|
readback), and every bind group that referenced the old view — the main
|
||||||
|
one and every live standalone image's — is rebuilt, since the view's
|
||||||
|
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
||||||
|
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
||||||
|
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
||||||
|
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
||||||
|
any GPU sync happens — the renderer only finds out later, when it
|
||||||
|
processes the queued `Push`.
|
||||||
|
|
||||||
|
**2. Standalone images, one bind group each.** `TextureKind` on
|
||||||
|
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
||||||
|
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
||||||
|
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
||||||
|
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
||||||
|
than through the generic `Primitive` trait, since an image has nowhere in
|
||||||
|
`PrimitiveData` to put a per-instance entry once the bind group already
|
||||||
|
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
||||||
|
once as before, then walks `image_instance` one entry at a time, binding
|
||||||
|
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
||||||
|
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
||||||
|
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
||||||
|
1x1 null view in the image slot.
|
||||||
|
|
||||||
|
**The one addition beyond the proposal**: the masks storage buffer lives
|
||||||
|
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
||||||
|
recreates its buffer whenever the mask count changes size
|
||||||
|
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
||||||
|
A resize invalidates every bind group holding the old buffer, not just the
|
||||||
|
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
||||||
|
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
||||||
|
array-growth path already needed. This wasn't a design question the
|
||||||
|
proposal had to answer (it treated bind-group construction as a given),
|
||||||
|
but it's exactly the shape of trap layer growth already had, so it uses
|
||||||
|
the same fix.
|
||||||
|
|
||||||
|
**3. No thumbnail atlas.** Not built, as proposed.
|
||||||
|
|
||||||
|
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
||||||
|
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
||||||
|
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
||||||
|
itself, not just its binding-array methods — once its two fields were
|
||||||
|
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
||||||
|
a limits parameter). `binding_array` no longer appears anywhere in
|
||||||
|
`shader.wgsl`.
|
||||||
|
|
||||||
|
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
||||||
|
note that this is a separate decision for whenever the image widget itself
|
||||||
|
is touched.
|
||||||
|
|
||||||
|
**The `changed = false` bug is structurally gone, not patched.** The old
|
||||||
|
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
||||||
|
unconditionally, which could erase an earlier `Push` in the same batch (a
|
||||||
|
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
||||||
|
`Patch`, both queued before the renderer ever runs). The new `update`
|
||||||
|
computes the rebuild signal by OR-ing each event's own answer
|
||||||
|
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
||||||
|
contributes to it — there is no shared mutable flag left for a `Patch` to
|
||||||
|
stomp on. Documented at the call site
|
||||||
|
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
||||||
|
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
||||||
|
the mechanism that could go wrong no longer exists.
|
||||||
|
|
||||||
|
**In-layer draw order is an explicit invariant now, not just a fact about
|
||||||
|
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
||||||
|
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
||||||
|
directly that both of a layer's lists (`instances` and `images`) free with
|
||||||
|
`swap_remove` and that nothing may assume adjacency survives a free —
|
||||||
|
recorded there because `apply_free` is the one place a change to either
|
||||||
|
list's ordering would have to be reconciled.
|
||||||
|
|
||||||
|
**Verified:**
|
||||||
|
|
||||||
|
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
||||||
|
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
||||||
|
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
||||||
|
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
||||||
|
test to — it's all GPU resource wiring).
|
||||||
|
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
||||||
|
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
||||||
|
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
||||||
|
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
||||||
|
- The standalone-image path specifically: a throwaway example (not
|
||||||
|
committed) with an `image(...)` widget as part of the root, run the same
|
||||||
|
way, rendered the image next to glyph-atlas text in one frame —
|
||||||
|
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
||||||
|
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
||||||
|
"image span" tab exercises the same widget but needs a click to reach,
|
||||||
|
which the headless compositor can't deliver (no seat devices, per I1's
|
||||||
|
own note on this file) — the throwaway example is what stood in for it.
|
||||||
|
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
||||||
|
Rather than building a purpose-made glyph flood, `PAGE`
|
||||||
|
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
||||||
|
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
||||||
|
exotic: a handful of `Text` widgets at a few sizes, one at
|
||||||
|
`Family::Monospace`) already exceeds one page's worth of distinct
|
||||||
|
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
||||||
|
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
||||||
|
glyphs landed on at least a third layer), and
|
||||||
|
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
||||||
|
correctly with no corruption or missing glyphs — confirming the
|
||||||
|
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
||||||
|
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
||||||
|
both work. Command:
|
||||||
|
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
||||||
|
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
||||||
|
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
||||||
|
throwaway diagnostic value, never a committed change, since a real
|
||||||
|
1024px page holding only a handful of glyphs at a time would be mostly
|
||||||
|
wasted space in normal use. Confirmed the revert left `tabs` and
|
||||||
|
`minimal` byte-identical to the pre-check screenshots afterward.
|
||||||
|
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
|
||||||
|
(empty) feature/limit set and run on this checkout's own emulator
|
||||||
|
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
||||||
|
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
||||||
|
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
||||||
|
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
||||||
|
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
||||||
|
at all, which reads exactly like the old bindless failure if you don't
|
||||||
|
know to ask for `EMU_GPU=software`):
|
||||||
|
|
||||||
|
cd rigs/gpu-probe
|
||||||
|
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
||||||
|
cargo ndk -t arm64-v8a -P 26 build --release
|
||||||
|
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
||||||
|
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
||||||
|
adb shell chmod 755 /data/local/tmp/gpu-probe
|
||||||
|
adb shell /data/local/tmp/gpu-probe
|
||||||
|
|
||||||
|
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
||||||
|
`features iris requires:` (none listed — the set is empty),
|
||||||
|
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
||||||
|
measured working, on the exact rig that first measured it failing.
|
||||||
|
Emulator stopped afterward (`emu down`); nothing was left running.
|
||||||
File renamed without changes.
File renamed without changes.
Generated
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# The common event model, extracted from `server/session/driver.rs` and
|
||||||
|
# `session/transcript.rs` so a Rust client (`client-core`) can share one
|
||||||
|
# definition with the server instead of hand-mirroring it the way
|
||||||
|
# `app/.../Events.kt` used to. Nothing here talks to a process, a file, or a
|
||||||
|
# socket -- it is exactly the wire shape in PLAN.md's "common event model",
|
||||||
|
# plus the transcript envelope and the context-token rule three different
|
||||||
|
# readers (the pump, the transcript, and a phone folding the same events)
|
||||||
|
# have to agree on.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
# `Event::ToolStart.input` is a tool's raw call arguments, whatever shape the
|
||||||
|
# dialect gave them -- typing it further would mean this crate knowing every
|
||||||
|
# driver's tool schema.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
//! The common event model: what a driver's process turns into before it
|
||||||
|
//! touches the transcript or the phone (see `PLAN.md`'s "The common event
|
||||||
|
//! model"). Extracted from `server/src/session/driver.rs` and
|
||||||
|
//! `session/transcript.rs` on 2026-09-04 so `client-core` shares this
|
||||||
|
//! definition instead of hand-mirroring it, which is what
|
||||||
|
//! `app/.../Events.kt` used to do. `server/`'s `session::driver` module
|
||||||
|
//! re-exports everything here, so nothing downstream of it had to change.
|
||||||
|
//!
|
||||||
|
//! What stayed behind in `server/`: the `Driver` trait, `SessionCommand`,
|
||||||
|
//! `Unqueued` and `EventSink`. Those are how *this* server runs a session,
|
||||||
|
//! not part of what a client reads off the wire.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The name a session's image is stored and served under -- minted for an
|
||||||
|
/// upload or for one a tool produced, and fetched back from
|
||||||
|
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
|
||||||
|
/// renders them identically.
|
||||||
|
pub type ImageRef = String;
|
||||||
|
|
||||||
|
/// The name an upload is stored and served under: an image is
|
||||||
|
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||||
|
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||||
|
/// the reader attached and what the session is told. Told apart by
|
||||||
|
/// `crate::media::media_type_for`.
|
||||||
|
pub type AttachmentRef = String;
|
||||||
|
|
||||||
|
/// One choice offered in answer to a [`Event::Question`]. More than a label
|
||||||
|
/// because the reader is deciding rather than confirming: what an option
|
||||||
|
/// means, and what picking it would produce, are what decide it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QuestionOption {
|
||||||
|
pub label: String,
|
||||||
|
/// A sentence about what this option means.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// A block to show as written -- a mockup, a diff, a config file.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuestionOption {
|
||||||
|
pub fn plain(label: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a session can tell the outside world. Every event is appended
|
||||||
|
/// to the transcript with a sequence number, then fanned out to SSE
|
||||||
|
/// subscribers, so reconnecting is just "events after seq N" -- no separate
|
||||||
|
/// history path to drift from the live one.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||||
|
// inside them. Both are needed and only the first is obvious: every field
|
||||||
|
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
|
||||||
|
// field went out as snake_case, the app looked for camelCase and found
|
||||||
|
// nothing, and the event still rendered -- as the "no counts reported" case,
|
||||||
|
// which is a state it is allowed to be in.
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Event {
|
||||||
|
/// What the user sent, written into the transcript by the manager (not by
|
||||||
|
/// drivers) so every device renders the conversation from one stream.
|
||||||
|
/// Recorded when the session reads it, which is what `MessageTaken` reports.
|
||||||
|
UserMessage {
|
||||||
|
/// The [`Event::MessageQueued`] this resolves, when it waited. The
|
||||||
|
/// phone has a bubble on screen for the waiting message and needs to
|
||||||
|
/// know *which* one this is, rather than matching on the text and
|
||||||
|
/// clearing the wrong one when the same thing was sent twice.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<String>,
|
||||||
|
text: String,
|
||||||
|
/// What was attached, by the ref the files route serves. On the
|
||||||
|
/// message rather than beside it: these used to be their own `Image`
|
||||||
|
/// events just before, which left the phone deciding from adjacency
|
||||||
|
/// which message an image belonged to. `images` on disk until
|
||||||
|
/// 2026-09-03, when files joined them; the alias reads the older rows.
|
||||||
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<AttachmentRef>,
|
||||||
|
},
|
||||||
|
/// A message accepted from the phone that the session cannot read yet.
|
||||||
|
///
|
||||||
|
/// Recorded, unlike the message itself, and that difference is the point:
|
||||||
|
/// the message belongs in the transcript where the session read it, but
|
||||||
|
/// something has to say it is waiting, and it has to be the server. The
|
||||||
|
/// phone used to remember its own outgoing messages, so leaving the
|
||||||
|
/// screen showed nothing pending when something was.
|
||||||
|
///
|
||||||
|
/// Carries no row of its own; resolved by the `UserMessage` bearing the
|
||||||
|
/// same id, as `CommandQueued` is resolved by `CommandSent`.
|
||||||
|
MessageQueued {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
/// Carried for the same reason [`Event::UserMessage`] carries it,
|
||||||
|
/// and it matters more here: a waiting message is on screen for as
|
||||||
|
/// long as the turn runs, so its attachment has nowhere else to be.
|
||||||
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<AttachmentRef>,
|
||||||
|
},
|
||||||
|
/// A message taken out of the queue before the session read it.
|
||||||
|
///
|
||||||
|
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||||
|
/// server's, so what is waiting has to be answerable from the transcript
|
||||||
|
/// alone. Without it a phone that reconnects replays the `MessageQueued`
|
||||||
|
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
|
||||||
|
/// that normally does is exactly what is not coming.
|
||||||
|
///
|
||||||
|
/// Only ever sent for a message that had not been handed over; see
|
||||||
|
/// [`Unqueued::AlreadySent`].
|
||||||
|
MessageDropped {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
/// A driver has taken one of the user's messages and started reading it.
|
||||||
|
/// The manager turns this into the `UserMessage` above, so it never
|
||||||
|
/// reaches a phone itself.
|
||||||
|
///
|
||||||
|
/// It exists because sending and being read are not the same moment. A
|
||||||
|
/// message sent into a running turn waits, and recording it among things
|
||||||
|
/// already read puts it in the transcript above output that predates it.
|
||||||
|
MessageTaken {
|
||||||
|
/// The `MessageQueued` this answers, or `None` when it never waited.
|
||||||
|
/// Carried through onto the `UserMessage`.
|
||||||
|
id: Option<String>,
|
||||||
|
text: String,
|
||||||
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<AttachmentRef>,
|
||||||
|
},
|
||||||
|
/// Streaming assistant text; the phone renders the concatenation as
|
||||||
|
/// markdown.
|
||||||
|
AssistantText {
|
||||||
|
delta: String,
|
||||||
|
},
|
||||||
|
ToolStart {
|
||||||
|
id: String,
|
||||||
|
tool: String,
|
||||||
|
input: serde_json::Value,
|
||||||
|
},
|
||||||
|
ToolUpdate {
|
||||||
|
id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
ToolEnd {
|
||||||
|
id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
/// An image the session produced or was sent, saved under the session
|
||||||
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
|
Image {
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
image: ImageRef,
|
||||||
|
/// The tool call whose result carried it, when one did. A screenshot
|
||||||
|
/// belongs under the call that took it, not floating beside it -- the
|
||||||
|
/// reader has to pair them by position otherwise, and position is
|
||||||
|
/// exactly what a page boundary breaks.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
about: Option<String>,
|
||||||
|
},
|
||||||
|
/// Anything the session needs a human for: AskUserQuestion, and
|
||||||
|
/// permission requests, are the same shape with different options.
|
||||||
|
Question {
|
||||||
|
id: String,
|
||||||
|
prompt: String,
|
||||||
|
/// A few words naming what the question is about, when the asker
|
||||||
|
/// offered one. `None` for a permission, which is about the call
|
||||||
|
/// above it.
|
||||||
|
header: Option<String>,
|
||||||
|
options: Vec<QuestionOption>,
|
||||||
|
/// Whether several options may be chosen at once. Here rather than
|
||||||
|
/// left for a phone to work out from the dialect underneath: how many
|
||||||
|
/// answers a question takes is a fact about the question, and the
|
||||||
|
/// alternative was Claude Code's tool-input schema written out a
|
||||||
|
/// second time in Kotlin, where no other dialect could reach it.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
multi_select: bool,
|
||||||
|
/// The tool call this is permission for, when it is one, so a phone
|
||||||
|
/// can draw the ask on the tool's own row rather than as a second
|
||||||
|
/// card repeating its input. `None` for anything not about a tool.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
about: Option<String>,
|
||||||
|
},
|
||||||
|
/// A message another agent sent this session.
|
||||||
|
///
|
||||||
|
/// Its own kind rather than a `UserMessage`, because it is not something
|
||||||
|
/// the reader said and a transcript that renders it in their voice is
|
||||||
|
/// claiming they did. It also explains what would otherwise be
|
||||||
|
/// inexplicable: a session working on something nobody here asked for.
|
||||||
|
PeerMessage {
|
||||||
|
/// The sending session's own name, which is what the reader
|
||||||
|
/// recognises it by -- the socket path it came from is not.
|
||||||
|
from: String,
|
||||||
|
text: String,
|
||||||
|
/// The seq of the `Status::Running` that opened the turn this message
|
||||||
|
/// started, so a reader can draw it above that turn.
|
||||||
|
///
|
||||||
|
/// The CLI says nothing about a peer message until the turn's
|
||||||
|
/// `result`, so the event is appended after everything it caused, and
|
||||||
|
/// an append-only transcript cannot go back and insert it. Carrying
|
||||||
|
/// the position instead keeps one order on the wire and one on screen.
|
||||||
|
///
|
||||||
|
/// Filled in by the pump, the only place that knows a seq, and only
|
||||||
|
/// where a turn was open: `None` for a message replayed by `import`,
|
||||||
|
/// which already has it in the right place.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
turn_start: Option<u64>,
|
||||||
|
},
|
||||||
|
/// The manager's record of a question being answered, so a rendered
|
||||||
|
/// question card resolves on every device rather than only the one that
|
||||||
|
/// answered.
|
||||||
|
///
|
||||||
|
/// A list because a question can take several answers, and one that took
|
||||||
|
/// one is the list of length one rather than a different shape.
|
||||||
|
Answered {
|
||||||
|
id: String,
|
||||||
|
answers: Vec<String>,
|
||||||
|
},
|
||||||
|
Status {
|
||||||
|
state: SessionStatus,
|
||||||
|
},
|
||||||
|
/// What the session is set to, as the session itself reports it.
|
||||||
|
///
|
||||||
|
/// Asking for a change and having one are different things, and only this
|
||||||
|
/// is a measurement: a model name the dialect does not know, a mode it
|
||||||
|
/// refuses, or a driver whose model is fixed at startup all leave a
|
||||||
|
/// request that was sent and nothing that changed. Reporting from the
|
||||||
|
/// request put the answer on the phone before the question was answered.
|
||||||
|
///
|
||||||
|
/// Either field alone, because the two are confirmed separately.
|
||||||
|
Settings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
},
|
||||||
|
/// Per-turn token counts, where the dialect reports them.
|
||||||
|
UsageDelta {
|
||||||
|
/// What this turn cost: the tokens it was charged for.
|
||||||
|
tokens: u64,
|
||||||
|
/// What the model was holding when the turn ended -- see
|
||||||
|
/// [`context_tokens`].
|
||||||
|
///
|
||||||
|
/// Carried rather than summed by whoever is reading, because it is
|
||||||
|
/// not a sum: context goes *down* at a compaction and a clear, so
|
||||||
|
/// adding turns up would report a figure the session stopped being
|
||||||
|
/// true of long ago.
|
||||||
|
///
|
||||||
|
/// `None` where the dialect did not say, which every reader has to be
|
||||||
|
/// able to draw.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
context: Option<u64>,
|
||||||
|
},
|
||||||
|
/// A compaction that finished, and how much context it recovered.
|
||||||
|
///
|
||||||
|
/// The counts are the point, and a spinner is not. They are optional
|
||||||
|
/// because the record has shipped without them, and "the compaction
|
||||||
|
/// happened, we don't know by how much" is a state this has to be able to
|
||||||
|
/// say -- a plausible number would be indistinguishable from a counted one.
|
||||||
|
Compacted {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pre_tokens: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
post_tokens: Option<u64>,
|
||||||
|
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||||
|
/// session compacted on its own. Carried rather than reduced to a bool
|
||||||
|
/// so an unrecognised trigger stays unrecognised: an automatic
|
||||||
|
/// compaction is the one worth naming, because it explains a wait
|
||||||
|
/// nobody asked for.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
trigger: Option<String>,
|
||||||
|
},
|
||||||
|
/// A command the session was asked to run on itself, held because it
|
||||||
|
/// cannot run yet. These are not messages: `/compact` and `/rename` are
|
||||||
|
/// instructions about the session, and a session mid-turn reads a line
|
||||||
|
/// written to it as something the model should see. So they wait, and
|
||||||
|
/// this is what a phone draws while they do.
|
||||||
|
CommandQueued {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||||
|
/// stops being pending when this arrives, matched by `id`; a command
|
||||||
|
/// that ran immediately has only this.
|
||||||
|
CommandSent {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// The conversation was cleared: everything above this is still in the
|
||||||
|
/// record but is no longer in the session's context.
|
||||||
|
///
|
||||||
|
/// Nothing is deleted. A transcript is the thing a person scrolls back
|
||||||
|
/// through, so this is a divider, not a truncation.
|
||||||
|
///
|
||||||
|
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||||
|
/// conversation from the transcript, this marker decides what the model
|
||||||
|
/// is given -- dropping it, or treating it as something only the phone
|
||||||
|
/// draws, silently puts a cleared conversation back in front of the model
|
||||||
|
/// at full cost. Today `llama::conversation` is the only fold that reads
|
||||||
|
/// it, which is why this is written down rather than left to be inferred
|
||||||
|
/// from a second example that does not exist.
|
||||||
|
Cleared,
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much the model was holding, from the three figures a turn reports:
|
||||||
|
/// the input side only, prompt plus both cache figures. A cached token is
|
||||||
|
/// cheaper but it is still one the model was given; output is what the turn
|
||||||
|
/// produced rather than what continuing has to carry.
|
||||||
|
///
|
||||||
|
/// One function so the definition cannot drift, because it is extracted two
|
||||||
|
/// quite different ways -- the live translators have the usage object parsed,
|
||||||
|
/// and `import::context_tokens` scans it out of a raw line without parsing.
|
||||||
|
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||||
|
input + cache_creation + cache_read
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The context after `event`, given what it was before.
|
||||||
|
///
|
||||||
|
/// The whole rule in one place, because three readers need the same answer:
|
||||||
|
/// the pump keeping a live session's figure, the transcript seeding it at
|
||||||
|
/// startup, and the phone folding the same events into what it draws.
|
||||||
|
///
|
||||||
|
/// The two that *lower* it are the point. A clear takes the conversation away
|
||||||
|
/// and a compaction replaces it with a summary, so a figure measured before
|
||||||
|
/// either stopped being true at that moment -- and carrying it forward is how
|
||||||
|
/// a session that had just been cleared went on reporting the context it no
|
||||||
|
/// longer had.
|
||||||
|
///
|
||||||
|
/// `None` is "we don't know", which each of them can reach.
|
||||||
|
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||||
|
match event {
|
||||||
|
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||||
|
// measurement standing: stale by a turn, which every context figure
|
||||||
|
// is, rather than wrong.
|
||||||
|
Event::UsageDelta { context, .. } => context.or(current),
|
||||||
|
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||||
|
Event::Cleared => None,
|
||||||
|
_ => current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SessionStatus {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
AwaitingInput,
|
||||||
|
Compacting,
|
||||||
|
Exited,
|
||||||
|
/// There is a process recorded for this session and the machine will not
|
||||||
|
/// say whether it is still running.
|
||||||
|
///
|
||||||
|
/// Its own state rather than the nearest of the others, because both
|
||||||
|
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||||
|
/// second process against a conversation that may already have one, and
|
||||||
|
/// `Idle` claims a session is waiting for you when nobody has checked.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||||
|
/// is flattened so the wire shape stays one flat object.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SeqEvent {
|
||||||
|
pub seq: u64,
|
||||||
|
/// Epoch seconds.
|
||||||
|
pub ts: f64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub event: Event,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
perf.data*
|
||||||
Generated
+5024
File diff suppressed because it is too large.
Load diff
+117
@@ -0,0 +1,117 @@
|
|||||||
|
[package]
|
||||||
|
name = "iris"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iris-core = { workspace = true }
|
||||||
|
iris-macro = { workspace = true }
|
||||||
|
parley = { workspace = true }
|
||||||
|
swash = { workspace = true }
|
||||||
|
pollster = { workspace = true }
|
||||||
|
wgpu = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
accesskit = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# doc comment) but are never compiled together: winit's own Android support
|
||||||
|
# pulls in `android-activity`, which panics at compile time unless one of
|
||||||
|
# its own backend features is picked, and picking one is exactly what
|
||||||
|
# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it
|
||||||
|
# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside
|
||||||
|
# `android-activity` itself with "Either game-activity or native-activity
|
||||||
|
# must be enabled" before this split existed.
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
winit = { workspace = true }
|
||||||
|
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||||
|
# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own
|
||||||
|
# adapter over `accesskit`. No pin needed the way android-view's rev is
|
||||||
|
# pinned -- this is an ordinary crates.io release with no local abort to
|
||||||
|
# track (that finding is Android-only, see below).
|
||||||
|
accesskit_winit = "0.34.0"
|
||||||
|
|
||||||
|
# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this
|
||||||
|
# emulator -- real Vulkan rendering, a working `InputConnection`, and the
|
||||||
|
# accesskit-detach abort, all against this rev specifically. Advancing it
|
||||||
|
# wants re-running E1's checks, the same reason the nightly toolchain pin
|
||||||
|
# is dated rather than floating.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
# I4 (RUST.md): the Android half of the AccessKit push, over android-view's
|
||||||
|
# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1
|
||||||
|
# found on 0.4.0** (the `State` enum still never returns to `Inactive`,
|
||||||
|
# and `send_completed_event` still unwraps a Java exception) -- advancing
|
||||||
|
# the version is not the fix, so pinning to a specific rev buys nothing
|
||||||
|
# here the way it does for android-view itself. `android/view.rs`'s
|
||||||
|
# `raise_if_enabled` is the mitigation, carried from E1.
|
||||||
|
accesskit_android = "0.8.0"
|
||||||
|
# Not re-exported by android-view (only `jni` and `ndk` are), and needed
|
||||||
|
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||||
|
# android-view's own `PEER_MAP` carries one.
|
||||||
|
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]
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
||||||
|
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so the
|
||||||
|
# same build can be measured against SwiftShader's software Vulkan ICD (the
|
||||||
|
# default) or virgl's GLES path, without a second env-var plumbing path that
|
||||||
|
# nothing on this machine can hand to an already-launched Android process
|
||||||
|
# (there is no `am start` environment and no system-property reader here to
|
||||||
|
# add one). Android-only; `android/render.rs` is the only reader.
|
||||||
|
force-gles = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||||
|
# The tabs example's widget tree. A dev-dependency cycle back to this
|
||||||
|
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||||
|
# to build the library itself, so this only matters for `--examples`.
|
||||||
|
tabs-ui = { path = "tabs-ui" }
|
||||||
|
|
||||||
|
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||||
|
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||||
|
# test-crate harness cargo would otherwise want, in favour of an ordinary
|
||||||
|
# `fn main()`.
|
||||||
|
[[bench]]
|
||||||
|
name = "message_list"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
|
||||||
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
|
# workspace, since it has no `[workspace]` table of its own and this
|
||||||
|
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||||
|
# -t x86_64 -P 26 build`.
|
||||||
|
exclude = ["android-app"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
pollster = "0.4.0"
|
||||||
|
winit = "0.30.12"
|
||||||
|
wgpu = "28.0.0"
|
||||||
|
bytemuck = "1.23.1"
|
||||||
|
image = "0.25.6"
|
||||||
|
parley = "0.11.1"
|
||||||
|
swash = "0.2.10"
|
||||||
|
fxhash = "0.2.1"
|
||||||
|
arboard = "3.6.1"
|
||||||
|
accesskit = "0.25.0"
|
||||||
|
iris-core = { path = "core" }
|
||||||
|
iris-macro = { path = "macro" }
|
||||||
|
tokio = "1.49.0"
|
||||||
|
# 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
|
||||||
|
# the identical job (RUST.md), rather than reimplementing a CommonMark
|
||||||
|
# parser.
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
images
|
||||||
|
settings (sampler)
|
||||||
|
|
||||||
|
text
|
||||||
|
figure out ways to speed up / what costs the most
|
||||||
|
resizing (per frame) is really slow (assuming painter isn't griefing)
|
||||||
|
j is weird / fix x offset
|
||||||
|
|
||||||
|
masks r just made to bare minimum work
|
||||||
|
|
||||||
|
scaling
|
||||||
|
could be just a simple scaling factor that multiplies abs
|
||||||
|
and need to ensure text uses raw abs and not scaled abs
|
||||||
|
naming? (pt, px)
|
||||||
|
want to keep (drawn) regions using px? or should I add another field to UiScalar/Vec
|
||||||
|
field could be best solution so redrawing stuff isn't needed & you can specify both as user
|
||||||
|
|
||||||
|
WidgetRef<W> or smth instead of Id
|
||||||
|
enum that's either an Id or an actual concrete instance of W
|
||||||
|
painter takes them in instead of (or in addition to) id
|
||||||
|
then type wrapper widgets to contain them
|
||||||
|
allows for compile time optimization if a widget wrapper's inner is known at compile time
|
||||||
|
and the id of inner is not needed anywhere
|
||||||
|
maybe introduce InnerWidget trait to allow for editors to expose & modify inner type
|
||||||
|
maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible
|
||||||
|
|
||||||
|
really weird limitation:
|
||||||
|
I don't think you can currently remove an element from a parent and put it in a child of the same parent
|
||||||
|
because it removes the unused children after the entire parent redraw
|
||||||
|
but the child gets drawn during that, so it will think the child is still active !!!
|
||||||
|
or something like that idk, maybe I need a special enum for parent that includes a undecided state where it may or may not get redrawn by the parent
|
||||||
|
or just do ref counting and ensure all drawn things == 1 afterwards (seems like best way)
|
||||||
|
ok so I'm removing the limit for now
|
||||||
|
|
||||||
|
don't forget I'm streaming
|
||||||
|
|
||||||
|
tags
|
||||||
|
vecs for each widget type?
|
||||||
|
|
||||||
|
POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..??
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
|
||||||
|
# Gradle build -- see RUST.md's I2 for the exact command.
|
||||||
|
app/src/main/jniLibs/
|
||||||
Generated
+5221
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
|||||||
|
[package]
|
||||||
|
name = "iris-android-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# Deliberately outside the `iris` workspace (see that Cargo.toml's
|
||||||
|
# `[workspace] exclude`): this crate exists only to be cross-compiled with
|
||||||
|
# `cargo ndk` for the emulator/a phone, and pulls in android-view, which
|
||||||
|
# needs the NDK sysroot to link. Folding it into the main workspace would
|
||||||
|
# make `cargo build --workspace --all-targets` -- the host command RUST.md
|
||||||
|
# and AGENTS.md both require to stay clean -- try to link a cdylib against
|
||||||
|
# libraries that do not exist on this machine. See RUST.md's I2.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "main"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iris = { path = "../" }
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
android_logger = "0.15.0"
|
||||||
|
log = "0.4.28"
|
||||||
|
# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's
|
||||||
|
# Android integration) are mutually exclusive -- one `ActiveClient` type is
|
||||||
|
# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps
|
||||||
|
# are optional and each screen's feature pulls in only its own. Without
|
||||||
|
# this, building `--features transcript-screen` alone (default features
|
||||||
|
# still on) left `tabs-ui` linked but never referenced under that cfg,
|
||||||
|
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
||||||
|
tabs-ui = { path = "../tabs-ui", optional = true }
|
||||||
|
transcript-ui = { path = "../transcript-ui", optional = true }
|
||||||
|
client-core = { path = "../../client-core", optional = true }
|
||||||
|
event-model = { path = "../../event-model", optional = true }
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
||||||
|
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
|
||||||
|
# process CPU time, matching `libc::getrusage`'s mention in that box over
|
||||||
|
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
|
||||||
|
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
||||||
|
# at 0.2.179) -- this makes it a direct dependency at the same version
|
||||||
|
# rather than a second, possibly-drifting resolution.
|
||||||
|
libc = { version = "0.2.179", optional = true }
|
||||||
|
# P0's bench build only: the scroll animation and the streaming phase are
|
||||||
|
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
|
||||||
|
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
|
||||||
|
# the battery sampler is a second, concurrent task on that same runtime
|
||||||
|
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
|
||||||
|
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
|
||||||
|
# doesn't call. Version matches the one `iris`'s own dependency tree already
|
||||||
|
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
|
||||||
|
# two.
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["tabs-screen"]
|
||||||
|
tabs-screen = ["dep:tabs-ui"]
|
||||||
|
transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model", "dep:serde_json"]
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes": forces the GLES backend
|
||||||
|
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
||||||
|
# on the feature this forwards to.
|
||||||
|
force-gles = ["iris/force-gles"]
|
||||||
|
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
|
||||||
|
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
# build type drives, run here against `transcript-ui`'s real screen with no
|
||||||
|
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
|
||||||
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
|
# listed, which is how this crate's build command names both explicitly.
|
||||||
|
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Rust side (this directory's Cargo.toml) is built separately with
|
||||||
|
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
|
||||||
|
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
|
||||||
|
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
|
||||||
|
// build command.
|
||||||
|
android {
|
||||||
|
namespace = "dev.iris.android.demo"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "dev.iris.android.demo"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release build must be signed, and the key is per machine rather than per repo -- same
|
||||||
|
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
|
||||||
|
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
|
||||||
|
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
|
||||||
|
// environment; without it a release build here is unsigned, which is fine for everything
|
||||||
|
// except installing.
|
||||||
|
def keystore = System.getenv("AI_APP_KEYSTORE")
|
||||||
|
signingConfigs {
|
||||||
|
if (keystore != null) {
|
||||||
|
release {
|
||||||
|
storeFile = file(keystore)
|
||||||
|
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = "ai-app"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
}
|
||||||
|
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
|
||||||
|
// itself is built separately with `cargo ndk --release --features "transcript-screen
|
||||||
|
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
|
||||||
|
// Gradle here only packages and signs whatever is already there, the same division as the
|
||||||
|
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
|
||||||
|
// installable beside a debug build of the tabs demo rather than replacing it.
|
||||||
|
release {
|
||||||
|
applicationIdSuffix ".bench"
|
||||||
|
if (keystore != null) {
|
||||||
|
signingConfig = signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
|
||||||
|
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
|
||||||
|
no network call and never noticed this was missing. Absent,
|
||||||
|
UreqTransport::new's connect failed with EPERM (Operation not
|
||||||
|
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
|
||||||
|
server would give: a seccomp-level socket denial reads nothing
|
||||||
|
like a network problem, which is what made it worth a comment. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="iris android-view demo"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import org.linebender.android.rustview.RustView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* android-view's abstract base plus the two native methods it has no hook
|
||||||
|
* for: window insets and unregistering this view's entry in
|
||||||
|
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
|
||||||
|
* comment for why those could not ride along on an existing android-view
|
||||||
|
* callback the way the back gesture does.
|
||||||
|
*/
|
||||||
|
public final class IrisView extends RustView {
|
||||||
|
@Override
|
||||||
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
|
native void applyWindowInsetsNative(
|
||||||
|
long peer, int left, int top, int right, int bottom, int imeBottom);
|
||||||
|
|
||||||
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
|
public IrisView(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
unregisterInsetsNative(mViewPeer);
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||||
|
* filling the window, running iris's tabs example through
|
||||||
|
* iris-android-app's Rust side. Mirrors android-view's own
|
||||||
|
* DemoActivity, plus the window-insets wiring that has no android-view
|
||||||
|
* counterpart.
|
||||||
|
*/
|
||||||
|
public final class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("main");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
IrisView view = new IrisView(this);
|
||||||
|
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
view.setFocusable(true);
|
||||||
|
view.setFocusableInTouchMode(true);
|
||||||
|
FrameLayout layout = new FrameLayout(this);
|
||||||
|
layout.addView(view);
|
||||||
|
setContentView(layout);
|
||||||
|
view.requestFocus();
|
||||||
|
|
||||||
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
|
int top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
int imeBottom = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
}
|
||||||
|
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package org.linebender.android.rustview;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.inputmethod.CompletionInfo;
|
||||||
|
import android.view.inputmethod.CorrectionInfo;
|
||||||
|
import android.view.inputmethod.ExtractedText;
|
||||||
|
import android.view.inputmethod.ExtractedTextRequest;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputContentInfo;
|
||||||
|
|
||||||
|
class RustInputConnection implements InputConnection {
|
||||||
|
private final RustView mView;
|
||||||
|
|
||||||
|
RustInputConnection(RustView view) {
|
||||||
|
mView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getViewPeer() {
|
||||||
|
return mView.mViewPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTextBeforeCursor(int n, int flags) {
|
||||||
|
return mView.getTextBeforeCursorNative(getViewPeer(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTextAfterCursor(int n, int flags) {
|
||||||
|
return mView.getTextAfterCursorNative(getViewPeer(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getSelectedText(int flags) {
|
||||||
|
return mView.getSelectedTextNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getCursorCapsMode(int reqModes) {
|
||||||
|
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||||
|
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
|
||||||
|
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||||
|
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingRegion(int start, int end) {
|
||||||
|
return mView.setComposingRegionNative(getViewPeer(), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean finishComposingText() {
|
||||||
|
return mView.finishComposingTextNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||||
|
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitCompletion(CompletionInfo text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitCorrection(CorrectionInfo correctionInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setSelection(int start, int end) {
|
||||||
|
return mView.setSelectionNative(getViewPeer(), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performEditorAction(int editorAction) {
|
||||||
|
return mView.performEditorActionNative(getViewPeer(), editorAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performContextMenuAction(int id) {
|
||||||
|
return mView.performContextMenuActionNative(getViewPeer(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean beginBatchEdit() {
|
||||||
|
return mView.beginBatchEditNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean endBatchEdit() {
|
||||||
|
return mView.endBatchEditNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean sendKeyEvent(KeyEvent event) {
|
||||||
|
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean clearMetaKeyStates(int states) {
|
||||||
|
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean reportFullscreenMode(boolean enabled) {
|
||||||
|
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performPrivateCommand(String action, Bundle data) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean requestCursorUpdates(int cursorUpdateMode) {
|
||||||
|
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Handler getHandler() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeConnection() {
|
||||||
|
mView.closeInputConnectionNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package org.linebender.android.rustview;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.Choreographer;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.SurfaceHolder;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo;
|
||||||
|
import android.view.accessibility.AccessibilityNodeProvider;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
|
|
||||||
|
public abstract class RustView extends SurfaceView
|
||||||
|
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||||
|
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
||||||
|
// with one deliberate change: `protected` rather than package-private, so a
|
||||||
|
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
|
||||||
|
// it to the window-insets native call android-view itself has no hook for --
|
||||||
|
// see iris/src/android/insets.rs's doc comment for why that call exists at
|
||||||
|
// all. No other line differs from upstream.
|
||||||
|
protected final long mViewPeer;
|
||||||
|
final InputMethodManager mInputMethodManager;
|
||||||
|
|
||||||
|
protected abstract long newViewPeer(Context context);
|
||||||
|
|
||||||
|
public RustView(Context context) {
|
||||||
|
super(context);
|
||||||
|
mViewPeer = newViewPeer(context);
|
||||||
|
getHolder().addCallback(this);
|
||||||
|
mInputMethodManager =
|
||||||
|
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthSpec, int heightSpec) {
|
||||||
|
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
|
||||||
|
if (result != null) {
|
||||||
|
setMeasuredDimension(result[0], result[1]);
|
||||||
|
} else {
|
||||||
|
super.onMeasure(widthSpec, heightSpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onLayoutNative(
|
||||||
|
long peer, boolean changed, int left, int top, int right, int bottom);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||||
|
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
|
||||||
|
super.onLayout(changed, left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||||
|
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||||
|
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onKeyUp(int keyCode, KeyEvent event) {
|
||||||
|
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onTrackballEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTrackballEvent(MotionEvent event) {
|
||||||
|
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onTouchEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||||
|
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onHoverEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onHoverEvent(MotionEvent event) {
|
||||||
|
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onFocusChangedNative(
|
||||||
|
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
|
||||||
|
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
|
||||||
|
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasWindowFocus) {
|
||||||
|
super.onWindowFocusChanged(hasWindowFocus);
|
||||||
|
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onAttachedToWindowNative(long peer);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow();
|
||||||
|
onAttachedToWindowNative(mViewPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onDetachedFromWindowNative(long peer);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
onDetachedFromWindowNative(mViewPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onWindowVisibilityChangedNative(long peer, int visibility);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onWindowVisibilityChanged(int visibility) {
|
||||||
|
super.onWindowVisibilityChanged(visibility);
|
||||||
|
onWindowVisibilityChangedNative(mViewPeer, visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceCreated(SurfaceHolder holder) {
|
||||||
|
surfaceCreatedNative(mViewPeer, holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceChangedNative(
|
||||||
|
long peer, SurfaceHolder holder, int format, int width, int height);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||||
|
surfaceChangedNative(mViewPeer, holder, format, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||||
|
surfaceDestroyedNative(mViewPeer, holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void postFrameCallback() {
|
||||||
|
Choreographer c = Choreographer.getInstance();
|
||||||
|
c.removeFrameCallback(this);
|
||||||
|
c.postFrameCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFrameCallback() {
|
||||||
|
Choreographer.getInstance().removeFrameCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void doFrameNative(long peer, long frameTimeNanos);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFrame(long frameTimeNanos) {
|
||||||
|
doFrameNative(mViewPeer, frameTimeNanos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void delayedCallbackNative(long peer);
|
||||||
|
|
||||||
|
private final Runnable mDelayedCallback =
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
delayedCallbackNative(mViewPeer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
boolean postDelayed(long delayMillis) {
|
||||||
|
return postDelayed(mDelayedCallback, delayMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean removeDelayedCallbacks() {
|
||||||
|
return removeCallbacks(mDelayedCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean hasAccessibilityNodeProviderNative(long peer);
|
||||||
|
|
||||||
|
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
|
||||||
|
long peer, int virtualViewId);
|
||||||
|
|
||||||
|
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
|
||||||
|
|
||||||
|
private native boolean performAccessibilityActionNative(
|
||||||
|
long peer, int virtualViewId, int action, Bundle arguments);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
|
||||||
|
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
|
||||||
|
return super.getAccessibilityNodeProvider();
|
||||||
|
}
|
||||||
|
return new AccessibilityNodeProvider() {
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
|
||||||
|
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeInfo findFocus(int focusType) {
|
||||||
|
return accessibilityFindFocusNative(mViewPeer, focusType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
|
||||||
|
return performAccessibilityActionNative(
|
||||||
|
mViewPeer, virtualViewId, action, arguments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||||
|
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new RustInputConnection(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
native String getTextBeforeCursorNative(long peer, int n);
|
||||||
|
|
||||||
|
native String getTextAfterCursorNative(long peer, int n);
|
||||||
|
|
||||||
|
native String getSelectedTextNative(long peer);
|
||||||
|
|
||||||
|
native int getCursorCapsModeNative(long peer, int reqModes);
|
||||||
|
|
||||||
|
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
|
||||||
|
|
||||||
|
native boolean deleteSurroundingTextInCodePointsNative(
|
||||||
|
long peer, int beforeLength, int afterLength);
|
||||||
|
|
||||||
|
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
|
||||||
|
|
||||||
|
native boolean setComposingRegionNative(long peer, int start, int end);
|
||||||
|
|
||||||
|
native boolean finishComposingTextNative(long peer);
|
||||||
|
|
||||||
|
native boolean commitTextNative(long peer, String text, int newCursorPosition);
|
||||||
|
|
||||||
|
native boolean setSelectionNative(long peer, int start, int end);
|
||||||
|
|
||||||
|
native boolean performEditorActionNative(long peer, int editorAction);
|
||||||
|
|
||||||
|
native boolean performContextMenuActionNative(long peer, int id);
|
||||||
|
|
||||||
|
native boolean beginBatchEditNative(long peer);
|
||||||
|
|
||||||
|
native boolean endBatchEditNative(long peer);
|
||||||
|
|
||||||
|
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
|
||||||
|
|
||||||
|
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
|
||||||
|
|
||||||
|
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
|
||||||
|
|
||||||
|
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
|
||||||
|
|
||||||
|
native void closeInputConnectionNative(long peer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.4.0" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// 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}")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "iris-android-demo"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
|
||||||
|
//! the same fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
|
||||||
|
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||||
|
//! that measures the renderer rather than the data or the network.
|
||||||
|
//!
|
||||||
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
|
||||||
|
//! `transcript_ui::build_tree` rebuild per event) with the network half
|
||||||
|
//! replaced by the checked-in fixture, embedded with `include_str!` --
|
||||||
|
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
|
||||||
|
//! by `app/bench-fixture/generate.py` and never a real transcript (that
|
||||||
|
//! file's own README). The first 3,200 lines are the opening backlog,
|
||||||
|
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
||||||
|
//! as a real `/transcript` page would be; the remaining ~400 are the
|
||||||
|
//! streaming tail, replayed one at a time through `fold_event` -- the same
|
||||||
|
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
|
||||||
|
//! control below.
|
||||||
|
|
||||||
|
use crate::bench_jni::PlatformHandle;
|
||||||
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench
|
||||||
|
/// runs are the same gesture and the same load, which is the entire point
|
||||||
|
/// of a shared fixture and a shared scripted loop (P0's pass condition).
|
||||||
|
const CYCLES: usize = 6;
|
||||||
|
const SWIPE_PX: f32 = 900.0;
|
||||||
|
const SWIPE_MS: u64 = 200;
|
||||||
|
const SWIPE_PAUSE_MS: u64 = 500;
|
||||||
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||||
|
const STREAM_SECONDS: u64 = 20;
|
||||||
|
/// One animation step's target cadence -- close enough to 60Hz that a
|
||||||
|
/// `List::scroll` swipe is many small moves rather than one jump, so
|
||||||
|
/// frames are actually rendered along the way (the point of animating it
|
||||||
|
/// at all rather than calling `scroll` once per swipe).
|
||||||
|
const ANIM_STEP_MS: u64 = 16;
|
||||||
|
|
||||||
|
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||||
|
|
||||||
|
pub struct BenchClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
report_display: WeakWidget<TextEdit>,
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||||
|
/// clone, kept here only as the source a second run would need (the
|
||||||
|
/// button can be pressed more than once; `running` just stops overlap,
|
||||||
|
/// not repeat).
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
|
last_report: Option<String>,
|
||||||
|
running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasAndroidUiState for BenchClient {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
|
||||||
|
/// the syscall itself fails, which UI_RULES.md's "never present an
|
||||||
|
/// inferred value as a measured one" says to keep apart from a real (and
|
||||||
|
/// here, impossible) zero.
|
||||||
|
fn process_cpu_ms() -> Option<u64> {
|
||||||
|
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||||
|
// initialises on success; on failure it is never read.
|
||||||
|
unsafe {
|
||||||
|
let mut usage: libc::rusage = std::mem::zeroed();
|
||||||
|
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
||||||
|
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
||||||
|
Some(user_ms + sys_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
|
||||||
|
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
|
||||||
|
/// two reports' numbers mean the same thing.
|
||||||
|
fn peak_rss_kb() -> Option<u64> {
|
||||||
|
std::fs::read_to_string("/proc/self/status")
|
||||||
|
.ok()?
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmHWM:"))
|
||||||
|
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
||||||
|
.and_then(|n| n.trim().parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn battery_line(samples: &[i32]) -> String {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return " battery current: unavailable on this device".to_string();
|
||||||
|
}
|
||||||
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
|
let min = samples.iter().min().unwrap();
|
||||||
|
let max = samples.iter().max().unwrap();
|
||||||
|
format!(
|
||||||
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||||
|
samples.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidAppState for BenchClient {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
let content = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading fixture...");
|
||||||
|
content(rsc).set(loading);
|
||||||
|
|
||||||
|
let report_display = wtext("")
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(true)
|
||||||
|
.size(14)
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.attr::<Selectable>(())
|
||||||
|
.label("Benchmark report")
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let controls = bench_controls(rsc);
|
||||||
|
let tree = (
|
||||||
|
controls,
|
||||||
|
content.height(rest(2)),
|
||||||
|
report_display.height(rest(1)).pad(8),
|
||||||
|
)
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(tree);
|
||||||
|
|
||||||
|
let mut client = Self {
|
||||||
|
ui_state,
|
||||||
|
content,
|
||||||
|
report_display,
|
||||||
|
screen: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
stream_tail: Vec::new(),
|
||||||
|
platform: None,
|
||||||
|
last_report: None,
|
||||||
|
running: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (backlog, stream_tail) = parse_fixture();
|
||||||
|
client.stream_tail = stream_tail;
|
||||||
|
match fold_page(&backlog) {
|
||||||
|
Ok(items) => {
|
||||||
|
client.items = items;
|
||||||
|
client.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
|
||||||
|
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rsc = AndroidRsc<BenchClient>;
|
||||||
|
|
||||||
|
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||||
|
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.start_benchmark(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Run benchmark");
|
||||||
|
let run = (
|
||||||
|
run_rect,
|
||||||
|
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
|
ctx.state.copy_report();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Copy report");
|
||||||
|
let copy = (
|
||||||
|
copy_rect,
|
||||||
|
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BenchClient {
|
||||||
|
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.content)(rsc).set(widget);
|
||||||
|
self.screen = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
|
let rows = group_tool_runs(&self.items);
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_report(&mut self) {
|
||||||
|
let Some(report) = &self.last_report else {
|
||||||
|
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(platform) = &self.platform else {
|
||||||
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if platform.copy_to_clipboard("iris bench report", report) {
|
||||||
|
log::info!("iris bench report: copied to clipboard");
|
||||||
|
} else {
|
||||||
|
log::info!("iris bench report: clipboard copy failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming
|
||||||
|
/// phase, then the report -- run in-process for the same reason that
|
||||||
|
/// file's own doc gives (no usable system tracing on a real phone, no
|
||||||
|
/// agent that can drive one).
|
||||||
|
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||||
|
if self.running {
|
||||||
|
log::info!("iris bench report: already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.running = true;
|
||||||
|
self.android_state_mut().frame_report.reset();
|
||||||
|
self.report_display.edit(rsc).set("Running benchmark...");
|
||||||
|
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let platform = self.platform.clone();
|
||||||
|
let stream_tail = self.stream_tail.clone();
|
||||||
|
let cpu_start = process_cpu_ms();
|
||||||
|
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
// The swipe loop: two drags toward newer content, two back --
|
||||||
|
// a cycle returns to where it started, so the whole loop
|
||||||
|
// measures steady-state scrolling. `BenchRun.kt`'s own
|
||||||
|
// comment on this shape.
|
||||||
|
for _ in 0..CYCLES {
|
||||||
|
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
|
||||||
|
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned to the newest end before streaming starts, matching
|
||||||
|
// `stream-bench.sh`'s "Jump to latest" tap.
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).jump_to_end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
|
||||||
|
// The battery sampler runs concurrently with the streaming
|
||||||
|
// phase, once a second, the same cadence `BatterySampler` uses
|
||||||
|
// on the Compose side -- via its own JNI-attached thread, not
|
||||||
|
// `ctx.update`, since a sample needs no widget-tree access.
|
||||||
|
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||||
|
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new()));
|
||||||
|
let sampler = platform.clone().map(|platform| {
|
||||||
|
let done = sampler_done.clone();
|
||||||
|
let samples = samples.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while !done.load(Ordering::Relaxed) {
|
||||||
|
if let Some(value) = platform.battery_current_ua() {
|
||||||
|
samples.lock().unwrap().push(value);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||||
|
let mut sent = 0usize;
|
||||||
|
for event in stream_tail.into_iter().take(total) {
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
state.items = fold_event(&state.items, &event);
|
||||||
|
state.rebuild_transcript(rsc);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
sent += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||||
|
}
|
||||||
|
// Lets the last few deltas land and draw before the report is
|
||||||
|
// read -- `BenchRun.kt`'s own closing delay.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
sampler_done.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(sampler) = sampler {
|
||||||
|
let _ = sampler.await;
|
||||||
|
}
|
||||||
|
let battery = battery_line(&samples.lock().unwrap());
|
||||||
|
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||||
|
(Some(start), Some(end)) => {
|
||||||
|
format!(" process CPU time over this run: {}ms", end.saturating_sub(start))
|
||||||
|
}
|
||||||
|
_ => " process CPU time over this run: unavailable".to_string(),
|
||||||
|
};
|
||||||
|
let rss_line = match peak_rss_kb() {
|
||||||
|
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||||
|
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
state.running = false;
|
||||||
|
let scroll_line = format!(
|
||||||
|
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events",
|
||||||
|
CYCLES * 4
|
||||||
|
);
|
||||||
|
let frames_line = match state.android_state().frame_report.report() {
|
||||||
|
Some(stats) => format!("{stats}"),
|
||||||
|
None => "no frames recorded".to_string(),
|
||||||
|
};
|
||||||
|
let report = format!(
|
||||||
|
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||||
|
);
|
||||||
|
log::info!("iris bench report: {report}");
|
||||||
|
state.report_display.edit(rsc).set(&report);
|
||||||
|
state.last_report = Some(report);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps,
|
||||||
|
/// so the swipe is many rendered frames rather than one jump -- the same
|
||||||
|
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose
|
||||||
|
/// side, in the one place the two backends have to differ (iris's `List`
|
||||||
|
/// has no built-in tween, so this drives it by hand).
|
||||||
|
async fn animate_scroll(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn iris::task::RequestRedraw>,
|
||||||
|
total_px: f32,
|
||||||
|
duration_ms: u64,
|
||||||
|
) {
|
||||||
|
let steps = (duration_ms / ANIM_STEP_MS).max(1);
|
||||||
|
let step_px = total_px / steps as f32;
|
||||||
|
for _ in 0..steps {
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).scroll(step_px);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||||
|
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||||
|
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||||
|
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
|
||||||
|
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
|
||||||
|
//! part of `android_view::context`'s own `Context`/`Resources` wrappers
|
||||||
|
//! (that file's own `// TODO: more methods?`), so this calls them
|
||||||
|
//! directly rather than growing that crate's wrapper for two one-off
|
||||||
|
//! calls this crate alone needs.
|
||||||
|
//!
|
||||||
|
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||||
|
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
|
||||||
|
//! whichever thread calls it -- the battery sampler runs on a background
|
||||||
|
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
|
||||||
|
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
|
||||||
|
//! already attached (the `jni` crate detects it and does not double
|
||||||
|
//! attach), so no caller here needs to know or care which thread it is.
|
||||||
|
|
||||||
|
use android_view::jni::{
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
objects::{GlobalRef, JObject, JValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
|
||||||
|
/// as a constant anywhere reachable without the Android SDK jar, so named
|
||||||
|
/// here with its source rather than left as a bare `2`.
|
||||||
|
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
|
||||||
|
|
||||||
|
pub struct PlatformHandle {
|
||||||
|
vm: JavaVM,
|
||||||
|
view: GlobalRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlatformHandle {
|
||||||
|
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
|
||||||
|
Self { vm, view }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
|
||||||
|
env.call_method(
|
||||||
|
self.view.as_obj(),
|
||||||
|
"getContext",
|
||||||
|
"()Landroid/content/Context;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_service<'e>(
|
||||||
|
&self,
|
||||||
|
env: &mut JNIEnv<'e>,
|
||||||
|
context: &JObject<'e>,
|
||||||
|
name: &str,
|
||||||
|
) -> Option<JObject<'e>> {
|
||||||
|
let jname = env.new_string(name).ok()?;
|
||||||
|
env.call_method(
|
||||||
|
context,
|
||||||
|
"getSystemService",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
|
&[JValue::Object(jname.as_ref())],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
|
||||||
|
/// on any JNI failure, on a device with no `BatteryManager` service,
|
||||||
|
/// or when the platform itself answers "not supported" -- `0` or
|
||||||
|
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
|
||||||
|
/// both would read as a real (and wrong) measurement if folded into an
|
||||||
|
/// average rather than named apart. UI_RULES.md: never present an
|
||||||
|
/// inferred value as a measured one.
|
||||||
|
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let battery_manager = self.system_service(env, &context, "batterymanager")?;
|
||||||
|
let value = env
|
||||||
|
.call_method(
|
||||||
|
&battery_manager,
|
||||||
|
"getIntProperty",
|
||||||
|
"(I)I",
|
||||||
|
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.i()
|
||||||
|
.ok()?;
|
||||||
|
if value == 0 || value == i32::MIN {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `text` on the system clipboard through `ClipboardManager` --
|
||||||
|
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
|
||||||
|
/// `setPrimaryClip`) succeeded.
|
||||||
|
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
|
||||||
|
self.try_copy_to_clipboard(label, text).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let clipboard = self.system_service(env, &context, "clipboard")?;
|
||||||
|
let jlabel = env.new_string(label).ok()?;
|
||||||
|
let jtext = env.new_string(text).ok()?;
|
||||||
|
let clip = env
|
||||||
|
.call_static_method(
|
||||||
|
"android/content/ClipData",
|
||||||
|
"newPlainText",
|
||||||
|
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||||
|
&[
|
||||||
|
JValue::Object(jlabel.as_ref()),
|
||||||
|
JValue::Object(jtext.as_ref()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()?;
|
||||||
|
env.call_method(
|
||||||
|
&clipboard,
|
||||||
|
"setPrimaryClip",
|
||||||
|
"(Landroid/content/ClipData;)V",
|
||||||
|
&[JValue::Object(&clip)],
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! The android-view demo app: by default, iris's `tabs` widget tree
|
||||||
|
//! (`tabs_ui::build`, shared with the winit example) running through
|
||||||
|
//! `iris::android`'s `ViewPeer`. This is RUST.md's I2 pass condition made
|
||||||
|
//! concrete -- there is no UI here beyond what `tabs-ui` already draws.
|
||||||
|
//!
|
||||||
|
//! `JNI_OnLoad` and `new_view_peer` mirror android-view's own demo
|
||||||
|
//! (`~/src/android-view/demo/src/lib.rs`): the only android-view-specific
|
||||||
|
//! plumbing a real app needs is registering its `View` subclass and
|
||||||
|
//! wrapping `iris::android::new_peer`'s generic function in a concrete
|
||||||
|
//! `extern "system" fn`, since `register_view_class` wants a plain
|
||||||
|
//! function pointer.
|
||||||
|
//!
|
||||||
|
//! **`transcript-screen` feature (RUST.md's I5 Android integration):** with
|
||||||
|
//! `--features transcript-screen`, `new_view_peer` instantiates
|
||||||
|
//! `transcript_client::TranscriptClient` instead of the tabs `Client`
|
||||||
|
//! below, against a real `ai-server` (see that module's doc). Chosen over a
|
||||||
|
//! third shell crate: this one already has the Gradle project, the
|
||||||
|
//! `IrisView`/`MainActivity` Java, and the JNI registration I2 built and
|
||||||
|
//! measured against, and the only thing a transcript screen needs on top
|
||||||
|
//! is a different `AndroidAppState` -- the same axis `tabs_ui::build` vs.
|
||||||
|
//! `transcript_ui::build` already varies along on the winit side (compare
|
||||||
|
//! `iris/examples/tabs.rs` and `iris/transcript-ui/examples/transcript.rs`).
|
||||||
|
//! A build picks one screen or the other, never both, so `Client` and
|
||||||
|
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
||||||
|
//! there is no in-app navigation to switch *to* on either side yet.
|
||||||
|
//!
|
||||||
|
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
|
||||||
|
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
|
||||||
|
//! `transcript_ui::build_tree` again, this time against the checked-in
|
||||||
|
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
|
||||||
|
//! server, with a "Run benchmark" control that drives the same scroll loop
|
||||||
|
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
|
||||||
|
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
|
||||||
|
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
|
||||||
|
//! enabled together -- `ActiveClient` below gives `bench` priority in that
|
||||||
|
//! case, the same way `transcript-screen` already takes priority over the
|
||||||
|
//! default `tabs-screen`.
|
||||||
|
|
||||||
|
use android_view::{
|
||||||
|
Context, View,
|
||||||
|
jni::{
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||||
|
},
|
||||||
|
register_view_class,
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
use iris::prelude::*;
|
||||||
|
use log::LevelFilter;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_client;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_jni;
|
||||||
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
|
mod transcript_client;
|
||||||
|
|
||||||
|
/// The app's `View` subclass, matching the Java side's package --
|
||||||
|
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
|
||||||
|
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
impl HasAndroidUiState for Client {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
impl AndroidAppState for Client {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
// `widgets.info` is the winit example's frame-debug readout, kept
|
||||||
|
// current from `DefaultAppState::window_event` -- android-view has
|
||||||
|
// no per-frame hook to drive the equivalent from here yet, so it
|
||||||
|
// is left at its built "" text rather than wired to nothing.
|
||||||
|
let _ = tabs_ui::build(rsc, &mut ui_state);
|
||||||
|
Self { ui_state }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
// Nothing in the tabs example has a back stack of its own to pop --
|
||||||
|
// declining lets the activity finish, which is the same "no
|
||||||
|
// handler" behaviour the default impl gives. Present as an
|
||||||
|
// explicit override (rather than relying on the default) so a
|
||||||
|
// reader checking "does the back gesture reach this app" finds an
|
||||||
|
// answer here rather than nothing.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
type ActiveClient = Client;
|
||||||
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
|
type ActiveClient = transcript_client::TranscriptClient;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
type ActiveClient = bench_client::BenchClient;
|
||||||
|
|
||||||
|
extern "system" fn new_view_peer<'local>(
|
||||||
|
env: JNIEnv<'local>,
|
||||||
|
view: View<'local>,
|
||||||
|
context: Context<'local>,
|
||||||
|
) -> jlong {
|
||||||
|
iris::android::new_peer::<ActiveClient>(env, view, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// Interacting with JNI at load time is always unsafe at some level --
|
||||||
|
/// mirrors android-view's own demo, which carries the same comment.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
||||||
|
android_logger::init_once(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(LevelFilter::Debug)
|
||||||
|
.with_tag("iris-android-app"),
|
||||||
|
);
|
||||||
|
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
|
||||||
|
let mut env = vm.get_env().unwrap();
|
||||||
|
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
|
||||||
|
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||||
|
JNI_VERSION_1_6
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the
|
||||||
|
//! whole window on android-view, against a real `ai-server` through
|
||||||
|
//! `client-core` -- the missing half `iris-android-app` (I2) only had for
|
||||||
|
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
|
||||||
|
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
|
||||||
|
//! demo I2/I4 already measured against.
|
||||||
|
//!
|
||||||
|
//! **Deliberate simplification, recorded rather than left to be
|
||||||
|
//! 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
|
||||||
|
//! pinned CA are baked in at build time (`build.rs`'s
|
||||||
|
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
|
||||||
|
//! session `ApiClient::fetch_sessions` returns is opened automatically --
|
||||||
|
//! 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
|
||||||
|
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
|
||||||
|
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
|
||||||
|
//! not this pass's job.
|
||||||
|
//!
|
||||||
|
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||||
|
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||||
|
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||||
|
//! a stale background response, and a full rebuild of the widget tree on
|
||||||
|
//! every event (same tradeoff, same reason: `push_row` cannot update a row
|
||||||
|
//! already on screen, and this rig's conversations are small). What
|
||||||
|
//! differs is only the redraw mechanism: android-view has no
|
||||||
|
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
|
||||||
|
//! (new, added alongside this box) to request a frame after each
|
||||||
|
//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single
|
||||||
|
//! end-of-future redraw -- see that method's own doc for why.
|
||||||
|
|
||||||
|
use client_core::api::{ApiClient, UreqTransport};
|
||||||
|
use client_core::event_stream::{StreamItem, follow_session_events};
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
mod pinned {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TranscriptClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
/// The screen's own content -- everything under the fixed
|
||||||
|
/// [`frame_report_controls`] bar, which is built once (`new`, below)
|
||||||
|
/// and never touched by `show_message`/`rebuild_transcript`'s own
|
||||||
|
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
||||||
|
/// by rebuilding the session list beside it.
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
/// The folded transcript as of the last rebuild -- kept here (not
|
||||||
|
/// re-derived) for the same reason `desktop-app`'s `Client::items`
|
||||||
|
/// exists: a live `StreamEvent` only carries one new wire event, and
|
||||||
|
/// `fold_event` needs everything folded so far to fold it in.
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
/// The session currently open -- `None` only before the first fetch
|
||||||
|
/// resolves. Read back by `apply_event`'s rebuild, which has no session
|
||||||
|
/// id of its own (a live `SeqEvent` doesn't carry one).
|
||||||
|
session_id: Option<String>,
|
||||||
|
/// Bumped every time a new session load starts; a background response
|
||||||
|
/// checks it before touching state, so a slow reply for a session this
|
||||||
|
/// screen has moved on from can't overwrite what replaced it. There is
|
||||||
|
/// only ever one session here (no list to switch away to), but the
|
||||||
|
/// guard still matters for the *first* fetch racing a `stop`/`start`.
|
||||||
|
generation: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasAndroidUiState for TranscriptClient {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
|
||||||
|
/// twice per session load, same as `desktop-app`'s `build_transport`
|
||||||
|
/// closure -- `ApiClient` and the live-stream follow each need their own,
|
||||||
|
/// since `UreqTransport` holds its own `ureq::Agent`.
|
||||||
|
fn build_transport() -> Result<UreqTransport, String> {
|
||||||
|
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
|
||||||
|
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 {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
|
||||||
|
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
|
||||||
|
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
|
||||||
|
/// frames at all -- this is the screen's own equivalent of the Compose
|
||||||
|
/// app's "Copy render timings" control, logged rather than clipboarded
|
||||||
|
/// (no clipboard wiring exists here) under this crate's own fixed
|
||||||
|
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
|
||||||
|
/// grep-able on the fixed string `"iris frame report"` the way
|
||||||
|
/// `transcript-bench.sh` greps `"ai-app render report"`.
|
||||||
|
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||||
|
type Rsc = AndroidRsc<TranscriptClient>;
|
||||||
|
let report_rect = rect(Color::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
||||||
|
.state
|
||||||
|
.android_state()
|
||||||
|
.frame_report
|
||||||
|
.report()
|
||||||
|
{
|
||||||
|
Some(stats) => log::info!("iris frame report: {stats}"),
|
||||||
|
None => log::info!(
|
||||||
|
"iris frame report: no frames recorded -- scroll first, then press this"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Frame report");
|
||||||
|
let report = (
|
||||||
|
report_rect,
|
||||||
|
wtext("Frame report").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let reset_rect = rect(Color::rgb(70, 40, 40))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
|
ctx.state.android_state_mut().frame_report.reset();
|
||||||
|
log::info!("iris frame report: reset");
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Reset frame report");
|
||||||
|
let reset = (
|
||||||
|
reset_rect,
|
||||||
|
wtext("Reset").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidAppState for TranscriptClient {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
let content = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading sessions...");
|
||||||
|
content(rsc).set(loading);
|
||||||
|
|
||||||
|
let tree = (frame_report_controls(rsc), content.height(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(tree);
|
||||||
|
|
||||||
|
let mut client = Self {
|
||||||
|
ui_state,
|
||||||
|
content,
|
||||||
|
screen: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
session_id: None,
|
||||||
|
generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
};
|
||||||
|
client.spawn_fetch_sessions(rsc);
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
// No screen stack of its own -- same "let the activity finish"
|
||||||
|
// answer `iris-android-app`'s tabs `Client` already gives.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptClient {
|
||||||
|
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.content)(rsc).set(widget);
|
||||||
|
self.screen = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let my_generation = self.generation.load(Ordering::SeqCst);
|
||||||
|
let generation = self.generation.clone();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
let outcome = match build_transport() {
|
||||||
|
Ok(transport) => ApiClient::new(transport)
|
||||||
|
.fetch_sessions()
|
||||||
|
.map_err(|e| e.to_string()),
|
||||||
|
Err(e) => Err(format!("couldn't set up TLS: {e}")),
|
||||||
|
};
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok(sessions) => match sessions.into_iter().next() {
|
||||||
|
Some(session) => state.select_session(rsc, session.id),
|
||||||
|
None => state.show_message(rsc, "No sessions on the sandbox server."),
|
||||||
|
},
|
||||||
|
Err(message) => {
|
||||||
|
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the opening page, then follows the live SSE stream for the
|
||||||
|
/// rest of this session's life -- `desktop-app`'s `select_session`
|
||||||
|
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
|
||||||
|
/// `redraw.request_redraw()` (see this module's doc).
|
||||||
|
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||||
|
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
self.items.clear();
|
||||||
|
self.session_id = Some(session_id.clone());
|
||||||
|
self.show_message(rsc, "Loading transcript...");
|
||||||
|
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let live_generation = self.generation.clone();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
let transports =
|
||||||
|
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
|
||||||
|
let (rest, stream_transport) = match transports {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
let message = format!("couldn't set up TLS: {e}");
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if live_generation.load(Ordering::SeqCst) == my_generation {
|
||||||
|
state.show_message(rsc, &message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let api = ApiClient::new(rest);
|
||||||
|
|
||||||
|
// The most recent 200 events, coalesced -- the same page size
|
||||||
|
// `desktop-app` uses; RUST.md's I3/history-paging work is what
|
||||||
|
// a real scrollback would reuse (out of scope here, same as
|
||||||
|
// E4).
|
||||||
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
// The wire `seq` of the last line, not a folded item's `seq()`
|
||||||
|
// -- see `client_core::transcript_fold::raw_seq`'s doc for why
|
||||||
|
// resuming from the latter re-delivers deltas already folded
|
||||||
|
// into an in-progress reply.
|
||||||
|
let after = page
|
||||||
|
.as_ref()
|
||||||
|
.ok()
|
||||||
|
.and_then(|values| values.last())
|
||||||
|
.and_then(client_core::transcript_fold::raw_seq)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let result = page.and_then(|values| fold_page(&values));
|
||||||
|
|
||||||
|
{
|
||||||
|
let live_generation = live_generation.clone();
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(items) => {
|
||||||
|
state.items = items;
|
||||||
|
state.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
redraw.request_redraw();
|
||||||
|
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The outer closure here is an `FnMut` -- `follow_session_events`
|
||||||
|
// calls it once per line -- so it captures `live_generation` by
|
||||||
|
// move and re-clones it for each inner `ctx.update` closure
|
||||||
|
// rather than moving a shared `stop`-style helper into itself:
|
||||||
|
// a value moved out of an `FnMut`'s captures on one call leaves
|
||||||
|
// nothing there for the next.
|
||||||
|
let _ =
|
||||||
|
follow_session_events(
|
||||||
|
&stream_transport,
|
||||||
|
&session_id,
|
||||||
|
after,
|
||||||
|
move |item| match item {
|
||||||
|
StreamItem::Open | StreamItem::Reset => {
|
||||||
|
live_generation.load(Ordering::SeqCst) == my_generation
|
||||||
|
}
|
||||||
|
StreamItem::Event { event, .. } => {
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let live_generation = live_generation.clone();
|
||||||
|
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||||
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.apply_event(rsc, &event);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
|
||||||
|
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
|
||||||
|
/// Reads `self.session_id` rather than taking one, since every caller
|
||||||
|
/// (the opening page, and every live event) already has it set there.
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||||
|
let in_progress = self
|
||||||
|
.screen
|
||||||
|
.as_ref()
|
||||||
|
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
|
let rows = group_tool_runs(&self.items);
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
|
if let Some(text) = in_progress {
|
||||||
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
|
}
|
||||||
|
if let Some(session_id) = self.session_id.clone() {
|
||||||
|
let field = screen.composer.field;
|
||||||
|
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||||
|
let text = field.edit(rsc).take();
|
||||||
|
let text = text.trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
ctx.state.send_message(session_id.clone(), text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||||
|
self.items = fold_event(&self.items, event);
|
||||||
|
self.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Ok(transport) = build_transport() {
|
||||||
|
let api = ApiClient::new(transport);
|
||||||
|
let _ = api.send_message(&session_id, &text, &[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's
|
||||||
|
//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run
|
||||||
|
//! explicitly with `cargo bench --bench message_list --release` or
|
||||||
|
//! `./run-bench.sh`.
|
||||||
|
//!
|
||||||
|
//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario
|
||||||
|
//! here is really "how many `Widget::draw` calls and primitive rewrites did
|
||||||
|
//! this frame cost," which `UiRenderState::take_counters` already answers
|
||||||
|
//! exactly (see `iris/src/layout_tests.rs`, which this file's harness
|
||||||
|
//! mirrors). A short loop that times itself and prints the counters
|
||||||
|
//! alongside the wall time says everything criterion's warm-up/sampling/
|
||||||
|
//! outlier-removal machinery would add on top, for scenarios that are
|
||||||
|
//! fundamentally about a *count*, not a noisy microbenchmark distribution
|
||||||
|
//! -- and it avoids a new dependency this crate does not otherwise need.
|
||||||
|
//! Per the code rules, the plain option is also the one shorter to explain.
|
||||||
|
//!
|
||||||
|
//! **The list under test is `iris::widget::List` (RUST.md's I3), not a
|
||||||
|
//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this
|
||||||
|
//! file built their own giant `Span` and wrapped it in `Scroll`, which
|
||||||
|
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
|
||||||
|
//! virtualised widget the app's transcript screen actually needs. `List`
|
||||||
|
//! still needs every row's *widget* built up front by the caller (its
|
||||||
|
//! module doc explains why: it only ever sees `&dyn Widget` through
|
||||||
|
//! `Painter`, so it cannot construct a row lazily on its own) -- what
|
||||||
|
//! virtualisation buys is that only the rows currently on screen are ever
|
||||||
|
//! *drawn*, which is what the draw/rewrite/move counters below are
|
||||||
|
//! measuring, not construction time.
|
||||||
|
//!
|
||||||
|
//! Scenarios (LAYOUT.md's O(1) move chain, list.rs's module doc, and
|
||||||
|
//! IRIS_TODO.md's "Benchmarks" wording):
|
||||||
|
//!
|
||||||
|
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
|
||||||
|
//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list
|
||||||
|
//! this is expected to stop scaling with N once N exceeds a screenful --
|
||||||
|
//! the draw/rewrite counters below are the number that used to grow 10x
|
||||||
|
//! per 10x N and should not any more.
|
||||||
|
//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not
|
||||||
|
//! re-layout.
|
||||||
|
//! - (c) the input-box case: growing a fixed-height field at the bottom of
|
||||||
|
//! the screen must move the message list above it, not re-lay its rows.
|
||||||
|
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
|
||||||
|
//! section 8 defines.
|
||||||
|
//! - (d) insert-above-anchor: paging older history onto the front of an
|
||||||
|
//! already-scrolled list. `List::push_front` is an O(1) index update
|
||||||
|
//! (list.rs's module doc); this measures that none of the rows already
|
||||||
|
//! on screen are touched by it.
|
||||||
|
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
|
||||||
|
//! tap recorded near one of its edges (list.rs's `note_tap`) must move
|
||||||
|
//! only the rows on the far side of it, never redraw the ones already
|
||||||
|
//! correctly placed.
|
||||||
|
//!
|
||||||
|
//! (f), many images with zero steady-state bind-group creation, needs a
|
||||||
|
//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead,
|
||||||
|
//! driven through `run-headless.sh` -- see that file's header.
|
||||||
|
//!
|
||||||
|
//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs`
|
||||||
|
//! notes), so everything here runs as an ordinary `--release` binary with
|
||||||
|
//! no compositor. Numbers are recorded in RUST.md's I3 box, not here --
|
||||||
|
//! this file is the rig, not the result.
|
||||||
|
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// The minimal `UiRsc` a benchmark needs -- identical in shape to
|
||||||
|
/// `layout_tests.rs`'s `TestRsc`.
|
||||||
|
struct BenchRsc {
|
||||||
|
ui: UiData,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRsc for BenchRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long enough to force real wrapping at a phone-plausible column width, and
|
||||||
|
/// varied enough (no two rows byte-identical) that nothing can special-case
|
||||||
|
/// on repeated content.
|
||||||
|
const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \
|
||||||
|
out wrapped text by shaping once per width and caching the result, so a \
|
||||||
|
row that is offered the same width twice does not reshape. This sentence \
|
||||||
|
exists only to give a row enough text to wrap across several lines at a \
|
||||||
|
typical phone column width.";
|
||||||
|
|
||||||
|
/// One message row: a wrapped `Text`, and every `image_every`th row also an
|
||||||
|
/// `Image` beneath it -- a small in-memory RGBA square rather than a file,
|
||||||
|
/// so N=10,000 rows costs no disk I/O.
|
||||||
|
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
|
||||||
|
let mut text = Text::new(format!("Message {i}: {BODY}"));
|
||||||
|
text.wrap = true;
|
||||||
|
let text = rsc.ui.widgets.add_strong(text).any();
|
||||||
|
|
||||||
|
if image_every > 0 && i.is_multiple_of(image_every) {
|
||||||
|
let img = image::DynamicImage::new_rgba8(64, 64);
|
||||||
|
let image_widget = image::<BenchRsc>(img)(rsc);
|
||||||
|
let image_widget = rsc.ui.widgets.add_strong(image_widget).any();
|
||||||
|
let mut row = Span::empty(Dir::DOWN);
|
||||||
|
row.push(text);
|
||||||
|
row.push(image_widget);
|
||||||
|
rsc.ui.widgets.add_strong(row).any()
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A virtualised `List` of `n` message rows, one in `image_every` of them
|
||||||
|
/// carrying an image (0 disables images entirely). Returns the list widget
|
||||||
|
/// (weak, so the caller can drive it) and the erased root to render.
|
||||||
|
fn build_message_list(
|
||||||
|
rsc: &mut BenchRsc,
|
||||||
|
n: usize,
|
||||||
|
image_every: usize,
|
||||||
|
) -> (WeakWidget<List>, StrongWidget) {
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
for i in 0..n {
|
||||||
|
let row = build_row(rsc, i, image_every);
|
||||||
|
list.push_back(ListRow::new(i as u64, row));
|
||||||
|
}
|
||||||
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
|
(list.weak(), list.any())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
|
||||||
|
println!(
|
||||||
|
"{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}",
|
||||||
|
elapsed.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (a) First-frame cost of a message list of N rows.
|
||||||
|
fn bench_first_frame(n: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (_list, root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
let (draws, rewrites, moves) = render.take_counters();
|
||||||
|
report(
|
||||||
|
&format!("(a) first frame, N={n}"),
|
||||||
|
elapsed,
|
||||||
|
draws,
|
||||||
|
rewrites,
|
||||||
|
moves,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (b) Per-frame cost of scrolling an already-laid-out list of N rows.
|
||||||
|
/// Warms up (one no-op tick, matching `Scroll`'s own need for it before an
|
||||||
|
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
|
||||||
|
/// rather than a resize), then times a run of individual scroll ticks.
|
||||||
|
fn bench_scroll(n: usize, ticks: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list, root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
for _ in 0..ticks {
|
||||||
|
rsc.ui.widgets.get_mut(&list).unwrap().scroll(-8.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-tick average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / ticks as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (c) The input-box case: a fixed-height field at the bottom of the screen
|
||||||
|
/// growing by a line at a time, with a message list of N rows filling the
|
||||||
|
/// rest of the screen above it. Growing the input shrinks the *offered*
|
||||||
|
/// height of the list container (a single widget, from the outer `Span`'s
|
||||||
|
/// point of view) without changing the width it offers its content -- so
|
||||||
|
/// the rows underneath, which only care about width, must not redraw; the
|
||||||
|
/// list's own re-registration of where its content sits is the one O(1)
|
||||||
|
/// move this is checking for.
|
||||||
|
fn bench_input_grows(n: usize, lines: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list, list_root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let list_area = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: list_root,
|
||||||
|
x: None,
|
||||||
|
y: Some(rest(1.0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let line_height = 24.0;
|
||||||
|
let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let input_area = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: input_rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(abs(line_height)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let input_area_weak = input_area.weak();
|
||||||
|
let mut root_span = Span::empty(Dir::DOWN);
|
||||||
|
root_span.push(list_area.any());
|
||||||
|
root_span.push(input_area.any());
|
||||||
|
let root = rsc.ui.widgets.add_strong(root_span).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
for line in 1..=lines {
|
||||||
|
rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y =
|
||||||
|
Some(abs(line_height * (line + 1) as f32));
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!(
|
||||||
|
"(c) input grows by {lines} lines above N={n} rows (totals; \
|
||||||
|
draws/rewrites must not scale with N)"
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-line average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / lines as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (d) Insert-above-anchor: the list is scrolled to its very first loaded
|
||||||
|
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
|
||||||
|
/// bottom, so a row prepended above it is genuinely "inserted above the
|
||||||
|
/// anchor" rather than merely far off-screen at the far end. Each
|
||||||
|
/// `push_front` is O(1) (list.rs's module doc: the anchor's slot is an
|
||||||
|
/// index, bumped by one) and, since the prepended rows never enter the
|
||||||
|
/// viewport, none of them should cost a draw either.
|
||||||
|
fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list, root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
for i in 0..inserts {
|
||||||
|
// Older-history rows: distinct keys below every existing one, so a
|
||||||
|
// real caller's paging code (prepending an older page) is exactly
|
||||||
|
// what this loop does.
|
||||||
|
let row = build_row(&mut rsc, usize::MAX - i, 20);
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list)
|
||||||
|
.unwrap()
|
||||||
|
.push_front(ListRow::new(i as u64, row));
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!(
|
||||||
|
"(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \
|
||||||
|
must not scale with N)"
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-push average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / inserts as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
|
||||||
|
/// directly controllable) is grown a little at a time, each time preceded
|
||||||
|
/// by `note_tap` aimed at its own top edge -- the exact mechanism list.rs's
|
||||||
|
/// module doc describes and its unit tests check for correctness. This
|
||||||
|
/// measures its *cost*: only the rows on the far side of the grown one
|
||||||
|
/// (below it, since the top edge is held) should ever move, and nothing
|
||||||
|
/// should be redrawn purely because the list overall got taller.
|
||||||
|
fn bench_expand_holds_edge(n: usize, growths: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
// Near the end (not the very last row) so it is already on screen
|
||||||
|
// under the list's default bottom-anchored placement, for every N --
|
||||||
|
// no scrolling needed to bring it into view before measuring.
|
||||||
|
let growable_index = n.saturating_sub(3);
|
||||||
|
let mut growable = None;
|
||||||
|
for i in 0..n {
|
||||||
|
if i == growable_index {
|
||||||
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let sized = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(abs(40.0)),
|
||||||
|
});
|
||||||
|
growable = Some(sized.weak());
|
||||||
|
list.push_back(ListRow::new(i as u64, sized.any()));
|
||||||
|
} else {
|
||||||
|
let row = build_row(&mut rsc, i, 20);
|
||||||
|
list.push_back(ListRow::new(i as u64, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
|
let list_weak = list.weak();
|
||||||
|
let root = list.any();
|
||||||
|
let growable = growable.unwrap();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
let mut height = 40.0f32;
|
||||||
|
let key = growable_index as u64;
|
||||||
|
for _ in 0..growths {
|
||||||
|
height += 10.0;
|
||||||
|
if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) {
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.note_tap(top + 1.0);
|
||||||
|
}
|
||||||
|
rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height));
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!(
|
||||||
|
"(e) expand-hold, N={n}, {growths} growths (totals; \
|
||||||
|
must not scale with N)"
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-growth average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / growths as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("iris message-list benchmark -- release build, this machine's CPU");
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_first_frame(n);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_scroll(n, 200);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_input_grows(n, 40);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_insert_above_anchor(n, 200);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_expand_holds_edge(n, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "iris-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wgpu = { workspace = true }
|
||||||
|
bytemuck ={ workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
parley = { workspace = true }
|
||||||
|
swash = { workspace = true }
|
||||||
|
fxhash = { workspace = true }
|
||||||
|
accesskit = { workspace = true }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike};
|
||||||
|
|
||||||
|
pub trait WidgetAttr<Rsc, W: ?Sized> {
|
||||||
|
type Input;
|
||||||
|
fn run(rsc: &mut Rsc, id: WeakWidget<W>, input: Self::Input);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Attrable<Rsc, W: ?Sized, Tag> {
|
||||||
|
fn attr<A: WidgetAttr<Rsc, W>>(self, input: A::Input) -> impl WidgetIdFn<Rsc, W>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: UiRsc, WL: WidgetLike<Rsc, Tag>, Tag> Attrable<Rsc, WL::Widget, Tag> for WL {
|
||||||
|
fn attr<A: WidgetAttr<Rsc, WL::Widget>>(
|
||||||
|
self,
|
||||||
|
input: A::Input,
|
||||||
|
) -> impl WidgetIdFn<Rsc, WL::Widget> {
|
||||||
|
|rsc| {
|
||||||
|
let id = self.add(rsc);
|
||||||
|
A::run(rsc, id, input);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use crate::{HasEvents, WeakWidget, Widget};
|
||||||
|
|
||||||
|
pub struct EventCtx<'a, Rsc: HasEvents, Data> {
|
||||||
|
pub state: &'a mut Rsc::State,
|
||||||
|
pub data: Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventIdCtx<'a, Rsc: HasEvents, Data, W: ?Sized> {
|
||||||
|
pub widget: WeakWidget<W>,
|
||||||
|
pub state: &'a mut Rsc::State,
|
||||||
|
pub data: Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents, Data, W: Widget> EventIdCtx<'_, Rsc, Data, W> {
|
||||||
|
pub fn widget<'a>(&self, rsc: &'a mut Rsc) -> &'a mut W {
|
||||||
|
&mut rsc.ui_mut().widgets[self.widget]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use crate::{
|
||||||
|
ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, IdLike, LayerId,
|
||||||
|
WeakWidget, WidgetEventFn, WidgetId,
|
||||||
|
util::{HashMap, HashSet, TypeMap},
|
||||||
|
};
|
||||||
|
use std::{any::TypeId, rc::Rc};
|
||||||
|
|
||||||
|
pub struct EventManager<Rsc> {
|
||||||
|
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
|
||||||
|
types: TypeMap<dyn EventManagerLike<Rsc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc> Default for EventManager<Rsc> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
widget_to_types: Default::default(),
|
||||||
|
types: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents + 'static> EventManager<Rsc> {
|
||||||
|
pub fn get_type<E: EventLike>(&mut self) -> &mut TypeEventManager<Rsc, E::Event> {
|
||||||
|
self.types.type_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register<I: IdLike + 'static, E: EventLike>(
|
||||||
|
&mut self,
|
||||||
|
id: I,
|
||||||
|
event: E,
|
||||||
|
f: impl for<'a> WidgetEventFn<Rsc, <E::Event as Event>::Data<'a>, I::Widget>,
|
||||||
|
) {
|
||||||
|
let i = id.id();
|
||||||
|
self.get_type::<E>().register(id, event, f);
|
||||||
|
self.widget_to_types
|
||||||
|
.entry(i)
|
||||||
|
.or_default()
|
||||||
|
.insert(Self::type_key::<E>());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn type_key<E: EventLike>() -> TypeId {
|
||||||
|
TypeId::of::<TypeEventManager<Rsc, E::Event>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EventsLike {
|
||||||
|
fn remove(&mut self, id: WidgetId);
|
||||||
|
fn draw(&mut self, active: &ActiveData);
|
||||||
|
fn undraw(&mut self, active: &ActiveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents + 'static> EventsLike for EventManager<Rsc> {
|
||||||
|
fn remove(&mut self, id: WidgetId) {
|
||||||
|
for t in self.widget_to_types.get(&id).into_flat_iter() {
|
||||||
|
self.types.get_mut(t).unwrap().remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self, active: &ActiveData) {
|
||||||
|
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
|
||||||
|
self.types.get_mut(t).unwrap().draw(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undraw(&mut self, active: &ActiveData) {
|
||||||
|
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
|
||||||
|
self.types.get_mut(t).unwrap().undraw(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EventManagerLike<State> {
|
||||||
|
fn remove(&mut self, id: WidgetId);
|
||||||
|
fn draw(&mut self, data: &ActiveData);
|
||||||
|
fn undraw(&mut self, data: &ActiveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>>);
|
||||||
|
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||||
|
// TODO: reduce visiblity!!
|
||||||
|
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
||||||
|
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents, E: Event> EventManagerLike<Rsc> for TypeEventManager<Rsc, E> {
|
||||||
|
fn remove(&mut self, id: WidgetId) {
|
||||||
|
self.map.remove(&id);
|
||||||
|
for layer in self.active.values_mut() {
|
||||||
|
layer.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn draw(&mut self, data: &ActiveData) {
|
||||||
|
self.active
|
||||||
|
.entry(data.layer)
|
||||||
|
.or_default()
|
||||||
|
.entry(data.id)
|
||||||
|
.or_default();
|
||||||
|
}
|
||||||
|
fn undraw(&mut self, data: &ActiveData) {
|
||||||
|
if let Some(layer) = self.active.get_mut(&data.layer) {
|
||||||
|
layer.remove(&data.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
active: Default::default(),
|
||||||
|
map: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
|
||||||
|
fn register<I: IdLike + 'static>(
|
||||||
|
&mut self,
|
||||||
|
widget: I,
|
||||||
|
event: impl EventLike<Event = E>,
|
||||||
|
f: impl for<'a> WidgetEventFn<Rsc, E::Data<'a>, I::Widget>,
|
||||||
|
) {
|
||||||
|
let event = event.into_event();
|
||||||
|
self.map.entry(widget.id()).or_default().push((
|
||||||
|
event,
|
||||||
|
Rc::new(move |ctx, rsc| {
|
||||||
|
f(
|
||||||
|
EventIdCtx {
|
||||||
|
widget: WeakWidget::new(widget.id()),
|
||||||
|
state: ctx.state,
|
||||||
|
data: ctx.data,
|
||||||
|
},
|
||||||
|
rsc,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event lists this widget was registered with (`register`'s
|
||||||
|
/// `event` argument, one per call), without running anything. Lets a
|
||||||
|
/// caller ask "would this widget's registrations match the current
|
||||||
|
/// state" separately from actually dispatching to it -- used by
|
||||||
|
/// `sense.rs` to decide whether a widget genuinely consumes a scroll
|
||||||
|
/// or press this frame (so a lower layer can still receive it if not)
|
||||||
|
/// without that decision being conflated with "the cursor happens to
|
||||||
|
/// be over it," which is all `run_fn` running something tells you.
|
||||||
|
pub fn registered(&self, id: WidgetId) -> impl Iterator<Item = &E> {
|
||||||
|
self.map.get(&id).into_iter().flatten().map(|(e, _)| e)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_fn<'a>(
|
||||||
|
&mut self,
|
||||||
|
id: impl IdLike,
|
||||||
|
) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) + 'a {
|
||||||
|
let fs = self.map.get(&id.id()).cloned().unwrap_or_default();
|
||||||
|
move |ctx, rsc| {
|
||||||
|
for (e, f) in fs {
|
||||||
|
if let Some(data) = e.should_run(&ctx.data) {
|
||||||
|
f(
|
||||||
|
EventCtx {
|
||||||
|
state: ctx.state,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
rsc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
mod ctx;
|
||||||
|
mod manager;
|
||||||
|
mod rsc;
|
||||||
|
|
||||||
|
pub use ctx::*;
|
||||||
|
pub use manager::*;
|
||||||
|
pub use rsc::*;
|
||||||
|
|
||||||
|
pub trait Event: Sized + 'static + Clone {
|
||||||
|
type Data<'a>: Clone = ();
|
||||||
|
type State: Default = ();
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||||
|
Some(data.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EventLike {
|
||||||
|
type Event: Event;
|
||||||
|
fn into_event(self) -> Self::Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Event> EventLike for E {
|
||||||
|
type Event = Self;
|
||||||
|
|
||||||
|
fn into_event(self) -> Self::Event {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EventFn<Rsc: HasEvents, Data>: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static {}
|
||||||
|
impl<Rsc: HasEvents, F: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static, Data> EventFn<Rsc, Data>
|
||||||
|
for F
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WidgetEventFn<Rsc: HasEvents, Data, W: ?Sized>:
|
||||||
|
Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl<Rsc: HasEvents, F: Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static, Data, W: ?Sized>
|
||||||
|
WidgetEventFn<Rsc, Data, W> for F
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use crate::{
|
||||||
|
Event, EventCtx, EventLike, EventManager, IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait HasState: 'static {
|
||||||
|
type State;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HasEvents: Sized + UiRsc + HasState {
|
||||||
|
fn events(&self) -> &EventManager<Self>;
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self>;
|
||||||
|
|
||||||
|
fn register_event<W: Widget + ?Sized, E: EventLike>(
|
||||||
|
&mut self,
|
||||||
|
id: WeakWidget<W>,
|
||||||
|
event: E,
|
||||||
|
f: impl for<'a> WidgetEventFn<Self, <E::Event as Event>::Data<'a>, W>,
|
||||||
|
) {
|
||||||
|
self.events_mut().register(id, event, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait RunEvents: HasEvents {
|
||||||
|
fn run_event<E: EventLike>(
|
||||||
|
&mut self,
|
||||||
|
id: impl IdLike,
|
||||||
|
data: <E::Event as Event>::Data<'_>,
|
||||||
|
state: &mut Self::State,
|
||||||
|
) {
|
||||||
|
let f = self.events_mut().get_type::<E>().run_fn(id);
|
||||||
|
f(EventCtx { state, data }, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<T: HasEvents> RunEvents for T {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#![feature(macro_metavar_expr_concat)]
|
||||||
|
#![feature(const_ops)]
|
||||||
|
#![feature(const_trait_impl)]
|
||||||
|
#![feature(const_convert)]
|
||||||
|
#![feature(unboxed_closures)]
|
||||||
|
#![feature(fn_traits)]
|
||||||
|
#![feature(const_destruct)]
|
||||||
|
#![feature(associated_type_defaults)]
|
||||||
|
#![feature(unsize)]
|
||||||
|
#![feature(coerce_unsized)]
|
||||||
|
#![feature(option_into_flat_iter)]
|
||||||
|
|
||||||
|
mod attr;
|
||||||
|
mod event;
|
||||||
|
mod num;
|
||||||
|
mod orientation;
|
||||||
|
mod primitive;
|
||||||
|
mod render;
|
||||||
|
mod ui;
|
||||||
|
mod widget;
|
||||||
|
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
|
pub use attr::*;
|
||||||
|
pub use event::*;
|
||||||
|
pub use num::*;
|
||||||
|
pub use orientation::*;
|
||||||
|
pub use primitive::*;
|
||||||
|
pub use render::*;
|
||||||
|
pub use ui::*;
|
||||||
|
pub use widget::*;
|
||||||
|
|
||||||
|
pub type UiColor = primitive::Color<u8>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::util::Vec2;
|
||||||
|
use std::marker::Destruct;
|
||||||
|
|
||||||
|
pub const trait UiNum {
|
||||||
|
fn to_f32(self) -> f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl UiNum for f32 {
|
||||||
|
fn to_f32(self) -> f32 {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl UiNum for u32 {
|
||||||
|
fn to_f32(self) -> f32 {
|
||||||
|
self as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl UiNum for i32 {
|
||||||
|
fn to_f32(self) -> f32 {
|
||||||
|
self as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
|
||||||
|
Vec2::new(x.to_f32(), y.to_f32())
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl<T: const UiNum + Copy> From<T> for Vec2 {
|
||||||
|
fn from(v: T) -> Self {
|
||||||
|
Self {
|
||||||
|
x: v.to_f32(),
|
||||||
|
y: v.to_f32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for Vec2
|
||||||
|
where
|
||||||
|
(T, U): const Destruct,
|
||||||
|
{
|
||||||
|
fn from((x, y): (T, U)) -> Self {
|
||||||
|
Self {
|
||||||
|
x: x.to_f32(),
|
||||||
|
y: y.to_f32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use crate::vec2;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Align {
|
||||||
|
pub x: Option<AxisAlign>,
|
||||||
|
pub y: Option<AxisAlign>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Align {
|
||||||
|
pub const TOP_LEFT: RegionAlign = RegionAlign::TOP_LEFT;
|
||||||
|
pub const TOP_CENTER: RegionAlign = RegionAlign::TOP_CENTER;
|
||||||
|
pub const TOP_RIGHT: RegionAlign = RegionAlign::TOP_RIGHT;
|
||||||
|
pub const CENTER_LEFT: RegionAlign = RegionAlign::CENTER_LEFT;
|
||||||
|
pub const CENTER: RegionAlign = RegionAlign::CENTER;
|
||||||
|
pub const CENTER_RIGHT: RegionAlign = RegionAlign::CENTER_RIGHT;
|
||||||
|
pub const BOT_LEFT: RegionAlign = RegionAlign::BOT_LEFT;
|
||||||
|
pub const BOT_CENTER: RegionAlign = RegionAlign::BOT_CENTER;
|
||||||
|
pub const BOT_RIGHT: RegionAlign = RegionAlign::BOT_RIGHT;
|
||||||
|
pub const LEFT: CardinalAlign = CardinalAlign::LEFT;
|
||||||
|
pub const H_CENTER: CardinalAlign = CardinalAlign::H_CENTER;
|
||||||
|
pub const RIGHT: CardinalAlign = CardinalAlign::RIGHT;
|
||||||
|
pub const TOP: CardinalAlign = CardinalAlign::TOP;
|
||||||
|
pub const V_CENTER: CardinalAlign = CardinalAlign::V_CENTER;
|
||||||
|
pub const BOT: CardinalAlign = CardinalAlign::BOT;
|
||||||
|
|
||||||
|
pub fn tuple(&self) -> (Option<AxisAlign>, Option<AxisAlign>) {
|
||||||
|
(self.x, self.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AxisAlign {
|
||||||
|
Neg,
|
||||||
|
Center,
|
||||||
|
Pos,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AxisAlign {
|
||||||
|
pub const fn rel(&self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Neg => 0.0,
|
||||||
|
Self::Center => 0.5,
|
||||||
|
Self::Pos => 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CardinalAlign {
|
||||||
|
axis: Axis,
|
||||||
|
align: AxisAlign,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardinalAlign {
|
||||||
|
pub const LEFT: Self = Self::new(Axis::X, AxisAlign::Neg);
|
||||||
|
pub const H_CENTER: Self = Self::new(Axis::X, AxisAlign::Center);
|
||||||
|
pub const RIGHT: Self = Self::new(Axis::X, AxisAlign::Pos);
|
||||||
|
pub const TOP: Self = Self::new(Axis::Y, AxisAlign::Neg);
|
||||||
|
pub const V_CENTER: Self = Self::new(Axis::Y, AxisAlign::Center);
|
||||||
|
pub const BOT: Self = Self::new(Axis::Y, AxisAlign::Pos);
|
||||||
|
|
||||||
|
pub const fn new(axis: Axis, align: AxisAlign) -> Self {
|
||||||
|
Self { axis, align }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct RegionAlign {
|
||||||
|
pub x: AxisAlign,
|
||||||
|
pub y: AxisAlign,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegionAlign {
|
||||||
|
pub const TOP_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Neg);
|
||||||
|
pub const TOP_CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Neg);
|
||||||
|
pub const TOP_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Neg);
|
||||||
|
pub const CENTER_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Center);
|
||||||
|
pub const CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Center);
|
||||||
|
pub const CENTER_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Center);
|
||||||
|
pub const BOT_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Pos);
|
||||||
|
pub const BOT_CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Pos);
|
||||||
|
pub const BOT_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Pos);
|
||||||
|
|
||||||
|
pub const fn new(x: AxisAlign, y: AxisAlign) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
pub const fn rel(&self) -> Vec2 {
|
||||||
|
vec2(self.x.rel(), self.y.rel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiVec2 {
|
||||||
|
pub fn partial_align(&self, align: Align) -> UiRegion {
|
||||||
|
UiRegion {
|
||||||
|
x: if let Some(align) = align.x {
|
||||||
|
self.x.align(align)
|
||||||
|
} else {
|
||||||
|
UiSpan::FULL
|
||||||
|
},
|
||||||
|
y: if let Some(align) = align.y {
|
||||||
|
self.y.align(align)
|
||||||
|
} else {
|
||||||
|
UiSpan::FULL
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn align(&self, align: RegionAlign) -> UiRegion {
|
||||||
|
UiRegion {
|
||||||
|
x: self.x.align(align.x),
|
||||||
|
y: self.y.align(align.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vec2 {
|
||||||
|
pub fn partial_align(&self, align: Align) -> UiRegion {
|
||||||
|
let s = UiVec2::from(*self);
|
||||||
|
UiRegion {
|
||||||
|
x: if let Some(align) = align.x {
|
||||||
|
s.x.align(align)
|
||||||
|
} else {
|
||||||
|
UiSpan::FULL
|
||||||
|
},
|
||||||
|
y: if let Some(align) = align.y {
|
||||||
|
s.y.align(align)
|
||||||
|
} else {
|
||||||
|
UiSpan::FULL
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn align(&self, align: RegionAlign) -> UiRegion {
|
||||||
|
let s = UiVec2::from(*self);
|
||||||
|
UiRegion {
|
||||||
|
x: s.x.align(align.x),
|
||||||
|
y: s.y.align(align.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiScalar {
|
||||||
|
pub const fn align(&self, align: AxisAlign) -> UiSpan {
|
||||||
|
let rel = align.rel();
|
||||||
|
let mut start = UiScalar::rel(rel);
|
||||||
|
start.abs -= self.abs * rel;
|
||||||
|
start.rel -= self.rel * rel;
|
||||||
|
let mut end = UiScalar::rel(rel);
|
||||||
|
end.abs += self.abs * (1.0 - rel);
|
||||||
|
end.rel += self.rel * (1.0 - rel);
|
||||||
|
UiSpan { start, end }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RegionAlign> for Align {
|
||||||
|
fn from(region: RegionAlign) -> Self {
|
||||||
|
Self {
|
||||||
|
x: Some(region.x),
|
||||||
|
y: Some(region.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Align> for RegionAlign {
|
||||||
|
fn from(align: Align) -> Self {
|
||||||
|
Self {
|
||||||
|
x: align.x.unwrap_or(AxisAlign::Center),
|
||||||
|
y: align.y.unwrap_or(AxisAlign::Center),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CardinalAlign> for RegionAlign {
|
||||||
|
fn from(align: CardinalAlign) -> Self {
|
||||||
|
Align::from(align).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CardinalAlign> for Align {
|
||||||
|
fn from(cardinal: CardinalAlign) -> Self {
|
||||||
|
let align = Some(cardinal.align);
|
||||||
|
match cardinal.axis {
|
||||||
|
Axis::X => Self { x: align, y: None },
|
||||||
|
Axis::Y => Self { x: None, y: align },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl From<RegionAlign> for UiVec2 {
|
||||||
|
fn from(align: RegionAlign) -> Self {
|
||||||
|
Self::rel(align.rel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegionAlign {
|
||||||
|
pub const fn pos(self) -> UiVec2 {
|
||||||
|
UiVec2::from(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub enum Axis {
|
||||||
|
X,
|
||||||
|
Y,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Not for Axis {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn not(self) -> Self::Output {
|
||||||
|
match self {
|
||||||
|
Self::X => Self::Y,
|
||||||
|
Self::Y => Self::X,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub struct Dir {
|
||||||
|
pub axis: Axis,
|
||||||
|
pub sign: Sign,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dir {
|
||||||
|
pub const fn new(axis: Axis, dir: Sign) -> Self {
|
||||||
|
Self { axis, sign: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LEFT: Self = Self::new(Axis::X, Sign::Neg);
|
||||||
|
pub const RIGHT: Self = Self::new(Axis::X, Sign::Pos);
|
||||||
|
pub const UP: Self = Self::new(Axis::Y, Sign::Neg);
|
||||||
|
pub const DOWN: Self = Self::new(Axis::Y, Sign::Pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum Sign {
|
||||||
|
Neg,
|
||||||
|
Pos,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vec2 {
|
||||||
|
pub fn axis(&self, axis: Axis) -> f32 {
|
||||||
|
match axis {
|
||||||
|
Axis::X => self.x,
|
||||||
|
Axis::Y => self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn axis_mut(&mut self, axis: Axis) -> &mut f32 {
|
||||||
|
match axis {
|
||||||
|
Axis::X => &mut self.x,
|
||||||
|
Axis::Y => &mut self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn from_axis(axis: Axis, aligned: f32, ortho: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
x: match axis {
|
||||||
|
Axis::X => aligned,
|
||||||
|
Axis::Y => ortho,
|
||||||
|
},
|
||||||
|
y: match axis {
|
||||||
|
Axis::Y => aligned,
|
||||||
|
Axis::X => ortho,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const trait AxisT {
|
||||||
|
fn get() -> Axis;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct XAxis;
|
||||||
|
const impl AxisT for XAxis {
|
||||||
|
fn get() -> Axis {
|
||||||
|
Axis::X
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct YAxis;
|
||||||
|
const impl AxisT for YAxis {
|
||||||
|
fn get() -> Axis {
|
||||||
|
Axis::Y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct BothAxis<T> {
|
||||||
|
pub x: T,
|
||||||
|
pub y: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> BothAxis<T> {
|
||||||
|
pub const fn axis<A: const AxisT>(&mut self) -> &mut T {
|
||||||
|
match A::get() {
|
||||||
|
Axis::X => &mut self.x,
|
||||||
|
Axis::Y => &mut self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn take_axis<A: const AxisT>(self) -> T {
|
||||||
|
match A::get() {
|
||||||
|
Axis::X => self.x,
|
||||||
|
Axis::Y => self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn axis_dyn(&mut self, axis: Axis) -> &mut T {
|
||||||
|
match axis {
|
||||||
|
Axis::X => &mut self.x,
|
||||||
|
Axis::Y => &mut self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::{UiNum, util::impl_op};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Size {
|
||||||
|
pub x: Len,
|
||||||
|
pub y: Len,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Len {
|
||||||
|
pub abs: f32,
|
||||||
|
pub rel: f32,
|
||||||
|
pub rest: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<N: UiNum> From<N> for Len {
|
||||||
|
fn from(value: N) -> Self {
|
||||||
|
Len::abs(value.to_f32())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Nx: UiNum, Ny: UiNum> From<(Nx, Ny)> for Size {
|
||||||
|
fn from((x, y): (Nx, Ny)) -> Self {
|
||||||
|
Self {
|
||||||
|
x: x.into(),
|
||||||
|
y: y.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Len> for Size {
|
||||||
|
fn from(value: Len) -> Self {
|
||||||
|
Self { x: value, y: value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Size {
|
||||||
|
pub const ZERO: Self = Self {
|
||||||
|
x: Len::ZERO,
|
||||||
|
y: Len::ZERO,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const REST: Self = Self {
|
||||||
|
x: Len::REST,
|
||||||
|
y: Len::REST,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn abs(v: Vec2) -> Self {
|
||||||
|
Self {
|
||||||
|
x: Len::abs(v.x),
|
||||||
|
y: Len::abs(v.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rel(v: Vec2) -> Self {
|
||||||
|
Self {
|
||||||
|
x: Len::rel(v.x),
|
||||||
|
y: Len::rel(v.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rest(v: Vec2) -> Self {
|
||||||
|
Self {
|
||||||
|
x: Len::rest(v.x),
|
||||||
|
y: Len::rest(v.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_uivec2(self) -> UiVec2 {
|
||||||
|
UiVec2 {
|
||||||
|
x: self.x.apply_rest(),
|
||||||
|
y: self.y.apply_rest(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_axis(axis: Axis, aligned: Len, ortho: Len) -> Self {
|
||||||
|
match axis {
|
||||||
|
Axis::X => Self {
|
||||||
|
x: aligned,
|
||||||
|
y: ortho,
|
||||||
|
},
|
||||||
|
Axis::Y => Self {
|
||||||
|
x: ortho,
|
||||||
|
y: aligned,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn axis(&self, axis: Axis) -> Len {
|
||||||
|
match axis {
|
||||||
|
Axis::X => self.x,
|
||||||
|
Axis::Y => self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Len {
|
||||||
|
pub const ZERO: Self = Self {
|
||||||
|
abs: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const REST: Self = Self {
|
||||||
|
abs: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn apply_rest(&self) -> UiScalar {
|
||||||
|
UiScalar {
|
||||||
|
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||||
|
abs: self.abs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abs(abs: impl UiNum) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: abs.to_f32(),
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn rel(rel: impl UiNum) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: 0.0,
|
||||||
|
rel: rel.to_f32(),
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn rest(ratio: impl UiNum) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: ratio.to_f32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod len_fns {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn abs(abs: impl UiNum) -> Len {
|
||||||
|
Len {
|
||||||
|
abs: abs.to_f32(),
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn rel(rel: impl UiNum) -> Len {
|
||||||
|
Len {
|
||||||
|
abs: 0.0,
|
||||||
|
rel: rel.to_f32(),
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn rest(ratio: impl UiNum) -> Len {
|
||||||
|
Len {
|
||||||
|
abs: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: ratio.to_f32(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_op!(Len Add add; abs rel rest);
|
||||||
|
impl_op!(Len Sub sub; abs rel rest);
|
||||||
|
|
||||||
|
impl_op!(Size Add add; x y);
|
||||||
|
impl_op!(Size Sub sub; x y);
|
||||||
|
|
||||||
|
impl Default for Len {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::rest(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Size {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "({}, {})", self.x, self.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Len {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if self.abs != 0.0 {
|
||||||
|
write!(f, "{} abs;", self.abs)?;
|
||||||
|
}
|
||||||
|
if self.rel != 0.0 {
|
||||||
|
write!(f, "{} rel;", self.rel)?;
|
||||||
|
}
|
||||||
|
if self.rest != 0.0 {
|
||||||
|
write!(f, "{} rest;", self.rest)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
mod align;
|
||||||
|
mod axis;
|
||||||
|
mod len;
|
||||||
|
mod pos;
|
||||||
|
|
||||||
|
use crate::util::Vec2;
|
||||||
|
|
||||||
|
pub use align::*;
|
||||||
|
pub use axis::*;
|
||||||
|
pub use len::*;
|
||||||
|
pub use pos::*;
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
use std::{fmt::Display, hash::Hash, marker::Destruct};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
UiNum,
|
||||||
|
util::{LerpUtil, impl_op},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable, Default)]
|
||||||
|
pub struct UiVec2 {
|
||||||
|
pub x: UiScalar,
|
||||||
|
pub y: UiScalar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiVec2 {
|
||||||
|
pub const ZERO: Self = Self {
|
||||||
|
x: UiScalar::ZERO,
|
||||||
|
y: UiScalar::ZERO,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const fn new(x: UiScalar, y: UiScalar) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn abs(abs: impl const Into<Vec2>) -> Self {
|
||||||
|
let abs = abs.into();
|
||||||
|
Self {
|
||||||
|
x: UiScalar::abs(abs.x),
|
||||||
|
y: UiScalar::abs(abs.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn rel(rel: impl const Into<Vec2>) -> Self {
|
||||||
|
let rel = rel.into();
|
||||||
|
Self {
|
||||||
|
x: UiScalar::rel(rel.x),
|
||||||
|
y: UiScalar::rel(rel.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn shift(&mut self, offset: impl const Into<UiVec2>) {
|
||||||
|
let offset = offset.into();
|
||||||
|
*self += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn offset(mut self, offset: impl const Into<UiVec2>) -> Self {
|
||||||
|
self.shift(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn within(&self, region: &UiRegion) -> UiVec2 {
|
||||||
|
UiVec2 {
|
||||||
|
x: self.x.within(®ion.x),
|
||||||
|
y: self.y.within(®ion.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn outside(&self, region: &UiRegion) -> UiVec2 {
|
||||||
|
UiVec2 {
|
||||||
|
x: self.x.outside(®ion.x),
|
||||||
|
y: self.y.outside(®ion.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn axis_mut(&mut self, axis: Axis) -> &mut UiScalar {
|
||||||
|
match axis {
|
||||||
|
Axis::X => &mut self.x,
|
||||||
|
Axis::Y => &mut self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn axis(&self, axis: Axis) -> UiScalar {
|
||||||
|
match axis {
|
||||||
|
Axis::X => self.x,
|
||||||
|
Axis::Y => self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_abs(&self, rel: Vec2) -> Vec2 {
|
||||||
|
Vec2 {
|
||||||
|
x: self.x.to_abs(rel.x),
|
||||||
|
y: self.y.to_abs(rel.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const FULL_SIZE: Self = Self::rel(Vec2::ONE);
|
||||||
|
|
||||||
|
pub const fn from_axis(axis: Axis, aligned: UiScalar, ortho: UiScalar) -> Self {
|
||||||
|
match axis {
|
||||||
|
Axis::X => Self {
|
||||||
|
x: aligned,
|
||||||
|
y: ortho,
|
||||||
|
},
|
||||||
|
Axis::Y => Self {
|
||||||
|
x: ortho,
|
||||||
|
y: aligned,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_abs(&self) -> Vec2 {
|
||||||
|
(self.x.abs, self.y.abs).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_rel(&self) -> Vec2 {
|
||||||
|
(self.x.rel, self.y.rel).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abs_mut(&mut self) -> Vec2View<'_> {
|
||||||
|
Vec2View {
|
||||||
|
x: &mut self.x.abs,
|
||||||
|
y: &mut self.y.abs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for UiVec2 {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "rel{};abs{}", self.get_rel(), self.get_abs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_op!(UiVec2 Add add; x y);
|
||||||
|
impl_op!(UiVec2 Sub sub; x y);
|
||||||
|
|
||||||
|
const impl From<Vec2> for UiVec2 {
|
||||||
|
fn from(abs: Vec2) -> Self {
|
||||||
|
Self::abs(abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2
|
||||||
|
where
|
||||||
|
(T, U): const Destruct,
|
||||||
|
{
|
||||||
|
fn from(abs: (T, U)) -> Self {
|
||||||
|
Self::abs(abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, Default, bytemuck::Zeroable)]
|
||||||
|
pub struct UiScalar {
|
||||||
|
pub rel: f32,
|
||||||
|
pub abs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for UiScalar {}
|
||||||
|
impl Hash for UiScalar {
|
||||||
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||||
|
state.write_u32(self.rel.to_bits());
|
||||||
|
state.write_u32(self.abs.to_bits());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_op!(UiScalar Add add; rel abs);
|
||||||
|
impl_op!(UiScalar Sub sub; rel abs);
|
||||||
|
|
||||||
|
impl UiScalar {
|
||||||
|
pub const ZERO: Self = Self { rel: 0.0, abs: 0.0 };
|
||||||
|
pub const FULL: Self = Self { rel: 1.0, abs: 0.0 };
|
||||||
|
|
||||||
|
pub const fn new(rel: f32, abs: f32) -> Self {
|
||||||
|
Self { rel, abs }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn rel(rel: f32) -> Self {
|
||||||
|
Self { rel, abs: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn abs(abs: f32) -> Self {
|
||||||
|
Self { rel: 0.0, abs }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn rel_min() -> Self {
|
||||||
|
Self::new(0.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn rel_max() -> Self {
|
||||||
|
Self::new(1.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn max(&self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
rel: self.rel.max(other.rel),
|
||||||
|
abs: self.abs.max(other.abs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn min(&self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
rel: self.rel.min(other.rel),
|
||||||
|
abs: self.abs.min(other.abs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn offset(mut self, amt: f32) -> Self {
|
||||||
|
self.abs += amt;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn within(&self, span: &UiSpan) -> Self {
|
||||||
|
let anchor = self.rel.lerp(span.start.rel, span.end.rel);
|
||||||
|
let offset = self.abs + self.rel.lerp(span.start.abs, span.end.abs);
|
||||||
|
Self {
|
||||||
|
rel: anchor,
|
||||||
|
abs: offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn outside(&self, span: &UiSpan) -> Self {
|
||||||
|
let rel = self.rel.lerp_inv(span.start.rel, span.end.rel);
|
||||||
|
let abs = self.abs - rel.lerp(span.start.abs, span.end.abs);
|
||||||
|
Self { rel, abs }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn within_len(&self, len: UiScalar) -> Self {
|
||||||
|
self.within(&UiSpan {
|
||||||
|
start: UiScalar::ZERO,
|
||||||
|
end: len,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_len(&self, len: UiScalar) -> Self {
|
||||||
|
len.within_len(*self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn flip(&mut self) {
|
||||||
|
self.rel = 1.0 - self.rel;
|
||||||
|
self.abs = -self.abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to(&self, end: Self) -> UiSpan {
|
||||||
|
UiSpan { start: *self, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to_abs(&self, rel: f32) -> f32 {
|
||||||
|
self.rel * rel + self.abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct UiSpan {
|
||||||
|
pub start: UiScalar,
|
||||||
|
pub end: UiScalar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiSpan {
|
||||||
|
pub const FULL: Self = Self {
|
||||||
|
start: UiScalar::ZERO,
|
||||||
|
end: UiScalar::FULL,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const fn rel(rel: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
start: UiScalar::rel(rel),
|
||||||
|
end: UiScalar::rel(rel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn new(start: UiScalar, end: UiScalar) -> Self {
|
||||||
|
Self { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn flip(&mut self) {
|
||||||
|
self.start.flip();
|
||||||
|
self.end.flip();
|
||||||
|
std::mem::swap(&mut self.start.rel, &mut self.end.rel);
|
||||||
|
std::mem::swap(&mut self.start.abs, &mut self.end.abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn shift(&mut self, offset: UiScalar) {
|
||||||
|
self.start += offset;
|
||||||
|
self.end += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn within(&self, parent: &Self) -> Self {
|
||||||
|
Self {
|
||||||
|
start: self.start.within(parent),
|
||||||
|
end: self.end.within(parent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn outside(&self, parent: &Self) -> Self {
|
||||||
|
Self {
|
||||||
|
start: self.start.outside(parent),
|
||||||
|
end: self.end.outside(parent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn len(&self) -> UiScalar {
|
||||||
|
self.end - self.start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct UiRegion {
|
||||||
|
pub x: UiSpan,
|
||||||
|
pub y: UiSpan,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRegion {
|
||||||
|
pub const FULL: Self = Self {
|
||||||
|
x: UiSpan::FULL,
|
||||||
|
y: UiSpan::FULL,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const fn new(x: UiSpan, y: UiSpan) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn rel(rel: Vec2) -> Self {
|
||||||
|
Self {
|
||||||
|
x: UiSpan::rel(rel.x),
|
||||||
|
y: UiSpan::rel(rel.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn within(&self, parent: &Self) -> Self {
|
||||||
|
Self {
|
||||||
|
x: self.x.within(&parent.x),
|
||||||
|
y: self.y.within(&parent.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn outside(&self, parent: &Self) -> Self {
|
||||||
|
Self {
|
||||||
|
x: self.x.outside(&parent.x),
|
||||||
|
y: self.y.outside(&parent.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn axis(&mut self, axis: Axis) -> &UiSpan {
|
||||||
|
match axis {
|
||||||
|
Axis::X => &self.x,
|
||||||
|
Axis::Y => &self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn axis_mut(&mut self, axis: Axis) -> &mut UiSpan {
|
||||||
|
match axis {
|
||||||
|
Axis::X => &mut self.x,
|
||||||
|
Axis::Y => &mut self.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn flip(&mut self, axis: Axis) {
|
||||||
|
match axis {
|
||||||
|
Axis::X => self.x.flip(),
|
||||||
|
Axis::Y => self.y.flip(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shift(&mut self, offset: impl Into<UiVec2>) {
|
||||||
|
let offset = offset.into();
|
||||||
|
self.x.shift(offset.x);
|
||||||
|
self.y.shift(offset.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset(mut self, offset: impl Into<UiVec2>) -> Self {
|
||||||
|
self.shift(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_px(&self, size: Vec2) -> PixelRegion {
|
||||||
|
PixelRegion {
|
||||||
|
top_left: self.top_left().get_rel() * size + self.top_left().get_abs(),
|
||||||
|
bot_right: self.bot_right().get_rel() * size + self.bot_right().get_abs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn center(&self) -> UiVec2 {
|
||||||
|
Align::CENTER.pos().within(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn size(&self) -> UiVec2 {
|
||||||
|
UiVec2 {
|
||||||
|
x: self.x.len(),
|
||||||
|
y: self.y.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn top_left(&self) -> UiVec2 {
|
||||||
|
UiVec2 {
|
||||||
|
x: self.x.start,
|
||||||
|
y: self.y.start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn bot_right(&self) -> UiVec2 {
|
||||||
|
UiVec2 {
|
||||||
|
x: self.x.end,
|
||||||
|
y: self.y.end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn from_axis(axis: Axis, aligned: UiSpan, ortho: UiSpan) -> Self {
|
||||||
|
Self {
|
||||||
|
x: match axis {
|
||||||
|
Axis::X => aligned,
|
||||||
|
Axis::Y => ortho,
|
||||||
|
},
|
||||||
|
y: match axis {
|
||||||
|
Axis::X => ortho,
|
||||||
|
Axis::Y => aligned,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for UiRegion {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} -> {} (size: {})",
|
||||||
|
self.top_left(),
|
||||||
|
self.bot_right(),
|
||||||
|
self.size()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PixelRegion {
|
||||||
|
pub top_left: Vec2,
|
||||||
|
pub bot_right: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PixelRegion {
|
||||||
|
pub fn contains(&self, pos: Vec2) -> bool {
|
||||||
|
pos.x >= self.top_left.x
|
||||||
|
&& pos.x <= self.bot_right.x
|
||||||
|
&& pos.y >= self.top_left.y
|
||||||
|
&& pos.y <= self.bot_right.y
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
self.bot_right - self.top_left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for PixelRegion {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} -> {}", self.top_left, self.bot_right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Vec2View<'a> {
|
||||||
|
pub x: &'a mut f32,
|
||||||
|
pub y: &'a mut f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vec2View<'_> {
|
||||||
|
pub fn set(&mut self, other: Vec2) {
|
||||||
|
*self.x = other.x;
|
||||||
|
*self.y = other.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, other: Vec2) {
|
||||||
|
*self.x += other.x;
|
||||||
|
*self.y += other.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
use std::marker::Destruct;
|
||||||
|
|
||||||
|
/// stored in linear for sane manipulation
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
|
||||||
|
pub struct Color<T> {
|
||||||
|
pub r: T,
|
||||||
|
pub g: T,
|
||||||
|
pub b: T,
|
||||||
|
pub a: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Required by parley's `Brush`, which every text style is generic over. Opaque
|
||||||
|
/// black rather than transparent: a brush that was never set should be visible
|
||||||
|
/// and obviously unstyled, not invisible.
|
||||||
|
impl<T: ColorNum> Default for Color<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::BLACK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ColorNum> Color<T> {
|
||||||
|
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
|
||||||
|
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
|
||||||
|
pub const GRAY: Self = Self::rgb(T::MID, T::MID, T::MID);
|
||||||
|
|
||||||
|
pub const RED: Self = Self::rgb(T::MAX, T::MIN, T::MIN);
|
||||||
|
pub const ORANGE: Self = Self::rgb(T::MAX, T::MID, T::MIN);
|
||||||
|
pub const YELLOW: Self = Self::rgb(T::MAX, T::MAX, T::MIN);
|
||||||
|
pub const LIME: Self = Self::rgb(T::MID, T::MAX, T::MIN);
|
||||||
|
pub const GREEN: Self = Self::rgb(T::MIN, T::MAX, T::MIN);
|
||||||
|
pub const TURQUOISE: Self = Self::rgb(T::MIN, T::MAX, T::MID);
|
||||||
|
pub const CYAN: Self = Self::rgb(T::MIN, T::MAX, T::MAX);
|
||||||
|
pub const SKY: Self = Self::rgb(T::MIN, T::MID, T::MAX);
|
||||||
|
pub const BLUE: Self = Self::rgb(T::MIN, T::MIN, T::MAX);
|
||||||
|
pub const PURPLE: Self = Self::rgb(T::MID, T::MIN, T::MAX);
|
||||||
|
pub const MAGENTA: Self = Self::rgb(T::MAX, T::MIN, T::MAX);
|
||||||
|
|
||||||
|
pub const NONE: Self = Self::new(T::MIN, T::MIN, T::MIN, T::MIN);
|
||||||
|
|
||||||
|
pub const fn new(r: T, g: T, b: T, a: T) -> Self {
|
||||||
|
Self { r, g, b, a }
|
||||||
|
}
|
||||||
|
pub const fn rgb(r: T, g: T, b: T) -> Self {
|
||||||
|
Self { r, g, b, a: T::MAX }
|
||||||
|
}
|
||||||
|
pub fn alpha(mut self, a: T) -> Self {
|
||||||
|
self.a = a;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_arr(self) -> [T; 4] {
|
||||||
|
[self.r, self.g, self.b, self.a]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const trait F32Conversion {
|
||||||
|
fn to(self) -> f32;
|
||||||
|
fn from(x: f32) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ColorNum {
|
||||||
|
const MIN: Self;
|
||||||
|
const MID: Self;
|
||||||
|
const MAX: Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! map_rgb {
|
||||||
|
($x:ident,$self:ident, $e:tt) => {
|
||||||
|
#[allow(unused_braces)]
|
||||||
|
Self {
|
||||||
|
r: {
|
||||||
|
let $x = $self.r;
|
||||||
|
$e
|
||||||
|
},
|
||||||
|
g: {
|
||||||
|
let $x = $self.g;
|
||||||
|
$e
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
let $x = $self.b;
|
||||||
|
$e
|
||||||
|
},
|
||||||
|
a: $self.a,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: ColorNum + const F32Conversion> Color<T>
|
||||||
|
where
|
||||||
|
Self: const Destruct,
|
||||||
|
{
|
||||||
|
pub const fn mul_rgb(self, amt: impl const F32Conversion) -> Self {
|
||||||
|
let amt = amt.to();
|
||||||
|
map_rgb!(x, self, { T::from(x.to() * amt) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn add_rgb(self, amt: impl const F32Conversion) -> Self {
|
||||||
|
let amt = amt.to();
|
||||||
|
map_rgb!(x, self, { T::from(x.to() + amt) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn darker(self, amt: f32) -> Self {
|
||||||
|
self.mul_rgb(1.0 - amt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn brighter(self, amt: f32) -> Self {
|
||||||
|
map_rgb!(x, self, {
|
||||||
|
let x = x.to();
|
||||||
|
T::from(x + (1.0 - x) * amt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_rgb(self, f: impl Fn(T) -> T) -> Self {
|
||||||
|
Self {
|
||||||
|
r: f(self.r),
|
||||||
|
g: f(self.g),
|
||||||
|
b: f(self.b),
|
||||||
|
a: self.a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn srgb(r: T, g: T, b: T) -> Self {
|
||||||
|
Self {
|
||||||
|
r: s_to_l(r),
|
||||||
|
g: s_to_l(g),
|
||||||
|
b: s_to_l(b),
|
||||||
|
a: T::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s_to_l<T: F32Conversion>(x: T) -> T {
|
||||||
|
let x = x.to();
|
||||||
|
T::from(if x <= 0.0405 {
|
||||||
|
x / 12.92
|
||||||
|
} else {
|
||||||
|
((x + 0.055) / 1.055).powf(2.4)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorNum for u8 {
|
||||||
|
const MIN: Self = u8::MIN;
|
||||||
|
const MID: Self = u8::MAX / 2;
|
||||||
|
const MAX: Self = u8::MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorNum for f32 {
|
||||||
|
const MIN: Self = 0.0;
|
||||||
|
const MID: Self = 0.5;
|
||||||
|
const MAX: Self = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl bytemuck::Pod for Color<u8> {}
|
||||||
|
|
||||||
|
const impl F32Conversion for f32 {
|
||||||
|
fn to(self) -> f32 {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn from(x: f32) -> Self {
|
||||||
|
x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const impl F32Conversion for u8 {
|
||||||
|
fn to(self) -> f32 {
|
||||||
|
self as f32 / 255.0
|
||||||
|
}
|
||||||
|
fn from(x: f32) -> Self {
|
||||||
|
(x * 255.0).clamp(0.0, 255.0) as Self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
use std::ops::{Index, IndexMut};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
UiRegion, WidgetId,
|
||||||
|
render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
|
||||||
|
util::to_mut,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type LayerId = usize;
|
||||||
|
|
||||||
|
struct LayerNode<T> {
|
||||||
|
next: Ptr,
|
||||||
|
prev: Ptr,
|
||||||
|
child: Option<Child>,
|
||||||
|
depth: usize,
|
||||||
|
data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum Ptr {
|
||||||
|
/// continue on same level
|
||||||
|
Next(usize),
|
||||||
|
/// go back to parent
|
||||||
|
Parent(usize),
|
||||||
|
/// end
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TODO: currently this does not ever free layers
|
||||||
|
/// is that realistically desired?
|
||||||
|
pub struct Layers<T> {
|
||||||
|
vec: Vec<LayerNode<T>>,
|
||||||
|
/// index of last layer at top level (start at first = 0)
|
||||||
|
last: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Child {
|
||||||
|
head: usize,
|
||||||
|
tail: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type PrimitiveLayers = Layers<Primitives>;
|
||||||
|
|
||||||
|
impl<T: Default> Layers<T> {
|
||||||
|
pub fn new() -> Layers<T> {
|
||||||
|
Self {
|
||||||
|
vec: vec![LayerNode::head()],
|
||||||
|
last: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.vec.clear();
|
||||||
|
self.vec.push(LayerNode::head());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, node: LayerNode<T>) -> LayerId {
|
||||||
|
let i = self.vec.len();
|
||||||
|
self.vec.push(node);
|
||||||
|
i
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self, i: LayerId) -> LayerId {
|
||||||
|
if let Ptr::Next(i) = self.vec[i].next {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
let i_new = self.push(LayerNode::new(
|
||||||
|
T::default(),
|
||||||
|
self.vec[i].next,
|
||||||
|
Ptr::Next(i),
|
||||||
|
self.vec[i].depth,
|
||||||
|
));
|
||||||
|
self.vec[i].next = Ptr::Next(i_new);
|
||||||
|
self.vec[i_new].prev = Ptr::Next(i);
|
||||||
|
match self.vec[i_new].next {
|
||||||
|
Ptr::Next(i) => self.vec[i].prev = Ptr::Next(i_new),
|
||||||
|
Ptr::Parent(i) => self.vec[i].child.as_mut().unwrap().tail = i_new,
|
||||||
|
Ptr::None => self.last = i_new,
|
||||||
|
}
|
||||||
|
i_new
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child(&mut self, i: LayerId) -> LayerId {
|
||||||
|
if let Some(c) = self.vec[i].child {
|
||||||
|
return c.head;
|
||||||
|
}
|
||||||
|
let i_child = self.push(LayerNode::new(
|
||||||
|
T::default(),
|
||||||
|
Ptr::Parent(i),
|
||||||
|
Ptr::Parent(i),
|
||||||
|
self.vec[i].depth + 1,
|
||||||
|
));
|
||||||
|
self.vec[i].child = Some(Child {
|
||||||
|
head: i_child,
|
||||||
|
tail: i_child,
|
||||||
|
});
|
||||||
|
i_child
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_mut(&mut self) -> LayerIteratorMut<'_, T> {
|
||||||
|
LayerIteratorMut::new(&mut self.vec, self.last)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_orderless_mut(&mut self) -> impl Iterator<Item = (usize, &mut T)> {
|
||||||
|
self.vec.iter_mut().map(|n| &mut n.data).enumerate()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (LayerId, &T)> {
|
||||||
|
self.indices().map(|i| (i, &self.vec[i].data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_depth(&self) -> impl Iterator<Item = ((LayerId, usize), &T)> {
|
||||||
|
self.indices()
|
||||||
|
.map(|i| ((i, self.vec[i].depth), &self.vec[i].data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn indices(&self) -> LayerIndexIterator<'_, T> {
|
||||||
|
LayerIndexIterator::new(&self.vec, self.last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Index<LayerId> for Layers<T> {
|
||||||
|
type Output = T;
|
||||||
|
|
||||||
|
fn index(&self, index: LayerId) -> &Self::Output {
|
||||||
|
&self.vec[index].data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> IndexMut<LayerId> for Layers<T> {
|
||||||
|
fn index_mut(&mut self, index: LayerId) -> &mut Self::Output {
|
||||||
|
&mut self.vec[index].data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Default> LayerNode<T> {
|
||||||
|
pub fn new(data: T, next: Ptr, prev: Ptr, depth: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
child: None,
|
||||||
|
data,
|
||||||
|
depth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn head() -> Self {
|
||||||
|
Self::new(T::default(), Ptr::None, Ptr::None, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LayerIteratorMut<'a, T> {
|
||||||
|
inner: LayerIndexIterator<'a, T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> Iterator for LayerIteratorMut<'a, T> {
|
||||||
|
type Item = (usize, &'a mut T);
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let i = self.inner.next()?;
|
||||||
|
// SAFETY: requires index iterator to work properly
|
||||||
|
let layer = unsafe { to_mut(&self.inner.vec[i].data) };
|
||||||
|
Some((i, layer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> DoubleEndedIterator for LayerIteratorMut<'a, T> {
|
||||||
|
fn next_back(&mut self) -> Option<Self::Item> {
|
||||||
|
let i = self.inner.next_back()?;
|
||||||
|
// SAFETY: requires index iterator to work properly
|
||||||
|
let layer = unsafe { to_mut(&self.inner.vec[i].data) };
|
||||||
|
Some((i, layer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> LayerIteratorMut<'a, T> {
|
||||||
|
fn new(vec: &'a mut Vec<LayerNode<T>>, last: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: LayerIndexIterator::new(vec, last),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LayerIndexIterator<'a, T> {
|
||||||
|
next: Option<usize>,
|
||||||
|
next_back: Option<usize>,
|
||||||
|
vec: &'a Vec<LayerNode<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> Iterator for LayerIndexIterator<'a, T> {
|
||||||
|
type Item = usize;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let ret_i = self.next?;
|
||||||
|
let node = &self.vec[ret_i];
|
||||||
|
self.next = if let Some(c) = node.child {
|
||||||
|
Some(c.head)
|
||||||
|
} else if let Ptr::Next(i) = node.next {
|
||||||
|
Some(i)
|
||||||
|
} else if let Ptr::Parent(i) = node.next {
|
||||||
|
let mut node = &self.vec[i];
|
||||||
|
while let Ptr::Parent(i) = node.next {
|
||||||
|
node = &self.vec[i];
|
||||||
|
}
|
||||||
|
if let Ptr::Next(i) = node.next {
|
||||||
|
Some(i)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if self.next_back.unwrap() == ret_i {
|
||||||
|
self.next = None;
|
||||||
|
self.next_back = None;
|
||||||
|
}
|
||||||
|
Some(ret_i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> DoubleEndedIterator for LayerIndexIterator<'a, T> {
|
||||||
|
fn next_back(&mut self) -> Option<Self::Item> {
|
||||||
|
let ret_i = self.next_back?;
|
||||||
|
let node = &self.vec[ret_i];
|
||||||
|
self.next_back = if let Ptr::Next(mut i) = node.prev {
|
||||||
|
while let Some(c) = self.vec[i].child {
|
||||||
|
i = c.tail
|
||||||
|
}
|
||||||
|
Some(i)
|
||||||
|
} else if let Ptr::Parent(i) = node.prev {
|
||||||
|
Some(i)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if self.next.unwrap() == ret_i {
|
||||||
|
self.next = None;
|
||||||
|
self.next_back = None;
|
||||||
|
}
|
||||||
|
Some(ret_i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T> LayerIndexIterator<'a, T> {
|
||||||
|
fn new(vec: &'a Vec<LayerNode<T>>, last: usize) -> Self {
|
||||||
|
let mut last = last;
|
||||||
|
while let Some(c) = vec[last].child {
|
||||||
|
last = c.tail;
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
next: Some(0),
|
||||||
|
next_back: Some(last),
|
||||||
|
vec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mod color;
|
||||||
|
mod layer;
|
||||||
|
mod text;
|
||||||
|
mod texture;
|
||||||
|
|
||||||
|
pub use color::*;
|
||||||
|
pub use layer::*;
|
||||||
|
pub use text::*;
|
||||||
|
pub use texture::*;
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
|
||||||
|
use parley::{
|
||||||
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||||
|
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||||
|
};
|
||||||
|
use std::ops::Range;
|
||||||
|
use swash::{
|
||||||
|
FontRef,
|
||||||
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||||
|
zeno::{Format, Vector},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Everything text needs that outlives one string: the font collection, the
|
||||||
|
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
||||||
|
pub struct TextData {
|
||||||
|
pub font_cx: FontContext,
|
||||||
|
pub layout_cx: LayoutContext<UiColor>,
|
||||||
|
scale_cx: ScaleContext,
|
||||||
|
pub atlas: GlyphAtlas,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
font_cx: FontContext::new(),
|
||||||
|
layout_cx: LayoutContext::new(),
|
||||||
|
scale_cx: ScaleContext::new(),
|
||||||
|
atlas: GlyphAtlas::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which family to ask for. Kept as an owned name rather than parley's
|
||||||
|
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Family {
|
||||||
|
SansSerif,
|
||||||
|
Serif,
|
||||||
|
Monospace,
|
||||||
|
Named(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Family {
|
||||||
|
fn family(&self) -> FontFamily<'_> {
|
||||||
|
let name = match self {
|
||||||
|
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||||
|
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
||||||
|
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
||||||
|
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
||||||
|
};
|
||||||
|
FontFamily::Single(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style
|
||||||
|
/// over `range` (a byte range into the buffer's text). Every field is
|
||||||
|
/// optional so a span only says what it changes -- e.g. a link span sets
|
||||||
|
/// `color` and `underline` and leaves weight/family at the paragraph's own
|
||||||
|
/// default. This is I5's answer to RUST.md's inline-rich-text ceiling
|
||||||
|
/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the
|
||||||
|
/// whole editor, with `// TODO: RichTextInput` beside it): parley's own
|
||||||
|
/// `RangedBuilder::push` already takes a style and a range, so per-span
|
||||||
|
/// bold/italic/monospace/colour/underline only needed plumbing this struct
|
||||||
|
/// through to it and giving each glyph its own colour at draw time (see
|
||||||
|
/// `PlacedGlyph::color` and `TextData::place` below) instead of the one
|
||||||
|
/// `RenderedText::color` every glyph used to share.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct SpanStyle {
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub color: Option<UiColor>,
|
||||||
|
pub family: Option<Family>,
|
||||||
|
/// Overrides `TextAttrs::font_size` for just this range -- what lets a
|
||||||
|
/// heading inside a transcript row's single `TextEdit` be bigger than
|
||||||
|
/// the paragraph text around it, so a whole markdown-folded row (block
|
||||||
|
/// and inline styling both) can stay one selectable text buffer instead
|
||||||
|
/// of one widget per block.
|
||||||
|
pub font_size: Option<f32>,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub underline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpanStyle {
|
||||||
|
pub fn new(range: Range<usize>) -> Self {
|
||||||
|
Self {
|
||||||
|
range,
|
||||||
|
color: None,
|
||||||
|
family: None,
|
||||||
|
font_size: None,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn color(mut self, color: UiColor) -> Self {
|
||||||
|
self.color = Some(color);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn family(mut self, family: Family) -> Self {
|
||||||
|
self.family = Some(family);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn font_size(mut self, size: f32) -> Self {
|
||||||
|
self.font_size = Some(size);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn bold(mut self) -> Self {
|
||||||
|
self.bold = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn italic(mut self) -> Self {
|
||||||
|
self.italic = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn underline(mut self) -> Self {
|
||||||
|
self.underline = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct TextAttrs {
|
||||||
|
pub color: UiColor,
|
||||||
|
pub font_size: f32,
|
||||||
|
pub line_height: f32,
|
||||||
|
pub family: Family,
|
||||||
|
pub wrap: bool,
|
||||||
|
/// inner alignment of text region (within where it's drawn)
|
||||||
|
pub align: RegionAlign,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
||||||
|
|
||||||
|
impl Default for TextAttrs {
|
||||||
|
fn default() -> Self {
|
||||||
|
let size = 16.0;
|
||||||
|
Self {
|
||||||
|
color: UiColor::WHITE,
|
||||||
|
font_size: size,
|
||||||
|
line_height: size * LINE_HEIGHT_MULT,
|
||||||
|
family: Family::SansSerif,
|
||||||
|
wrap: false,
|
||||||
|
align: Align::CENTER_LEFT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string together with its laid-out form.
|
||||||
|
///
|
||||||
|
/// The text and the layout live in one place because parley's `Layout` borrows
|
||||||
|
/// nothing but is only meaningful against the string it was built from: keeping
|
||||||
|
/// them apart is how they get out of step.
|
||||||
|
pub struct TextBuffer {
|
||||||
|
text: String,
|
||||||
|
layout: Layout<UiColor>,
|
||||||
|
spans: Vec<SpanStyle>,
|
||||||
|
/// What the current layout was built for, so `shape` can decline to redo
|
||||||
|
/// work that would come out the same. Spans are not part of this key --
|
||||||
|
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||||
|
/// does, since spans change far less often than a naive equality check
|
||||||
|
/// on the whole `Vec` would cost to compute every frame.
|
||||||
|
shaped: Option<(TextAttrs, Option<f32>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBuffer {
|
||||||
|
pub fn new(text: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
layout: Layout::new(),
|
||||||
|
spans: Vec::new(),
|
||||||
|
shaped: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace this buffer's per-range style overrides (I5's rich text --
|
||||||
|
/// see `SpanStyle`). Invalidates the layout unconditionally, mirroring
|
||||||
|
/// `set_text`.
|
||||||
|
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
|
||||||
|
self.spans = spans;
|
||||||
|
self.shaped = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_empty() -> Self {
|
||||||
|
Self::new("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout(&self) -> &Layout<UiColor> {
|
||||||
|
&self.layout
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.text.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_text(&mut self, text: impl Into<String>) {
|
||||||
|
let text = text.into();
|
||||||
|
if text != self.text {
|
||||||
|
self.text = text;
|
||||||
|
self.shaped = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit the string in place; invalidates the layout unconditionally, since
|
||||||
|
/// the caller is assumed to have changed something.
|
||||||
|
pub fn edit(&mut self) -> &mut String {
|
||||||
|
self.shaped = None;
|
||||||
|
&mut self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
Vec2::new(self.layout.width(), self.layout.height())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay the text out, unless it is already laid out for these attributes and
|
||||||
|
/// this width.
|
||||||
|
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
||||||
|
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut builder = data
|
||||||
|
.layout_cx
|
||||||
|
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||||
|
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
||||||
|
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
||||||
|
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||||
|
attrs.line_height,
|
||||||
|
)));
|
||||||
|
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||||
|
for span in &self.spans {
|
||||||
|
let range = span.range.clone();
|
||||||
|
if let Some(color) = span.color {
|
||||||
|
builder.push(StyleProperty::Brush(color), range.clone());
|
||||||
|
}
|
||||||
|
if let Some(family) = &span.family {
|
||||||
|
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||||
|
}
|
||||||
|
if let Some(size) = span.font_size {
|
||||||
|
builder.push(StyleProperty::FontSize(size), range.clone());
|
||||||
|
}
|
||||||
|
if span.bold {
|
||||||
|
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||||
|
}
|
||||||
|
if span.italic {
|
||||||
|
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
|
||||||
|
}
|
||||||
|
if span.underline {
|
||||||
|
builder.push(StyleProperty::Underline(true), range.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.build_into(&mut self.layout, &self.text);
|
||||||
|
self.layout.break_all_lines(width);
|
||||||
|
self.layout
|
||||||
|
.align(Alignment::Start, AlignmentOptions::default());
|
||||||
|
self.shaped = Some((attrs.clone(), width));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextData {
|
||||||
|
/// Rasterise whatever of `buffer` is not in the atlas yet, and return where
|
||||||
|
/// each glyph goes relative to the text's top-left.
|
||||||
|
///
|
||||||
|
/// Nothing is uploaded for a glyph already in the atlas, which is the point
|
||||||
|
/// of having one: a resize re-runs this and touches the GPU only if the new
|
||||||
|
/// width brought genuinely new glyphs into view.
|
||||||
|
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
|
||||||
|
let mut placed = Vec::new();
|
||||||
|
for line in buffer.layout.lines() {
|
||||||
|
for item in line.items() {
|
||||||
|
let PositionedLayoutItem::GlyphRun(run) = item else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let font = run.run().font();
|
||||||
|
let font_size = run.run().font_size();
|
||||||
|
let coords = run.run().normalized_coords();
|
||||||
|
let run_color = run.style().brush;
|
||||||
|
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let coords_hash = hash_coords(coords);
|
||||||
|
// `font.data.id()` rather than the pointer, so the same font
|
||||||
|
// loaded twice is still one set of entries.
|
||||||
|
let font_id = font.data.id();
|
||||||
|
|
||||||
|
for glyph in run.positioned_glyphs() {
|
||||||
|
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
|
||||||
|
let key = GlyphKey {
|
||||||
|
font: font_id,
|
||||||
|
glyph: glyph.id,
|
||||||
|
size: (font_size * 16.0).round() as u32,
|
||||||
|
subpixel,
|
||||||
|
coords: coords_hash,
|
||||||
|
};
|
||||||
|
let entry = match self.atlas.get(&key) {
|
||||||
|
Some(entry) => entry,
|
||||||
|
None => {
|
||||||
|
let mut scaler = self
|
||||||
|
.scale_cx
|
||||||
|
.builder(font_ref)
|
||||||
|
.size(font_size)
|
||||||
|
.hint(true)
|
||||||
|
.normalized_coords(coords)
|
||||||
|
.build();
|
||||||
|
let image = Render::new(&[
|
||||||
|
Source::ColorOutline(0),
|
||||||
|
Source::ColorBitmap(StrikeWith::BestFit),
|
||||||
|
Source::Outline,
|
||||||
|
])
|
||||||
|
.format(Format::Alpha)
|
||||||
|
.offset(Vector::new(subpixel as f32 / 4.0, 0.0))
|
||||||
|
.render(&mut scaler, glyph.id as u16);
|
||||||
|
match image {
|
||||||
|
Some(image) => self.atlas.insert(key, &image, textures),
|
||||||
|
None => {
|
||||||
|
self.atlas.insert_empty(key);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(entry) = entry else { continue };
|
||||||
|
placed.push(PlacedGlyph {
|
||||||
|
entry,
|
||||||
|
offset: Vec2::new(
|
||||||
|
glyph.x.floor() + entry.left as f32,
|
||||||
|
glyph.y.floor() - entry.top as f32,
|
||||||
|
),
|
||||||
|
color: run_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_coords(coords: &[i16]) -> u64 {
|
||||||
|
// FxHash over the coordinates; they are short and change rarely.
|
||||||
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for c in coords {
|
||||||
|
h ^= *c as u16 as u64;
|
||||||
|
h = h.wrapping_mul(0x1000_0000_01b3);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A laid-out string, ready to draw: where each glyph goes and how big the
|
||||||
|
/// whole thing is.
|
||||||
|
///
|
||||||
|
/// Cheap to clone and to keep, which is the point -- a widget holds one across
|
||||||
|
/// frames and re-emits its quads without going near the rasteriser. `color`
|
||||||
|
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
|
||||||
|
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
|
||||||
|
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
|
||||||
|
/// override per range.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RenderedText {
|
||||||
|
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||||
|
pub size: Vec2,
|
||||||
|
pub color: UiColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextData {
|
||||||
|
/// Lay out and place in one step, which is what a widget wants.
|
||||||
|
pub fn render(
|
||||||
|
&mut self,
|
||||||
|
buffer: &mut TextBuffer,
|
||||||
|
attrs: &TextAttrs,
|
||||||
|
width: Option<f32>,
|
||||||
|
textures: &mut Textures,
|
||||||
|
) -> RenderedText {
|
||||||
|
buffer.shape(self, attrs, width);
|
||||||
|
let glyphs = self.place(buffer, textures);
|
||||||
|
RenderedText {
|
||||||
|
glyphs: std::sync::Arc::new(glyphs),
|
||||||
|
size: buffer.size(),
|
||||||
|
color: attrs.color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use crate::util::{RefCounter, Vec2};
|
||||||
|
use image::{DynamicImage, GenericImageView};
|
||||||
|
use std::{
|
||||||
|
ops::Index,
|
||||||
|
sync::mpsc::{Receiver, Sender, channel},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Which of the two things a texture slot holds. See TEXTURES.md's
|
||||||
|
/// "Recommended shape" for why these are drawn so differently: a page is a
|
||||||
|
/// layer of one shared array texture and never gets its own bind group; a
|
||||||
|
/// standalone image is the opposite, one texture and one bind group, never a
|
||||||
|
/// layer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TextureKind {
|
||||||
|
Image,
|
||||||
|
/// The array-texture layer this page was assigned. Chosen synchronously
|
||||||
|
/// by `Textures::add_page` rather than by the renderer, because glyph
|
||||||
|
/// insertion needs it in the same call, before any GPU sync happens.
|
||||||
|
Page {
|
||||||
|
layer: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TextureHandle {
|
||||||
|
slot: u32,
|
||||||
|
kind: TextureKind,
|
||||||
|
size: Vec2,
|
||||||
|
counter: RefCounter,
|
||||||
|
send: Sender<(TextureKind, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// a texture manager for a ui
|
||||||
|
/// note that this is heavily oriented towards wgpu's renderer so the primitives don't need mapped
|
||||||
|
pub struct Textures {
|
||||||
|
free: Vec<u32>,
|
||||||
|
images: Vec<Option<DynamicImage>>,
|
||||||
|
/// 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.
|
||||||
|
next_page_layer: u32,
|
||||||
|
updates: Vec<Update>,
|
||||||
|
send: Sender<(TextureKind, u32)>,
|
||||||
|
recv: Receiver<(TextureKind, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum TextureUpdate<'a> {
|
||||||
|
Push(TextureKind, &'a DynamicImage),
|
||||||
|
Set(TextureKind, u32, &'a DynamicImage),
|
||||||
|
/// Overwrite a rectangle of an existing texture, rather than replacing it.
|
||||||
|
/// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas
|
||||||
|
/// per glyph is megabytes of copy for a few hundred bytes of change.
|
||||||
|
/// Only ever issued against a page -- a standalone image is never patched.
|
||||||
|
Patch(u32, PatchRect, &'a DynamicImage),
|
||||||
|
Free(u32),
|
||||||
|
PushFree(TextureKind),
|
||||||
|
SetFree,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct PatchRect {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Update {
|
||||||
|
Push(TextureKind, u32),
|
||||||
|
Set(TextureKind, u32),
|
||||||
|
Patch(u32, PatchRect),
|
||||||
|
Free(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Textures {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (send, recv) = channel();
|
||||||
|
Self {
|
||||||
|
free: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
next_page_layer: 0,
|
||||||
|
updates: Vec::new(),
|
||||||
|
send,
|
||||||
|
recv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
|
||||||
|
let image = image.into();
|
||||||
|
let size = image.dimensions().into();
|
||||||
|
let kind = TextureKind::Image;
|
||||||
|
let slot = self.push(kind, image);
|
||||||
|
TextureHandle {
|
||||||
|
slot,
|
||||||
|
kind,
|
||||||
|
size,
|
||||||
|
counter: RefCounter::new(),
|
||||||
|
send: self.send.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a page of the shared glyph atlas array. Only `atlas.rs` should
|
||||||
|
/// call this -- everything else wants `add`.
|
||||||
|
pub fn add_page(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
|
||||||
|
let image = image.into();
|
||||||
|
let size = image.dimensions().into();
|
||||||
|
let layer = self.next_page_layer;
|
||||||
|
self.next_page_layer += 1;
|
||||||
|
let kind = TextureKind::Page { layer };
|
||||||
|
let slot = self.push(kind, image);
|
||||||
|
TextureHandle {
|
||||||
|
slot,
|
||||||
|
kind,
|
||||||
|
size,
|
||||||
|
counter: RefCounter::new(),
|
||||||
|
send: self.send.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
|
||||||
|
if let Some(i) = self.free.pop() {
|
||||||
|
self.images[i as usize] = Some(image);
|
||||||
|
self.updates.push(Update::Set(kind, i));
|
||||||
|
i
|
||||||
|
} else {
|
||||||
|
let i = self.images.len() as u32;
|
||||||
|
self.images.push(Some(image));
|
||||||
|
self.updates.push(Update::Push(kind, i));
|
||||||
|
i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored image for a handle, to be written into before `patch`.
|
||||||
|
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
||||||
|
self.images[handle.slot as usize]
|
||||||
|
.as_mut()
|
||||||
|
.expect("texture was freed while still held")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an upload of just `rect`, after writing it with `image_mut`.
|
||||||
|
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
|
||||||
|
self.updates.push(Update::Patch(handle.slot, rect));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn free(&mut self) {
|
||||||
|
for (kind, idx) in self.recv.try_iter() {
|
||||||
|
self.images[idx as usize] = None;
|
||||||
|
self.updates.push(Update::Free(idx));
|
||||||
|
// A page's slot is never reclaimed: `GlyphAtlas` never drops the
|
||||||
|
// handles it holds, and there is no eviction path for a hole in
|
||||||
|
// the middle of the array's layers. If that ever changes, this
|
||||||
|
// is where a freed page's layer would need to go on a free list
|
||||||
|
// of its own, separate from `free`, which only ever holds
|
||||||
|
// ordinary image slots today.
|
||||||
|
if kind == TextureKind::Image {
|
||||||
|
self.free.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
|
||||||
|
self.updates.drain(..).map(|u| match u {
|
||||||
|
Update::Push(kind, i) => self.images[i as usize]
|
||||||
|
.as_ref()
|
||||||
|
.map(|img| TextureUpdate::Push(kind, img))
|
||||||
|
.unwrap_or(TextureUpdate::PushFree(kind)),
|
||||||
|
Update::Set(kind, i) => self.images[i as usize]
|
||||||
|
.as_ref()
|
||||||
|
.map(|img| TextureUpdate::Set(kind, i, img))
|
||||||
|
.unwrap_or(TextureUpdate::SetFree),
|
||||||
|
Update::Patch(i, rect) => self.images[i as usize]
|
||||||
|
.as_ref()
|
||||||
|
.map(|img| TextureUpdate::Patch(i, rect, img))
|
||||||
|
.unwrap_or(TextureUpdate::SetFree),
|
||||||
|
Update::Free(i) => TextureUpdate::Free(i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextureHandle {
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bind-group index this handle draws with. Only valid for a
|
||||||
|
/// standalone image; an atlas page has no bind group of its own -- it
|
||||||
|
/// samples the shared array via `layer()` instead. Getting this wrong is
|
||||||
|
/// a caller bug (the wrong kind of handle reached the wrong draw path),
|
||||||
|
/// not a recoverable condition, so it panics rather than drawing garbage.
|
||||||
|
pub fn image_index(&self) -> u32 {
|
||||||
|
match self.kind {
|
||||||
|
TextureKind::Image => self.slot,
|
||||||
|
TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layer this page occupies in the shared atlas array texture.
|
||||||
|
/// Only valid for a page handle; see `image_index`'s note.
|
||||||
|
pub fn layer(&self) -> u32 {
|
||||||
|
match self.kind {
|
||||||
|
TextureKind::Page { layer } => layer,
|
||||||
|
TextureKind::Image => panic!("layer() called on a standalone image handle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TextureHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.counter.drop() {
|
||||||
|
let _ = self.send.send((self.kind, self.slot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Index<&TextureHandle> for Textures {
|
||||||
|
type Output = DynamicImage;
|
||||||
|
|
||||||
|
fn index(&self, index: &TextureHandle) -> &Self::Output {
|
||||||
|
self.images[index.slot as usize].as_ref().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Textures {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user