From 25370731d09023d619c7b5a07359e03a5c6ef6c4 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 10 Sep 2026 00:44:13 -0400 Subject: [PATCH] Prune commentary and stale Rust port notes --- AGENTS.md | 7 +- app-rust/Cargo.toml | 76 +- .../dev/iris/android/demo/DevLogProvider.java | 76 +- .../java/dev/iris/android/demo/IrisView.java | 15 +- .../dev/iris/android/demo/MainActivity.java | 99 +- .../linebender/android/rustview/RustView.java | 8 +- app-rust/examples/phone.rs | 44 - app-rust/examples/transcript.rs | 62 - app-rust/rust-toolchain.toml | 9 +- app-rust/src/android/app_log.rs | 53 +- app-rust/src/android/bench_client.rs | 332 +----- app-rust/src/android/bench_jni.rs | 46 - app-rust/src/android/devlog.rs | 51 - app-rust/src/android/enrollment.rs | 46 - app-rust/src/android/mod.rs | 67 -- app-rust/src/android/transcript_client.rs | 95 -- app-rust/src/bin_desktop.rs | 26 - app-rust/src/client/ansi.rs | 78 -- app-rust/src/client/api.rs | 63 - app-rust/src/client/config.rs | 49 - app-rust/src/client/durations.rs | 26 - app-rust/src/client/event_stream.rs | 17 - app-rust/src/client/highlight/languages.rs | 45 - app-rust/src/client/highlight/markdown.rs | 62 - app-rust/src/client/highlight/mod.rs | 48 - app-rust/src/client/log_ring.rs | 114 -- app-rust/src/client/markdown_blocks.rs | 84 -- app-rust/src/client/mod.rs | 4 - app-rust/src/client/notifications.rs | 13 - app-rust/src/client/sse.rs | 19 - app-rust/src/client/text_cap.rs | 41 - app-rust/src/client/tool_summary.rs | 58 - app-rust/src/client/transcript_cache.rs | 226 ---- app-rust/src/client/transcript_fold.rs | 183 --- app-rust/src/client/transcript_source.rs | 109 -- app-rust/src/desktop/app.rs | 94 -- app-rust/src/desktop/config.rs | 13 - app-rust/src/desktop/mod.rs | 4 - app-rust/src/desktop/startup.rs | 6 - app-rust/src/lib.rs | 18 - app-rust/src/shell/jcall.rs | 53 - app-rust/src/shell/mod.rs | 36 - app-rust/src/shell/notify.rs | 31 - app-rust/src/shell/settings.rs | 9 - app-rust/src/shell/share.rs | 28 - app-rust/src/ui/composer.rs | 59 - app-rust/src/ui/fixture.rs | 40 - app-rust/src/ui/markdown.rs | 160 --- app-rust/src/ui/mod.rs | 301 ----- app-rust/src/ui/row.rs | 172 --- app-rust/src/ui/selection.rs | 152 --- app-rust/src/ui/tap.rs | 45 - app-rust/src/ui/tool.rs | 250 ---- app-rust/tests/catch_a_fling.rs | 45 - app-rust/tests/fence_fling.rs | 39 - app-rust/tests/gesture_cancel.rs | 56 - app-rust/tests/input_log_roundtrip.rs | 48 - app-rust/tests/phone_screen.rs | 94 -- app-rust/tests/top_edge.rs | 91 -- docs/CLIENT_CORE.md | 257 +--- docs/IRIS_TODO.md | 38 +- docs/LAYOUT.md | 497 +------- docs/RUST.md | 381 +----- docs/SCROLL.md | 100 +- docs/TEXTURES.md | 266 +---- docs/TODO.md | 45 +- event-model/Cargo.toml | 13 +- event-model/src/lib.rs | 253 +--- iris/Cargo.toml | 90 +- iris/benches/fling_spline_reference.py | 5 - iris/benches/message_list.rs | 129 -- iris/benches/report_to_touch.py | 2 - iris/benches/velocity_reference.py | 10 - iris/core/Cargo.toml | 6 +- iris/core/build-icon-font.sh | 2 - iris/core/src/event/manager.rs | 1 - iris/core/src/event/mod.rs | 13 - iris/core/src/icon.rs | 34 - iris/core/src/orientation/align.rs | 10 +- iris/core/src/orientation/len.rs | 18 - iris/core/src/primitive/color.rs | 1 - iris/core/src/primitive/layer.rs | 9 - iris/core/src/primitive/text.rs | 653 ++++------- iris/core/src/primitive/texture.rs | 46 - iris/core/src/render/atlas.rs | 78 -- iris/core/src/render/data.rs | 54 - iris/core/src/render/frame_report.rs | 247 ---- iris/core/src/render/mod.rs | 137 --- iris/core/src/render/primitive.rs | 137 --- iris/core/src/render/sdf.rs | 27 - iris/core/src/render/shader.wgsl | 86 +- iris/core/src/render/texture.rs | 62 - iris/core/src/render/util/mod.rs | 26 - iris/core/src/ui/access.rs | 27 - iris/core/src/ui/active.rs | 11 - iris/core/src/ui/mod.rs | 21 - iris/core/src/ui/painter.rs | 84 -- iris/core/src/ui/render_state.rs | 253 +--- iris/core/src/util/arena.rs | 8 - iris/core/src/util/dirty.rs | 28 - iris/core/src/util/id.rs | 2 - iris/core/src/util/math.rs | 3 - iris/core/src/util/typemap.rs | 1 - iris/core/src/util/vec2.rs | 1 - iris/core/src/widget/data.rs | 1 - iris/core/src/widget/handle.rs | 7 - iris/core/src/widget/like.rs | 1 - iris/core/src/widget/mod.rs | 7 - iris/core/src/widget/widgets.rs | 12 - iris/examples/bench_images.rs | 24 - iris/examples/message_list.rs | 36 - iris/examples/tabs/main.rs | 4 - iris/macro/src/lib.rs | 7 - iris/rig-input/Cargo.toml | 22 +- iris/rig-input/src/main.rs | 23 - iris/run-headless.sh | 4 - iris/rust-toolchain.toml | 9 +- iris/src/access_tests.rs | 21 - iris/src/android/access.rs | 18 - iris/src/android/ime.rs | 51 - iris/src/android/insets.rs | 49 +- iris/src/android/mod.rs | 14 - iris/src/android/platform.rs | 6 - iris/src/android/render.rs | 110 -- iris/src/android/view.rs | 251 ---- iris/src/attr.rs | 53 - iris/src/default/access.rs | 10 - iris/src/default/app.rs | 3 - iris/src/default/attr.rs | 2 - iris/src/default/logging.rs | 27 - iris/src/default/mod.rs | 42 - iris/src/default/platform.rs | 11 - iris/src/default/render.rs | 25 - iris/src/diagnostics.rs | 34 - iris/src/event.rs | 13 - iris/src/harness.rs | 77 -- iris/src/layout_tests.rs | 268 ----- iris/src/lib.rs | 7 - iris/src/platform.rs | 19 - iris/src/sense.rs | 1043 +---------------- iris/src/sense_tests.rs | 149 --- iris/src/widget/position/lazy_span.rs | 631 +--------- iris/src/widget/position/pad.rs | 5 - iris/src/widget/position/scroll_area.rs | 72 -- iris/src/widget/position/scrollable.rs | 189 --- iris/src/widget/text/build.rs | 4 - iris/src/widget/text/edit.rs | 102 -- iris/src/widget/text/mod.rs | 41 - iris/src/widget/trait_fns.rs | 25 - iris/tabs-ui/Cargo.toml | 6 +- iris/tabs-ui/src/lib.rs | 16 - iris/tests/mask_sdf.rs | 109 -- resources.ron | 28 +- scripts/rigs/gpu-probe/Cargo.toml | 11 +- scripts/rigs/gpu-probe/src/bin/teardown.rs | 35 - scripts/rigs/gpu-probe/src/main.rs | 51 - scripts/rigs/gpu-probe/src/vk.rs | 11 - scripts/rigs/ui-profile/Cargo.toml | 16 +- scripts/rigs/ui-profile/rust-toolchain.toml | 9 +- scripts/rigs/ui-profile/src/stats.rs | 9 - scripts/rigs/ui-profile/tests/arena_churn.rs | 54 +- .../rigs/ui-profile/tests/frame_profile.rs | 95 +- scripts/run-tests.sh | 20 +- scripts/test-wg-tunnel.sh | 5 +- scripts/wg-setup-host.sh | 4 +- scripts/xtask/Cargo.toml | 9 +- scripts/xtask/src/apk.rs | 52 - scripts/xtask/src/keystore.rs | 13 - scripts/xtask/src/main.rs | 22 - server/Cargo.toml | 40 +- server/src/auth.rs | 40 - server/src/config.rs | 207 ---- server/src/files.rs | 129 +- server/src/main.rs | 90 -- server/src/media.rs | 13 - server/src/models.rs | 114 -- server/src/resume.rs | 77 -- server/src/routes.rs | 405 ------- server/src/session/claude.rs | 361 ------ server/src/session/claude/translate.rs | 322 ----- server/src/session/driver.rs | 96 -- server/src/session/echo.rs | 213 ---- server/src/session/import.rs | 294 ----- server/src/session/llama.rs | 138 +-- server/src/session/mod.rs | 996 ---------------- server/src/session/pending.rs | 46 - server/src/session/process.rs | 97 -- server/src/session/subagent.rs | 60 - server/src/session/transcript.rs | 157 --- server/src/session/transport.rs | 76 -- server/src/setups.rs | 61 - server/src/ssh.rs | 109 -- server/src/usage.rs | 300 +---- 193 files changed, 693 insertions(+), 16219 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a59124d..93b3477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,10 +196,9 @@ guaranteed to have. own suite, which is slower and not about this product. Each workspace also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays warning-clean and rustfmt-clean at the defaults — there is no - `rustfmt.toml` and there should not be one. `app-rust/` and `iris/` are - pinned to the same dated nightly (`rust-toolchain.toml`, one copy each, - because a pin applies per directory); `server/` and `event-model/` are - stable. + `rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the + UI profiling rig use the rolling nightly channel through per-directory + `rust-toolchain.toml` files; `server/` and `event-model/` are stable. - **App**: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat :androidApp:compileDebugKotlin :androidApp:lintDebug diff --git a/app-rust/Cargo.toml b/app-rust/Cargo.toml index f0ed4ae..439d6bd 100644 --- a/app-rust/Cargo.toml +++ b/app-rust/Cargo.toml @@ -1,26 +1,10 @@ -# The app: everything that is about *this product* rather than about the UI -# framework it draws with. One package, because splitting it was buying -# nothing -- see `docs/RUST.md`'s "One app crate" for the account. In short: -# `client` (the REST/SSE clients, the transcript cache and fold, the -# highlighter) and `ui` (the screens, in iris widgets) only ever ship -# together, and the three entry points below are three faces of one binary -# rather than three programs. -# -# `iris/` is the framework and knows nothing about any of this; the -# dependency runs one way, and a widget or a colour appearing here that is -# not about a session, a transcript or a setup belongs there instead -# (AGENTS.md). +# Product code lives here; reusable UI belongs in `iris/`. [package] name = "ai-app" version = "0.1.0" edition = "2024" -# `cdylib` is the Android face -- both the iris app (`android-project/`, -# `System.loadLibrary("ai_app")`) and the JNI bridge the Kotlin shell in -# `app/shellApp` calls (the `shell` feature). `rlib` is what the desktop -# binary, the examples and `tests/` link against. One package produces one -# library artifact, so the two Android apps share a `.so` name and pick -# what goes in it with features rather than with a second crate. +# Android loads the cdylib; desktop, examples, and tests link the rlib. [lib] name = "ai_app" crate-type = ["cdylib", "rlib"] @@ -39,106 +23,67 @@ name = "phone" required-features = ["fixture"] [dependencies] -# The event model, shared with `server/` so the two agree by construction. -# It stays a crate of its own at the repo root for exactly that reason: -# it is the contract between this app and the backend, not app code. event-model = { path = "../event-model" } serde = { version = "1", features = ["derive"] } -# `float_roundtrip` for the same reason `server/` sets it -- AGENTS.md's -# "Things that have bitten". `raw_value` for the transcript cache. +# Transcript lines must retain exact float values and raw JSON bytes. serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] } ureq = { version = "3", features = ["json"] } pulldown-cmark = "0.13.4" base64 = "0.23" log = { version = "0.4.34", features = ["std"] } -# The UI framework. Optional so `--no-default-features --features shell` -# builds the Kotlin shell's JNI bridge without linking wgpu, parley and -# the rest of a renderer into an APK that draws with Compose. +# Optional so the Compose shell does not link the renderer. iris = { path = "../iris", optional = true } -# iris's own tabs demo, kept runnable on Android through this project's -# Gradle app (the `tabs-screen` feature). The dependency direction is the -# right way round: the app may reach into the framework's example widget -# tree, never the reverse. tabs-ui = { path = "../iris/tabs-ui", optional = true } jni = { version = "0.22", optional = true } -# `bench` only: `libc` for the process CPU-time and RSS samples, `tokio` -# for the run's own timer. libc = { version = "0.2.189", optional = true } tokio = { version = "1.53.1", features = ["rt", "time"], optional = true } [target.'cfg(not(target_os = "android"))'.dependencies] winit = "0.30.13" -# Pinned to the exact commit RUST.md's E1 measured on this emulator; see -# `iris/Cargo.toml`'s copy of this pin for what advancing it costs. +# Keep this pin synchronized with `iris/Cargo.toml`. [target.'cfg(target_os = "android")'.dependencies] android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" } android_logger = "0.15.1" [features] default = ["screens", "fixture"] -# The iris half: `src/ui` and everything that draws. Off for the Kotlin -# shell's bridge, which is JNI and `src/client` only. screens = ["dep:iris"] -# `src/ui/fixture.rs` and the harness tests that drive it. Default-on so -# `cargo test` covers them; `build-apk.sh` passes `--no-default-features` -# so an APK carries the 1.9 MB fixture only when it asked for `bench`. +# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture. fixture = ["screens"] -# The two Android widget trees, on the same axis: a build picks one. transcript-screen = ["screens"] tabs-screen = ["screens", "dep:tabs-ui"] -# P0's iris half (docs/RUST.md): the fixture screen with a "Run benchmark" -# control, driving the same scroll loop and streaming phase the Compose -# bench build type does. bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"] -# The JNI bridge `app/shellApp` calls -- notifications, the share target -# and the Keystore-sealed settings. shell = ["dep:jni"] -# See `iris/Cargo.toml`'s feature of the same name. Never for a phone. force-gles = ["screens", "iris/force-gles"] [dev-dependencies] tempfile = "3" tokio = { version = "1.53.1", features = ["rt", "time"] } -# The Android builds, kept off `release`/`dev` so a desktop build is not -# also optimised for size and unwinding is not also turned off for the -# tests. `build-apk.sh` passes `--profile android-release`. +# APK builds select these profiles explicitly. [profile.android-release] inherits = "release" panic = "abort" strip = true lto = "fat" codegen-units = 1 -# **Speed, not size** (2026-09-09). This was `"s"`, chosen when the -# question was why the APK was double the Compose one -- but that was -# measured in bytes only, and `"s"` costs the loop vectorisation and -# inlining a renderer runs on. Measured with -# `scripts/rigs/ui-profile`'s `frame_profile.rs`, the same warm fling eight -# times over: -# iris's own per-frame work is p90 0.15ms / p99 0.42ms at `"s"` and -# p90 0.09ms / p99 0.26ms at `3`, so about a third of the CPU half of a -# scrolling frame was being paid for 1.9 MB of download. The same -# argument the table in docs/RUST.md gives for refusing `"z"`, applied one -# level further up. +# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus +# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost. opt-level = 3 [profile.android-dev] inherits = "dev" panic = "abort" -# Same reasoning as `iris/Cargo.toml`'s copy: full DWARF in every test -# binary is what made `cargo test` here write tens of gigabytes. +# Full DWARF in each renderer-linked test binary writes tens of gigabytes. [profile.dev] debug = "line-tables-only" [profile.test] debug = "line-tables-only" -# The headless harness tests (`iris::harness`, no window and no GPU) all -# open the bench fixture, so they say so rather than failing to compile -# when it is off. [[test]] name = "catch_a_fling" required-features = ["fixture"] @@ -162,4 +107,3 @@ required-features = ["fixture"] [[test]] name = "top_edge" required-features = ["fixture"] - diff --git a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java b/app-rust/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java index 9585c28..8477403 100644 --- a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java +++ b/app-rust/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java @@ -7,52 +7,13 @@ import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; -/** - * This app's own recent log, exposed on the device. - * - * Iris runs these builds on a phone with no {@code adb}, and Android - * forbids one app reading another's {@code logcat} -- so nothing outside - * this process can recover what it wrote. The process already keeps a - * bounded copy of its log (Rust: {@code client_core::log_ring}); this - * hands it to Dev Updater, which is on the same phone, so it needs no - * tunnel, no token and no second enrolment. - * - *

The shape is Dev Updater's contract, not something invented - * here -- see that project's {@code README.md}, "An app's own log". Any - * app it delivers can implement the same and get the same Runtime tab. - * Two paths: - * - *

- * - *

Read-only: there is nothing here for anyone else to change, so the - * three writing methods throw rather than silently doing nothing. - * - *

The authority is {@code .devlog}, filled in from - * Gradle so the bench build and the ordinary one each get their own and - * neither can read the other's. Read access is guarded by - * {@code dev.updater.permission.READ_DEVLOG}, declared in the manifest. - * - *

No {@code notifyChange}: the ring is filled by a {@code log::Log} - * backend on whatever thread logged, and giving that a way to reach a - * provider would mean plumbing a callback through {@code client-core} for - * every platform. Dev Updater polls while its tab is open, which its - * contract says it does precisely so implementing this stays cheap. - */ +/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */ public final class DevLogProvider extends ContentProvider { static { - // The provider is created before any activity, so it cannot rely - // on MainActivity's own load. Loading twice is a no-op. + // A provider can start the process without creating MainActivity. System.loadLibrary("ai_app"); } - /** Matches {@link #nativeLinesSince}'s flat answer. Both sides say it once. */ private static final int FIELDS_PER_LINE = 5; private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"}; @@ -63,33 +24,16 @@ public final class DevLogProvider extends ContentProvider { private UriMatcher matcher; - /** Every held line, {@link #FIELDS_PER_LINE} strings each, oldest first. */ private static native String[] nativeLinesSince(long since); - /** Three strings: held, dropped, newest sequence. */ private static native String[] nativeStatus(); - /** - * Tells the Rust side which authority this build registered under, so - * the diagnostics pane can name somewhere a reader can actually query - * -- and so "declared but never created" is a state it can say. Only - * the provider knows it was instantiated; Android creates one lazily. - * - *

The files directory goes with it because this is usually the - * only thing running: after the app has died, Dev Updater's query - * starts the process for the provider alone, with no activity, so - * {@code MainActivity.nativeSetFilesDir} is never called and the line - * the panic hook left on disk is never replayed into the ring. That is - * exactly the run whose log somebody wants. - */ + // The provider may be the process's only component, so it must supply + // the files directory normally initialized by MainActivity. private static native void nativeReady(String authority, String filesDir); @Override public boolean onCreate() { - // The authority is not a constant here: it is derived from this - // build's applicationId, so the bench package and the ordinary one - // do not share one. Read back from the manifest rather than - // recomposed, so there is one answer to what it is. String authority = getContext().getPackageName() + ".devlog"; matcher = new UriMatcher(UriMatcher.NO_MATCH); matcher.addURI(authority, "lines", LINES); @@ -111,19 +55,10 @@ public final class DevLogProvider extends ContentProvider { case STATUS: return status(); default: - // Null rather than an exception: an unknown path is a - // reader asking for something this app does not have, and - // the contract's own answer for that is no cursor. return null; } } - /** - * {@code ?since=} as a number, or 0 for a reader starting from the - * beginning. A value that is not a number is treated as 0 rather than - * refused -- what a caller wants from a malformed cursor is the log, - * not a stack trace about the query string. - */ private static long sinceOf(Uri uri) { String since = uri.getQueryParameter("since"); if (since == null) { @@ -170,9 +105,6 @@ public final class DevLogProvider extends ContentProvider { @Override public String getType(Uri uri) { - // A MIME type is for something meant to be handed to another app - // as data; these rows are read by one reader that knows the - // columns. Saying nothing is the honest answer, not a gap. return null; } diff --git a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java b/app-rust/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java index ded3d11..35fbec1 100644 --- a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java +++ b/app-rust/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java @@ -39,20 +39,7 @@ public final class IrisView extends RustView { super.onDetachedFromWindow(); } - /** - * Called from the Rust side (iris/src/android/view.rs's - * `show_renderer_error`) when `AndroidRenderer::new` fails instead of - * drawing -- an ordinary instance method rather than a `native` one, - * since this call is Rust reaching into Java rather than the other - * direction. Replaces the whole activity content with plain, - * selectable, scrollable text rather than leaving the last frame (or a - * blank surface) on screen with no way to report what happened: - * UI_RULES.md's "a failure is reported where it happened, and says - * what to do next." No dialog and no styling beyond what is needed to - * read and copy the text -- this path exists for exactly the crash it - * replaces, so it must not depend on anything that could itself fail - * to render. - */ + // This path must not depend on the renderer that failed to initialize. void showRendererError(String report) { Context context = getContext(); if (!(context instanceof Activity)) { diff --git a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java b/app-rust/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java index fa8ac58..9ea799a 100644 --- a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java +++ b/app-rust/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java @@ -10,38 +10,18 @@ import android.view.WindowInsetsAnimation; import android.widget.FrameLayout; import java.util.List; -/** - * 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("ai_app"); } - /** - * The app's private directory, where the Rust side keeps its enrollment - * (`src/enrollment.rs`). Handed over before the view is built, because - * the client the view creates reads the enrollment as it starts. - */ private static native void nativeSetFilesDir(String path); - /** - * One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's - * Enroll button opens it. Parsed and stored on the Rust side, which is - * where the enrollment lives for the desktop app too -- nothing about - * the link's format is known here. - */ private static native void nativeEnroll(String uri); @Override public void onCreate(Bundle state) { super.onCreate(state); - // Before the view: creating it starts the Rust client, which asks - // straight away which server it is enrolled with. nativeSetFilesDir(getFilesDir().getAbsolutePath()); handleEnrollmentIntent(getIntent()); IrisView view = new IrisView(this); @@ -54,46 +34,14 @@ public final class MainActivity extends Activity { setContentView(layout); view.requestFocus(); - // RUST.md's P0 box, defect 4 ("keyboard: could not be shown"): - // `logcat` showed the platform's own IME open/resize happening - // while `setOnApplyWindowInsetsListener` fired only once, at - // attach, and never again for a pure keyboard toggle -- a plain - // (non-edge-to-edge) window is only guaranteed that one initial - // dispatch; `adjustResize` handling the IME entirely by resizing - // the window is not itself a trigger for a fresh one. Opting into - // edge-to-edge (a platform call, API 30+, no new dependency) is - // what makes the system redeliver insets on every change, - // including the ones this activity actually cares about -- - // `getSystemWindowInset*` below is unaffected by this (it has - // always reported the raw system-bar/IME overlap regardless of - // who consumes it), so the on-screen bars and the padding Rust - // already derives from those four numbers are unchanged; only the - // callback's firing became reliable. + // Edge-to-edge makes IME-only changes produce fresh inset dispatches. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { getWindow().setDecorFitsSystemWindows(false); } - // **The keyboard's height arrives twice, over two different - // paths, and the phone needs the second one** (Iris, 2026-09-07: - // the emulator pushed the composer up and her Pixel did not). - // `setOnApplyWindowInsetsListener` is the platform's *settled* - // answer; `WindowInsetsAnimation.Callback` is the running one, and - // an IME that animates in delivers every intermediate height - // through the callback with the static dispatch arriving only at - // the ends -- on some devices only at `onEnd`. Registering both - // means neither device depends on the other's timing, and it is - // also what makes the push-up *animate* with the keyboard rather - // than jump when it lands. - // - // The two do not disagree, because they are the same call with the - // same numbers read out of whichever `WindowInsets` is current. - // `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming - // nothing keeps the ordinary dispatch running underneath. - // `onEnd` re-reads the root's insets rather than trusting the last - // `onProgress`: an animation interrupted mid-flight never delivers - // its final frame, which is exactly the fault the Compose app hit - // (AGENTS.md, "the composer can get stuck floating above the - // bottom of the screen"). + // Static dispatch supplies settled insets; the animation callback + // supplies intermediate IME heights. An interrupted animation may + // omit its final progress frame, so onEnd re-reads the root insets. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback( WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) { @@ -120,25 +68,14 @@ public final class MainActivity extends Activity { }); } - /** - * A link that arrives while the activity is already up. `singleTop` is - * not set, so this is the resumed case only -- the fresh-launch case - * goes through `onCreate`'s `getIntent`. `setIntent` so a later - * `getIntent` reports the one actually being acted on rather than the - * one this activity started with. - */ @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); + // Keep getIntent() consistent with the enrollment being handled. setIntent(intent); handleEnrollmentIntent(intent); } - /** - * Hands a VIEW intent's URI to the Rust side, which decides whether it - * is an enrollment link -- the scheme is checked here only so a launch - * intent (which carries no data) costs nothing. - */ private static void handleEnrollmentIntent(Intent intent) { if (intent == null) { return; @@ -149,35 +86,13 @@ public final class MainActivity extends Activity { } } - /** Read one `WindowInsets` and hand it to the Rust side. The only - * place that reads these fields, so the static dispatch and the - * animation callback above cannot come to report different things. */ private static void sendInsets(IrisView view, WindowInsets insets) { int left = insets.getSystemWindowInsetLeft(); int top = insets.getSystemWindowInsetTop(); int right = insets.getSystemWindowInsetRight(); int bottom = insets.getSystemWindowInsetBottom(); - // **Two separate answers, because they are separate questions** - // (Iris's phone, 2026-09-06: "message box does not push up the - // scroll area"). `isVisible(ime())` says whether the keyboard is - // up; `getInsets(ime()).bottom` says how tall it is. An earlier - // pass sent the boolean *as* the height (0 or 1) because under - // plain `adjustResize` the window shrinks to make room and the ime - // inset therefore measures a zero overlap by construction -- true - // then, and no longer true now that this is an edge-to-edge window - // (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call - // above for the devices below that), which is exactly the case - // where the system stops resizing and hands the app the real - // overlap instead. Sending 1 for it left the Rust side padding the - // composer by one physical pixel, so the keyboard covered the bar - // and the transcript alike. - // - // The visibility is still sent in its own right rather than - // inferred from `height > 0`: the two disagree during the - // keyboard's slide-in and -out (visible, height still climbing), - // and "is the IME up" drives the bench's own state machine - // (`bench_client.rs`'s `ime_state`) where a half-open frame - // reading as "closed" is a miscount. + // Visibility and height disagree during IME animation, so neither + // can be inferred from the other. int imeBottom = 0; int imeVisible = 0; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java b/app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java index ddde7d6..b7fa4ad 100644 --- a/app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java +++ b/app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java @@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager; public abstract class RustView extends SurfaceView implements SurfaceHolder.Callback, Choreographer.FrameCallback { - // Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view) - // with one deliberate change: `protected` rather than package-private, so a - // subclass in a different package (dev.iris.android.demo.IrisView) can pass - // it to the window-insets native call android-view itself has no hook for -- - // see iris/src/android/insets.rs's doc comment for why that call exists at - // all. No other line differs from upstream. + // Vendored from android-view bec6c62. The only local change is `protected`, + // allowing IrisView to forward insets through this native peer. protected final long mViewPeer; final InputMethodManager mInputMethodManager; diff --git a/app-rust/examples/phone.rs b/app-rust/examples/phone.rs index 8781926..c22037a 100644 --- a/app-rust/examples/phone.rs +++ b/app-rust/examples/phone.rs @@ -1,39 +1,6 @@ -//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed -//! transcript screen in a phone-shaped window, for looking at. -//! -//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture -//! -//! `--phone` sets the headless sway output to the phone's own 1080x2424 -//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's -//! phone reports (`ai_app::ui::fixture::PHONE_SCALE`) rather than the -//! desktop's 1.0 -- same screen, same fixture and the same folding as -//! the Android bench and the headless tests, so what differs between a -//! screenshot here and one from the phone is the renderer, never the -//! data. -//! -//! `--message TEXT` (through `RUN_HEADLESS_ARGS`) starts with that text -//! already in the composer, `\n` for a newline -- the composer's grown -//! and overflowing states are otherwise unreachable here, since this -//! window has no keyboard to type into (UI_RULES.md's "check the states -//! you can't see by default"). `--typed TEXT` *enters* the same text -//! instead, one character per 100ms: laying the composer out from -//! scratch and growing one already on screen are different cases, and -//! only the second reproduced the caret landing in the bar's padding -//! (decided 2026-09-08). -//! -//! No server: `transcript-fixture` embeds the transcript. Colour, -//! spacing, type and anything a person has to *see* is answered here; -//! anything with an assertion behind it belongs in `tests/ -//! phone_screen.rs` one layer down. - use iris::prelude::*; use winit::{dpi::PhysicalSize, window::WindowAttributes}; -/// The `--ime PX` argument: the bottom inset a keyboard would report, -/// applied after the first frame the way Android's `on_insets_changed` -/// does. The composer's keyboard-open layout is otherwise unreachable -/// here, and it is where its mask went wrong before (see -/// `ActiveData::own_mask`). fn ime_argv() -> Option { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -44,8 +11,6 @@ fn ime_argv() -> Option { None } -/// The `--message TEXT` argument, with `\n` taken as a newline so a -/// multi-line message survives one shell word. fn message_argv() -> Option { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -56,12 +21,6 @@ fn message_argv() -> Option { None } -/// The `--typed TEXT` argument: the same text as `--message`, but -/// *entered* rather than preloaded -- one insertion per 100ms, into a -/// focused field, the way a person types. The two are different cases -/// for layout: `--message` is laid out from scratch on the first frame, -/// while this grows an already-drawn composer, which is the path -/// Iris's 2026-09-08 phone report is about. fn typed_argv() -> Option { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -127,9 +86,6 @@ impl DefaultAppState for Client { } Some(opened.screen) } - // On screen rather than a panic: this window exists to be - // looked at, and "the fixture stopped folding" is something - // to read, not a process that vanished (UI_RULES.md). Err(message) => { let text = wtext(format!("Couldn't fold the bench fixture: {message}")) .color(Color::WHITE) diff --git a/app-rust/examples/transcript.rs b/app-rust/examples/transcript.rs index a90cd23..a33bf8a 100644 --- a/app-rust/examples/transcript.rs +++ b/app-rust/examples/transcript.rs @@ -1,18 +1,3 @@ -//! I5's desktop proof: the transcript screen built from synthetic -//! `ai_app::client::transcript_fold` rows (no network, no server -- see -//! `lib.rs`'s doc for why `transcript-ui` itself never fetches anything), -//! run via `iris/run-headless.sh transcript -- -p transcript-ui` for a -//! screenshot on the winit backend, or `cargo run --example transcript -p -//! transcript-ui` with a real compositor. -//! -//! The rows exercise every one of the seven "hard to get back" behaviours -//! this box's markdown/selection work is meant to show: a heading, bold, -//! italic, an inline code span, a link, a fenced code block (rich inline -//! text), a multi-message conversation (bottom-anchored virtualised list), -//! and a three-call tool run (collapsed by default -- tap it, or drive it -//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove -//! hold-the-edge expand). - use ai_app::client::QuestionOption; use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use iris::prelude::*; @@ -44,17 +29,10 @@ fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow { }) } -/// One tool call. `result` is `None` for a call with no result yet and -/// `Some((output, failed))` for one that answered. fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem { tool_call_in("run1", id, tool, input, result) } -/// The same, in a named run. Two runs in one transcript must not share a -/// `run_id`: it is the row's identity in the list (`row::row_key`), and -/// two rows under one key is the duplicate-key fault AGENTS.md's -/// "Importing" section describes. Here it made two rows swap cached -/// heights and draw at each other's boxes. fn tool_call_in( run: &str, id: &str, @@ -76,7 +54,6 @@ fn tool_call_in( } } -/// A call stopped on the reader: one unanswered permission question. fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem { let mut call = tool_call_in("run2", id, tool, input, None); if let TranscriptItem::ToolRun { asks, .. } = &mut call { @@ -104,8 +81,6 @@ fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem { call } -/// Longer than the card's own cap, so the "Show all N lines" control is on -/// screen in the expanded shot. fn long_output() -> String { (0..200) .map(|i| format!("test ai_app::ui::case_{i} ... ok")) @@ -125,12 +100,6 @@ fn synthetic_rows() -> Vec { false, "# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```", ), - // Every state a tool card has to draw, in one run (P1b): a call - // that worked, one the tool reported as failed, one whose result - // never arrived, and one still running. The last two look the same - // in the events -- an empty output and `done: false` -- and are - // told apart only by whether the session is still working, which - // is what `TranscriptScreen::set_session_working` says. FoldedRow::Tools(vec![ tool_call( "t1", @@ -149,9 +118,6 @@ fn synthetic_rows() -> Vec { ), tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None), ]), - // A lone call is a card too rather than a group of one -- and this - // one carries the kilobyte output a collapsed card must not lay - // out. FoldedRow::Single(tool_call( "t5", "Bash", @@ -159,19 +125,10 @@ fn synthetic_rows() -> Vec { Some((&long_output(), false)), )), msg(6, true, "Looks good, thanks!"), - // Every block kind `ai_app::client::markdown_blocks` names, in one - // row, so P1a's appearance can be looked at against the Compose - // app's without a server (docs/RUST.md's P1a box). The heading, - // paragraph, fence and table are the *same source* the bench - // fixture carries (`app/bench-fixture/generate.py`), so the two - // screenshots differ only in the renderer; the list and the quote - // are extra, because the fixture has neither. msg(7, false, BLOCK_SAMPLER), ] } -/// One of each markdown block, for the P1a screenshot pair. See -/// [`synthetic_rows`]. const BLOCK_SAMPLER: &str = "\ ## What changed @@ -181,7 +138,6 @@ iris measure iris scroll call transcript layout *cursor* context, and a \ ```rust fn fold_event(items: Vec, seq: u64) -> Vec { - // a comment worth keeping: this is the fold the app's own screen runs let mut out = items; out.push(Item::new(seq)); out @@ -208,10 +164,6 @@ impl DefaultAppState for Client { _: Proxy, ) -> Self { let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows()); - // Exercises `push_row`/`ItemKey` beyond construction time, matching - // how a live SSE loop appends -- a row arriving after the screen - // already exists must land at the bottom without disturbing what's - // above it (I3's `push_back`/`snap_end`). screen.push_row( rsc, &FoldedRow::Single(TranscriptItem::CommandRow { @@ -219,11 +171,6 @@ impl DefaultAppState for Client { text: "clear".into(), }), ); - // A second run at the live end, so the *running* state is on - // screen too. It cannot share a row with "no result": the two are - // the same events and are told apart only by whether the session - // is working, which is a property of the row rather than of the - // call (`TranscriptScreen::set_session_working`). screen.push_row( rsc, &FoldedRow::Tools(vec![ @@ -242,12 +189,6 @@ impl DefaultAppState for Client { Some(("error: unused variable `x`", true)), ), tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None), - // Waiting on a permission, so this card is drawn *open* - // whatever the reader last chose -- the command is the - // thing being decided, and a row saying only "Bash" - // cannot be decided on. It is also how the expanded card - // (input block, output block, timeout) gets into the - // screenshot without a finger. asking( "t9", "Bash", @@ -256,9 +197,6 @@ impl DefaultAppState for Client { ]), ); screen.set_session_working(rsc, true); - // The expanded picture has no other way to be looked at on a - // machine with no display and no finger -- see `run-headless.sh` - // and docs/RUST.md's P1b box. if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() { assert!( screen.expand_tail_tools(rsc, true), diff --git a/app-rust/rust-toolchain.toml b/app-rust/rust-toolchain.toml index 7fd6ab2..303e39c 100644 --- a/app-rust/rust-toolchain.toml +++ b/app-rust/rust-toolchain.toml @@ -1,11 +1,4 @@ -# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs). -# The pin is dated rather than "nightly" because the const-traits feature set -# changes shape between nightlies: on 2026-09-04 the vendored January tree would -# not parse at all, because `impl const Trait for T` had become -# `const impl Trait for T`. A rolling channel turns that into a build that -# breaks unattended on whatever machine Dev Updater happens to build on. -# Advance this deliberately, with the feature list in RUST.md's I0b. [toolchain] -channel = "nightly-2026-09-03" +channel = "nightly" components = ["clippy", "rustfmt"] targets = ["aarch64-linux-android", "x86_64-linux-android"] diff --git a/app-rust/src/android/app_log.rs b/app-rust/src/android/app_log.rs index 213aec0..10a82ad 100644 --- a/app-rust/src/android/app_log.rs +++ b/app-rust/src/android/app_log.rs @@ -1,17 +1,6 @@ //! The platform half of this app's logging: what //! `crate::client::log_ring` needs that only Android can supply, which is //! `android_logger` as the logger to forward to and nothing else. -//! -//! Everything general -- the ring, its bounds, the `log::Log` backend -- -//! is in `client-core`, shared with the desktop app (AGENTS.md's sharing -//! rule). -//! -//! **Why an app carries its own log at all**: Iris tests these builds on a -//! GrapheneOS phone with no `adb`, and Android forbids one app reading -//! another's `logcat`. Nothing outside this process can recover what it -//! wrote, so the process keeps a copy -- and hands it to Dev Updater on -//! the same phone through `devlog`'s `ContentProvider`. See -//! decided 2026-09-07. use crate::client::log_ring::{self, LogRing}; @@ -43,23 +32,11 @@ pub fn install(max_level: log::LevelFilter) { install_panic_hook(); } -/// The process's ring -- what `Copy report` appends, what the diagnostics -/// pane counts, and what `devlog`'s provider hands to Dev Updater. pub fn ring() -> &'static LogRing { log_ring::process_ring() } -/// Only the bench build has a diagnostics pane to put this in; the -/// transcript build's screen is the app's own and has no room for a -/// readout. Gated rather than left dead so the build stays warning-clean. #[cfg(feature = "bench")] -/// Two lines for the diagnostics pane: how much of this app's log is held, -/// and where it can be read from. -/// -/// The second names the provider's authority rather than saying "logging -/// is on", so a screenshot of this pane is enough to tell whether the -/// contract is live and which package's log it is -- the bench build and -/// the ordinary one have different ones. pub fn diagnostics_line() -> String { let where_to_read = match crate::android::devlog::authority() { Some(authority) => format!("devlog provider: content://{authority}"), @@ -88,28 +65,11 @@ const CRASH_FILE: &str = "last-panic.txt"; /// unbounded paste would evict the run that is actually being watched. const CRASH_CONTEXT_LINES: usize = 80; -/// The target the replayed context lines carry, so a reader can tell a -/// line from the run that died from one this run wrote. They keep their -/// original timestamp and level inside the text, which is why the level -/// they are re-pushed at is not meaningful and the target has to be. const PREVIOUS_RUN_TARGET: &str = "previous_run"; static CRASH_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); -/// Installs a `log`-level panic hook, so a panic's message and location -/// reach the ring and `logcat` rather than only the tombstone. -/// -/// **Why this is needed at all**: these builds are `panic = "abort"` -/// (`Cargo.toml`), and the default hook writes to `stderr` plus -/// `android_set_abort_message` -- the crash report. Iris runs these on a -/// phone with no `adb`, so the crash report is exactly the surface she -/// cannot read, and an `assert!` that fired said nothing anywhere she -/// could see it. Routing it through `log::error!` puts it in front of -/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev -/// Updater. -/// -/// The ring is memory only, so after an abort the process that holds it -/// is gone -- hence the file half. [`set_crash_dir`] replays it. +/// Copies aborting panics into the device-readable log ring. fn install_panic_hook() { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { @@ -117,8 +77,6 @@ fn install_panic_hook() { Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()), None => "an unknown location".to_string(), }; - // `info`'s own `Display` repeats the location and a newline; - // the payload alone keeps this to the one line the ring wants. let message = info.payload_as_str().unwrap_or("Box"); let line = format!("iris panic at {where_at}: {message}"); log::error!("{line}"); @@ -128,8 +86,6 @@ fn install_panic_hook() { // `set_crash_dir`. let context = ring() .try_tail_text(CRASH_CONTEXT_LINES) - // Said rather than left empty, so "the ring was locked as - // we died" cannot be read as "nothing had been logged". .unwrap_or_else(|| { "(the log ring was locked as this run died; no context)".to_string() }); @@ -167,13 +123,6 @@ pub fn set_crash_dir(dir: &std::path::Path) { /// Puts a previous run's report back in the ring: its context lines in /// the order they happened, then the panic itself. -/// -/// Chronological, so the Runtime tab reads as one story -- the lines that -/// led to the crash, then the crash, then this run. The context goes in -/// through `LogRing::push` rather than through `log::info!` so it is not -/// stamped with this run's clock: each line already carries the time and -/// level it was written at, and [`PREVIOUS_RUN_TARGET`] is what says -/// whose run it was. fn replay_crash(report: &str) { let (panic_line, context) = report.split_once('\n').unwrap_or((report, "")); for line in context.lines().filter(|line| !line.is_empty()) { diff --git a/app-rust/src/android/bench_client.rs b/app-rust/src/android/bench_client.rs index 3127bd6..698a302 100644 --- a/app-rust/src/android/bench_client.rs +++ b/app-rust/src/android/bench_client.rs @@ -1,22 +1,3 @@ -//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"): -//! the same fixture, scroll loop and streaming phase the Compose `bench` -//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against -//! `transcript-ui`'s real screen with no server -- a frame-time comparison -//! that measures the renderer rather than the data or the network. -//! -//! **Reuses `transcript_client.rs`'s shape** (folded items, the same -//! `TranscriptScreen::apply` incremental update on every event) with the -//! network half replaced by the checked-in fixture. Reading that fixture -//! and folding it into a screen is **`transcript-fixture`'s** job, not -//! this file's -- the same crate the headless harness and the -//! phone-shaped desktop window open, so all three measure one screen -//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is -//! replayed one at a time through `fold_event` -- the same fold path a -//! live SSE reply arrives on -- by the "Run benchmark" control below. -//! Streaming through `apply` rather than a full rebuild per event is what -//! this file exists to measure -- see docs/RUST.md's P0 box for the -//! before/after report. - use crate::android::bench_jni::PlatformHandle; use crate::client::transcript_fold::{TranscriptItem, fold_event}; use android_view::jni::{JavaVM, objects::GlobalRef}; @@ -27,35 +8,16 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients -/// implement the identical four phases -- see that box before changing any -/// constant here, since a mismatch would make the two reports stop -/// measuring the same thing while still looking like they do. const STREAM_EVENTS_PER_SEC: u64 = 20; const STREAM_SECONDS: u64 = 20; -/// Kept only so this phase's own label text still reads "scroll: 6 cycles -/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does -- -/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this -/// exact line even though the swipe loop it names no longer runs there -/// either (the fling phase replaced it); nothing here drives an actual -/// swipe with these any more. const LEGACY_CYCLES: usize = 6; -/// Fling phase (v2): a real fling through `Scroll::fling`, not a tween -- -/// Iris's ask was that it "travel way faster" than the v1 swipe, and a -/// tween can never exceed the distance/time it is given while a real -/// fling decays from an initial velocity the way a finger flick does. -/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly. const FLING_VELOCITY_PX_S: f32 = 12_000.0; const FLING_COUNT: usize = 8; const FLING_SETTLE_CAP_MS: u64 = 3_000; const FLING_PAUSE_MS: u64 = 300; -/// Type phase (v2): long, multisyllabic words so the composer actually -/// wraps and the transcript above it is pushed upward, typed and deleted -/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` -- -/// verified 600 characters by `type_text_is_exactly_600_characters` below. const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \ multisyllabic words so wrapping and reflow are properly exercised: internationalization, \ counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \ @@ -66,72 +28,29 @@ keyboard-adjacent box, which is exactly what a real reader typing a long message happening now!!!"; const TYPE_CHAR_MS: u64 = 50; -/// Keyboard phase (v2): five show/hide cycles, a second apart, matching -/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/ -/// `KEYBOARD_HIDE_WAIT_MS`. const KEYBOARD_CYCLES: usize = 5; const KEYBOARD_WAIT_MS: u64 = 1_000; -/// How often this file *asks a question of* the running app -- polls for -/// a `ctx.update` closure's answer, or for a fling to have settled. -/// -/// It is not an animation cadence and nothing on screen moves at this -/// rate: the frame loop advances animations once per frame at the -/// display's own refresh (`UiData::tick_animations`). It used to be both, -/// and that is the defect Iris reported on 2026-09-08 -- see -/// `wait_for_fling_settle`. const POLL_MS: u64 = 16; -/// How much of the screen a *filled* benchmark report may take before it -/// scrolls instead of growing -- roughly a third of a phone screen, the -/// share the pane used to reserve unconditionally. An empty report takes -/// nothing at all; see `new`'s comment at the tree it is used in. const REPORT_MAX_HEIGHT_DP: f32 = 260.0; pub struct BenchClient { ui_state: AndroidUiState, content: WeakWidget, report_display: WeakWidget, - /// The top button row, in a `WidgetPtr` slot rather than added - /// directly (like `content`) so `on_insets_changed` can swap in a - /// version padded for the status bar once insets are known -- RUST.md's - /// P0 box, "the status-bar inset is not applied," found the row sitting - /// directly under it because nothing here read `insets().top` at all. top_bar: WeakWidget, screen: Option, items: Vec, - /// 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, platform: Option>, last_report: Option, running: bool, - /// The keyboard phase's own confirmation channel -- updated from - /// `on_insets_changed` (the platform's own answer for whether the IME - /// is actually visible, per `WindowInsets::ime_bottom`), read from the - /// benchmark's spawned task via the shared `Arc>` rather than - /// `ctx.update`, since neither side needs the widget tree for this. ime_state: Arc>, - /// Edge-triggers the keyboard diagnostics capture below -- set on the - /// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the - /// first where it is not, so opening the keyboard fires this once - /// rather than on every insets update while it stays open (a rotation - /// or a status-bar change with the keyboard already up would otherwise - /// re-fire it). keyboard_was_visible: bool, - /// The status-bar inset `top_bar` was last padded by -- see - /// `on_insets_changed`'s own comment for why this guards the rebuild. last_top_pad: f32, } -/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events` -/// count real 0->visible / visible->0 transitions `on_insets_changed` -/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never -/// present an inferred value as a measured one. `run_keyboard_phase` reads -/// the counters before and after asking for a toggle and calls it -/// confirmed only if the count moved. #[derive(Default)] struct ImeState { visible: bool, @@ -157,10 +76,6 @@ fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { .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 { // SAFETY: `rusage` is a plain-old-data struct `getrusage` fully // initialises on success; on failure it is never read. @@ -175,9 +90,6 @@ fn process_cpu_ms() -> Option { } } -/// `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 { std::fs::read_to_string("/proc/self/status") .ok()? @@ -192,12 +104,6 @@ fn battery_line(samples: &[i32]) -> String { return " battery current: unavailable on this device".to_string(); } let mean = samples.iter().map(|&v| v as i64).sum::() / samples.len() as i64; - // `min`/`max` are guarded by the `is_empty` check above, three lines - // up -- pairing the `Option` unwraps with the emptiness check right - // here (rather than two statements apart, with `mean` in between - // reading the same slice) is what keeps a future reorder from - // separating the guard from what it protects (review, 2026-09-06 - // finding 7). let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else { unreachable!("samples is non-empty, checked above"); }; @@ -226,22 +132,6 @@ impl AndroidAppState for BenchClient { let top_bar = WidgetPtr::new().add(rsc); let controls = bench_controls(rsc, 0.0); top_bar(rsc).set(controls); - // The report pane is sized to whatever report it is holding, not - // to a share of the window: `rest(1)` here reserved a third of - // the screen for an *empty* `TextEdit` at every launch, which is - // what Iris's 2026-09-06 11:39 phone report described as "the app - // does not start with keyboard spacing correct" -- the composer - // two thirds down with black below it, nothing to do with the IME - // inset (measured: `iris insets:` reports bottom=63 ime_bottom=0 - // at launch, while the `Message` field's own box sat 789px above - // the bottom of a 2282px surface -- exactly this pane's third). - // Capped and scrollable so a long report cannot take the screen - // back over, the same idiom `composer.rs` uses for the field. - // Above the transcript, not below it: the report is what the - // header's own "Run benchmark" button produces (UI_RULES.md -- - // results appear where the action was started), and a pane under - // the composer would eat the navigation-bar clearance - // `set_bottom_inset` gives it. let tree = ( top_bar, report_display @@ -254,10 +144,6 @@ impl AndroidAppState for BenchClient { .any(); ui_state.set_root(tree); - // Startup log line (RUST.md's P0 box, "log once at startup ... the - // number of font families found, the default family resolved"): - // what font discovery actually found on this device, before - // anything is drawn. let font = rsc.ui.text.font_diagnostics(); log::info!( "iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \ @@ -310,47 +196,6 @@ impl AndroidAppState for BenchClient { false } - /// Pads the top button row by the status-bar inset -- see `top_bar`'s - /// field comment. Rebuilds the row rather than mutating a stored - /// `Padding` in place, since nothing here holds a handle to one -- - /// but **only when `insets.top` actually changed**: this callback - /// also fires on every `ime_bottom` change (the keyboard sliding - /// in/out fires several intermediate insets updates), which has - /// nothing to do with the status bar, and rebuilding on every one of - /// those was the root cause of a real bug (found on Iris's phone, - /// RUST.md's P0 box): each rebuild drops the old `top_bar` content - /// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s - /// `needs_redraw.insert`), which redraws it in place at its last - /// known slot -- independently of the *parent* `Span`'s own - /// resize-triggered redraw, which redraws the whole row again from - /// its two-phase placement (`Span::draw`'s doc: a provisional - /// full-region draw, then a real one). A `.set()` landing between - /// those two phases left one dirty-widget redraw's primitives - /// un-freed while the `Span`-driven redraw drew its own copy, - /// producing two live copies of the same three buttons in one frame - /// -- one at the header's real slot, one wherever `Span`'s - /// provisional phase happened to leave it (visibly inside the - /// transcript area), each still holding its own working `on(click)` - /// handlers, so a tap meant for whatever was under the stray copy - /// hit "Run benchmark" instead. Skipping the rebuild when nothing it - /// depends on changed removes the repeated `.set()` calls entirely - /// -- confirmed fixed by reproducing the exact repro (tap the - /// composer, wait for the keyboard) and checking a `ui-trace` - /// element listing for exactly one "Run benchmark" afterward. - /// - /// Also two things downstream of the same `ime_bottom` transition: - /// **the keyboard phase's own confirmation signal** (`ime_state`'s - /// doc -- the platform's own answer for whether the IME actually - /// opened or closed, rather than assumed from having called - /// `show_ime`/`hide_ime`), and **the trigger for the keyboard - /// diagnostics capture** (RUST.md's P0 box): the IME resizing the - /// surface is exactly the case a previous commit found wiped text, - /// and Iris needs a way to get a report off the phone even if that - /// (or some other keyboard-triggered regression) is still happening - /// on the build she is holding -- `capture_keyboard_diagnostics` - /// below fires ~500ms after the keyboard becomes visible, once per - /// keyboard opening, and shows its report in a plain overlay view - /// that draws independently of whatever iris itself is doing. fn on_insets_changed( &mut self, rsc: &mut AndroidRsc, @@ -362,12 +207,6 @@ impl AndroidAppState for BenchClient { (self.top_bar)(rsc).set(controls); } - // The composer bar sits directly on whichever of the IME or the - // navigation bar is currently the bottom of usable space -- see - // `crate::ui::composer::Composer::set_bottom_inset`'s doc. - // `ime_bottom` already exceeds the plain nav-bar inset whenever the - // keyboard covers it, so the larger of the two is always the right - // answer without needing to know which is currently showing. if let Some(screen) = &self.screen { screen .composer @@ -407,13 +246,6 @@ impl AndroidAppState for BenchClient { } } -/// How long to wait after the keyboard becomes visible before capturing -/// diagnostics -- long enough that the resize, the reported wipe (if it is -/// still happening) and a couple of frames have all had time to land, per -/// AGENTS.md's "so that operations that finish in milliseconds have states -/// on the way that nothing can observe" reasoning applied the other way: -/// this wants to observe the state *after* the transition settles, not -/// mid-flight. const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500; type Rsc = AndroidRsc; @@ -452,39 +284,8 @@ const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255); /// exposing the unadded builder for a caller to `.pad()` itself, because /// naming that builder's type at each call site is more machinery than a /// top-of-screen padding number is worth. -/// -/// **Backed by an opaque rect the full size of the row, not just the three -/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on -/// build a9232ac): "the header buttons have nothing behind them and -/// overlap the transcript text" -- before this, only each button's own -/// `rect(...)` painted anything, so the gaps between and around them (and -/// the status-bar strip above them) showed whatever was one layer back -/// (`CLEAR_COLOR`, black), and the row's true height was three -/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the -/// density-correct size the transcript below was already using post-P0 -- -/// exactly what reads as "overlap" once the two disagree. Fixed two ways -/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this -/// function), and every size below moved from a bare number (physical -/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit), -/// so the row's reserved height in the outer `Span::DOWN` -/// (`AndroidAppState::new`) matches what is actually painted. -/// The size every label in the header row is drawn at. -/// -/// One constant for all four rather than a number per button, because the -/// whole row has to be sized together. Adding the trace switch made four -/// controls too wide for one row at the size three had used (18), and an -/// earlier pass shrank this constant to 13 to make them fit -- exactly -/// what UI_RULES forbids ("never shrink text to make it fit": a label a -/// different size from its neighbours elsewhere in the app for a reason -/// the reader cannot see). The fix is [`bench_controls`]'s two rows -/// instead, which leaves room to put this back. Whoever adds a fifth -/// control reconsiders the row split, not this number. const HEADER_TEXT: f32 = 18.0; -/// The height of one row of header controls, in dp. `bench_controls` now -/// stacks two of these, so this is the one number to change if a control's -/// own padding ever changes instead of `dp(56)` and `dp(112)` needing to -/// be kept in sync by hand. const HEADER_ROW_HEIGHT_DP: f32 = 56.0; fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { @@ -548,11 +349,6 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { // session fills the 2000-line ring in seconds, so "is it on right // now" is the question somebody has while looking at a log that is // either full of trace or has none. - // - // The visible text carries the state and the accessibility label does - // not, deliberately: the label is also what `run-bench.sh` taps by - // name, and a control that renames itself when pressed is one no - // script can find twice. let tracing = iris::diagnostics::trace_enabled(); let trace_rect = rect(if tracing { Color::rgb(90, 70, 30) @@ -576,12 +372,6 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { .pad(dp(8)) .add(rsc); - // Two rows rather than one: four controls at the restored `HEADER_TEXT` - // no longer fit a 1080px-wide row (that was the shrink this replaces -- - // see the constant's own doc). Grouped by what they act on: the first - // row starts a benchmark and copies its result; the second is the - // diagnostics pane and the switch that decides what it will contain - // next time. let row1 = (run, copy).span(Dir::RIGHT).add(rsc); let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc); let buttons = (row1, row2).span(Dir::DOWN).add(rsc); @@ -607,12 +397,6 @@ impl BenchClient { self.screen = Some(screen); } - /// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this - /// and send it to Iris'." Fills `report_display` (the same TextEdit the - /// benchmark report uses) rather than a separate widget, so the - /// existing "Copy report" button and clipboard path work on whichever - /// text is currently shown -- `last_report` is what `copy_report` reads, - /// so it's set here too rather than adding a second copy path. fn show_diagnostics(&mut self, rsc: &mut Rsc) { let report = self.diagnostics_text(rsc); self.report_display.edit(rsc).set(&report); @@ -621,11 +405,6 @@ impl BenchClient { /// Turns the `iris::input`/`iris::frame` trace on or off, redraws the /// switch that says so, and shows the pane that now reports it. - /// - /// Showing the pane is the point rather than a convenience: this is a - /// control whose whole effect is on what a *later* report says, so - /// putting the state on screen at the moment of the press is the only - /// thing that distinguishes it from a button that did nothing. fn toggle_trace(&mut self, rsc: &mut Rsc) { let on = !iris::diagnostics::trace_enabled(); iris::diagnostics::set_trace(on); @@ -652,10 +431,8 @@ impl BenchClient { Some(renderer) => renderer.diagnostics_report(&font, &frame_report), None => "iris diagnostics: no renderer yet (no surface)".to_string(), }; - // The insets line goes in the pane, not just the log: Iris has no - // logcat on her phone, and "the keyboard does not push the - // composer up" cannot be told from "the listener never fired" - // without it (`AndroidUiState::insets_report`). + // Insets must be visible without adb so a missing callback can be + // distinguished from a callback reporting zero IME height. format!( "{renderer}\n{}\n{}\n{}\n{}", trace_line( @@ -672,40 +449,11 @@ impl BenchClient { ) } - /// The keyboard's own diagnostics capture -- see `on_insets_changed`'s - /// doc comment. **Logged only.** It used to also copy the report to - /// the clipboard unprompted and put it in the shell's overlay view, - /// from when the keyboard-inset callback was not firing at all and a - /// report could not be got off the phone any other way. Both are gone - /// as of 2026-09-06: the callback fires reliably now (edge-to-edge, - /// `MainActivity.java`), and the overlay covered the whole screen on - /// *every* keyboard open with its own Copy/Close buttons underneath - /// the keyboard, so it could not be dismissed -- an interruption for - /// something nobody asked for, over an app you are trying to type - /// into (UI_RULES.md). The named `Diagnostics` button still shows the - /// same text on demand, and `iris surface:`/`iris insets:` (view.rs) - /// carry the lifecycle a `logcat` pull actually needs. fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) { let report = self.diagnostics_text(rsc); log::info!("iris keyboard diagnostics:\n{report}"); } - /// Always copies something, and never depends on `Diagnostics` or - /// `Run benchmark` having been pressed first (docs/IRIS_TODO.md, - /// 2026-09-07 night: "the copy report button seemed impossible to hit - /// until I hit the diagnostics one" -- it was silently declining - /// instead of reporting where it had failed, the UI_RULES failure "a - /// failure is reported where it happened"). With no benchmark run yet, - /// it copies the diagnostics pane's own text instead, with a first - /// line saying so -- `diagnostics_text` needs no prior button press - /// either, so this is never actually empty-handed. - /// The report carries **no copy of the app log** (removed 2026-09-08, - /// Iris: "please remove the app log from the diagnostics. Those can be - /// obtained through dev updater now"). Dev Updater's Runtime tab reads - /// the same ring through `devlog`'s provider, and the diagnostics - /// pane's own `devlog provider:` line names the authority to read it - /// from -- so what is left here is the measurement, not a second copy - /// of something already reachable. fn copy_report(&mut self, rsc: &mut Rsc) { let Some(platform) = &self.platform else { log::info!("iris bench report: no platform handle, can't reach the clipboard"); @@ -725,10 +473,6 @@ impl BenchClient { } } - /// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1), - /// then type, then keyboard, then the report -- run in-process for the - /// same reason `BenchRun.kt`'s own doc gives (no usable system tracing - /// on a real phone, no agent that can drive one). fn start_benchmark(&mut self, rsc: &mut Rsc) { if self.running { log::info!("iris bench report: already running"); @@ -742,11 +486,6 @@ impl BenchClient { let platform = self.platform.clone(); let stream_tail = self.stream_tail.clone(); let ime_state = self.ime_state.clone(); - // What the platform *says*, kept apart from what the run measured - // -- see where the two are resolved below. A phone that varies its - // refresh rate answers with whichever mode it is in when asked, so - // this alone judged a 120Hz run against a 60Hz budget (Iris's - // phone, 2026-09-09). let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz()); let cpu_start = process_cpu_ms(); // Read at the start as well as the end, because the switch is on @@ -804,13 +543,6 @@ impl BenchClient { ctx.update(move |state: &mut BenchClient, rsc| { state.running = false; let now = Instant::now(); - // **The larger of the two, because each can only be wrong - // one way.** The platform under-reports a display that - // varies its rate (60 for a run that sustained 120 on - // Iris's phone), and the sustained rate is a floor -- an - // app that cannot keep up says nothing about the panel. - // Printed together whenever they disagree, so the - // resolution is visible rather than silent. let drawn_hz = state.android_state().frame_report.sustained_frame_hz(); let refresh_hz = match (drawn_hz, platform_hz) { (Some(d), Some(p)) => d.max(p), @@ -934,16 +666,6 @@ where } } -/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away -/// from it (toward older messages) through `Scroll::fling`, then -/// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s -/// convention, which is the finger's: a finger dragged down the screen -/// brings earlier content into view. It was negative here until -/// 2026-09-08, when the transcript's scroll position moved out of the -/// `LazySpan` -- whose anchor offset ran the other way -- and into the -/// `ScrollArea` around it. The two apps' *travel* is directly comparable -/// whichever way the signs run, because both report it as a row index plus -/// a pixel offset rather than a signed distance. async fn run_fling_phase( ctx: &mut iris::task::TaskCtx, redraw: &Arc, @@ -988,9 +710,6 @@ async fn run_fling_phase( } let end = read_anchor_position(ctx, redraw).await; - // Says how the fling was advanced, because that is what changed on - // 2026-09-08 and a report from before then is not comparable: the - // phase used to tick the fling itself at ~60Hz. format!("start={start} outward={outward} end={end} ticked=frame-loop") } @@ -1005,32 +724,11 @@ async fn read_anchor_position( .await } -/// Register the scrolling widget with the frame loop, exactly as a -/// finger's own release does (`crate::ui::Selection::drag`'s -/// `Released` arm) -- `Scrollable::fling` sets a velocity and drives -/// nothing by itself. fn animate_scroll(scroll: iris::prelude::WeakWidget, rsc: &mut Rsc) { let id = scroll.id(); rsc.ui_mut().animate(id); } -/// Waits for the fling started above to settle, or for -/// `FLING_SETTLE_CAP_MS` -- belt-and-suspenders the same way -/// `BenchRun.kt`'s own `waitForSettle` is, since a fling's own -/// spline-decided `duration()` already caps how long it can run. -/// -/// **It observes; it does not drive.** Until 2026-09-08 this loop called -/// `Scrollable::tick_fling` itself every `POLL_MS`, which advanced the -/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame -/// redrew the list at a position it had already drawn, and the benchmark -/// looked distinctly less smooth than the same list under her finger. -/// That is what she reported that day, and it was the rig rather than the -/// renderer: a real fling is ticked once per frame by -/// `UiData::tick_animations`, from the frame callback. So the bench now -/// starts the fling the way a gesture does (`fling` + `UiData::animate`) -/// and polls `is_scrolling` to know when it is over, which makes the -/// phase measure the same path a finger takes. The poll interval is only -/// how often the *question* is asked and has no bearing on the animation. async fn wait_for_fling_settle( ctx: &mut iris::task::TaskCtx, redraw: &Arc, @@ -1050,11 +748,6 @@ async fn wait_for_fling_settle( } } -/// Phase 2, unchanged from v1: pinned to the newest end before streaming -/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then -/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed -/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns -/// `(sent, total)`. async fn run_stream_phase( ctx: &mut iris::task::TaskCtx, redraw: &Arc, @@ -1085,17 +778,10 @@ async fn run_stream_phase( sent += 1; tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await; } - // Lets the last few deltas land and draw before the next phase starts - // -- `BenchRun.kt`'s own closing delay. tokio::time::sleep(Duration::from_millis(300)).await; (sent, total) } -/// Phase 3: focuses the real composer, shows the keyboard, then types -/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s -/// real edit path (`set`, the same call a real keystroke's `onValueChange` -/// makes -- `Composer::build_composer`'s `field`), and deletes it the same -/// way. async fn run_type_phase( ctx: &mut iris::task::TaskCtx, redraw: &Arc, @@ -1114,9 +800,6 @@ async fn run_type_phase( if let Some(p) = platform { p.show_ime(); } - // Lets focus and the keyboard's opening animation land before typing - // starts, so the frames this phase records are the wrap/reflow it is - // measuring, not the keyboard opening -- `BenchRun.kt`'s own delay. tokio::time::sleep(Duration::from_millis(300)).await; let mut typed = String::new(); @@ -1145,13 +828,6 @@ async fn run_type_phase( } } -/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own -/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each -/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather -/// than assumed from the JNI call having returned -- `ImeState`'s doc. -/// "keyboard: could not be shown" if the platform never confirms it even -/// once, per UI_RULES.md ("design the unknown/failed state before the -/// answer's"). async fn run_keyboard_phase( ctx: &mut iris::task::TaskCtx, platform: &Option>, @@ -1198,10 +874,6 @@ async fn run_keyboard_phase( mod tests { use super::TYPE_TEXT; - /// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this - /// is the same string, so it has to match exactly or the two apps' - /// type phases stop typing the same content -- RUST.md's "Benchmark - /// v2" spec is one shared string for both. #[test] fn type_text_is_exactly_600_characters() { assert_eq!(TYPE_TEXT.chars().count(), 600); diff --git a/app-rust/src/android/bench_jni.rs b/app-rust/src/android/bench_jni.rs index 6fac495..a9fa1f5 100644 --- a/app-rust/src/android/bench_jni.rs +++ b/app-rust/src/android/bench_jni.rs @@ -1,24 +1,3 @@ -//! JNI calls the `bench` feature needs that go through the shell's own -//! Java side rather than anything `iris`/`android-view` already wraps: -//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the -//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the -//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for -//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase -//! report's real late-frame budget and `InputMethodManager. -//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None -//! of these are part of `android_view::context`'s own `Context`/ -//! `Resources` wrappers (that file's own `// TODO: more methods?`), so -//! this calls them directly rather than growing that crate's wrapper for -//! calls this crate alone needs. -//! -//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through -//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach -//! whichever thread calls it -- the battery sampler runs on a background -//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls -//! run on. `JavaVM::attach_current_thread` is safe to call from a thread -//! already attached (the `jni` crate detects it and does not double -//! attach), so no caller here needs to know or care which thread it is. - use android_view::jni::{ JNIEnv, JavaVM, objects::{GlobalRef, JObject, JValue}, @@ -69,13 +48,6 @@ impl PlatformHandle { .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 { let mut guard = self.vm.attach_current_thread().ok()?; let env: &mut JNIEnv = &mut guard; @@ -135,14 +107,6 @@ impl PlatformHandle { Some(()) } - /// The display's own refresh rate in Hz (`View::getDisplay()` -> - /// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late - /// frames are judged against *this* device's real budget, not an - /// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames - /// "late" that met their own faster deadline. `None` if the view is - /// not yet attached to a window (`getDisplay` returns `null`) or the - /// platform reports a non-positive rate, which is not a real answer - /// either. pub fn refresh_rate_hz(&self) -> Option { let mut guard = self.vm.attach_current_thread().ok()?; let env: &mut JNIEnv = &mut guard; @@ -167,20 +131,10 @@ impl PlatformHandle { if rate > 0.0 { Some(rate) } else { None } } - /// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's - /// own show, called directly rather than through the focus-driven - /// `pending_show_keyboard` path `android/view.rs` uses for a real tap, - /// since RUST.md's "Benchmark v2" spec asks for this "through the - /// shell's InputMethodManager" independent of focus state. `true` only - /// if the platform itself reports the request succeeded -- whether the - /// IME actually became visible is confirmed separately, from - /// `on_insets_changed`, per UI_RULES.md ("never present an inferred - /// value as a measured one"). pub fn show_ime(&self) -> bool { self.try_toggle_ime(true).unwrap_or(false) } - /// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`. pub fn hide_ime(&self) -> bool { self.try_toggle_ime(false).unwrap_or(false) } diff --git a/app-rust/src/android/devlog.rs b/app-rust/src/android/devlog.rs index ac80199..cc54620 100644 --- a/app-rust/src/android/devlog.rs +++ b/app-rust/src/android/devlog.rs @@ -1,36 +1,11 @@ //! The JNI half of `DevLogProvider`: reading this process's own log ring //! for a `ContentProvider` that Dev Updater queries. -//! -//! **Why**: Iris runs these builds on a phone with no `adb`, and Android -//! forbids one app reading another's `logcat`, so nothing outside this -//! process can recover what it wrote. The app already keeps a bounded copy -//! (`crate::client::log_ring`); this is how the copy leaves the process. Dev -//! Updater is on the same phone, so handing it over needs no tunnel, no -//! token and no second enrolment -- and it is Dev Updater's own contract -//! rather than something invented here, so any app it delivers can do the -//! same (its `README.md`, "An app's own log"). -//! -//! **Everything general stays in `client-core`** (AGENTS.md's sharing -//! rule). What is here is only what Android forces: the JNI boundary and -//! the Java class on the other side of it. -//! -//! Both entry points answer a **flat `String[]`** rather than a row of -//! typed columns. That is the whole of the JNI, and it is one array type -//! instead of three interleaved ones for a payload the provider is about -//! to hand back over binder as a `MatrixCursor` anyway; `DevLogProvider` -//! parses the two numeric fields. Kept flat rather than nested for the -//! same reason -- an array of arrays is four more JNI calls per line. use android_view::jni::JNIEnv; use android_view::jni::objects::{JClass, JObject, JString}; use android_view::jni::sys::{jlong, jobjectArray}; use std::sync::OnceLock; -/// How many `String`s each log line occupies in the flat answer: -/// `seq`, `t_ms`, `level`, `target`, `message`, in that order. The Java -/// side has the same constant, and the two are the one place the shape is -/// written down on each side. -/// /// Gated with its one reader: the tabs demo links no `client-core` and so /// has no ring to lay out, and an ungated constant is a warning in that /// build (`iris-android-app` without `transcript-screen`). @@ -43,21 +18,11 @@ const FIELDS_PER_LINE: usize = 5; /// the same from inside this process otherwise. static AUTHORITY: OnceLock = OnceLock::new(); -/// Where this app's log can be read from, for the diagnostics pane. -/// -/// The provider's own answer rather than one composed from the package -/// name here: what makes the line worth showing is that it names an -/// authority somebody can actually query, and only the provider knows it -/// registered. #[cfg(feature = "bench")] pub fn authority() -> Option<&'static str> { AUTHORITY.get().map(String::as_str) } -/// `DevLogProvider.nativeReady` -- the provider announcing the authority -/// it registered under and the app's private directory, from its own -/// `onCreate`. -/// /// The directory is taken here as well as in /// `MainActivity.nativeSetFilesDir` because **the provider is often the /// only thing running**: once the app has died, Dev Updater's query @@ -76,8 +41,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady( authority: JString, files_dir: JString, ) { - // Before the authority line, so the previous run's death is above the - // line announcing this one rather than buried under it. #[cfg(feature = "transcript-screen")] if let Some(dir) = string_arg(&mut env, &files_dir) { crate::android::app_log::set_crash_dir(std::path::Path::new(&dir)); @@ -91,7 +54,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady( let _ = AUTHORITY.set(authority); } -/// One `String` argument, or `None` for a null or unreadable one. fn string_arg(env: &mut JNIEnv, value: &JString) -> Option { if value.is_null() { return None; @@ -99,18 +61,11 @@ fn string_arg(env: &mut JNIEnv, value: &JString) -> Option { env.get_string(value).ok().map(Into::into) } -/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as -/// three strings. -/// /// `newest_seq` is `-1` for a ring nothing has been written to, which is /// what tells a reader holding a cursor that this process **restarted**: /// the ring is in memory, so a new process starts again at zero and a /// stale cursor would otherwise skip everything silently. /// -/// Exported by name rather than registered, matching this crate's other -/// activity-side natives: the mangled name is the whole of what a class -/// this app owns needs. -/// /// # Safety /// Called by the JVM with the arguments its `native` declaration names. #[unsafe(no_mangle)] @@ -121,9 +76,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus( string_array(&mut env, &status_fields()) } -/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at -/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each. -/// /// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`] /// is, and one definition of the cursor is what keeps the app's own /// uploaded report and this provider describing the same lines. @@ -141,7 +93,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSinc string_array(&mut env, &line_fields(since.max(0) as u64)) } -/// The three status numbers, as the provider's row. #[cfg(feature = "transcript-screen")] fn status_fields() -> Vec { let ring = crate::client::log_ring::process_ring(); @@ -181,8 +132,6 @@ fn line_fields(_since: u64) -> Vec { Vec::new() } -/// A Java `String[]` of those, or a null array if the JVM refused one. -/// /// Null rather than a panic across the JNI boundary: `DevLogProvider` /// reads it as "the provider could not answer" and returns no cursor, /// which Dev Updater already draws as a distinct state. Taking the app diff --git a/app-rust/src/android/enrollment.rs b/app-rust/src/android/enrollment.rs index d9a99b5..78a33a9 100644 --- a/app-rust/src/android/enrollment.rs +++ b/app-rust/src/android/enrollment.rs @@ -1,36 +1,9 @@ -//! Which `ai-server` this app talks to, and how it was told. -//! -//! The parsing, the file and its owner-only mode are -//! `crate::client::config` (`EnrolledServer`/`EnrollmentStore`), shared with -//! the desktop app. What is genuinely this platform's, and all that is -//! here, is the intent plumbing: Android hands an `aiapp://enroll?...` -//! link to `MainActivity`, which passes it and the app's private files -//! directory across JNI (see `lib.rs`'s two exported functions). -//! -//! **Why the app is told at runtime rather than at build time.** The APK -//! is cross-compiled in a VM and run against the server on the host, whose -//! CA and token are not this machine's -- so nothing about the destination -//! can be baked in, and no token or CA may sit in a repo or a delivered -//! artifact either way. The CA arrives with the link (`ca` parameter, -//! `wg_app_link::enroll::ca_param`), which is what makes an APK built -//! anywhere able to pin the server it is pointed at. -//! -//! The files directory is process-wide state, which this project otherwise -//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the -//! first thing that wants the enrollment -- has no parameter it could come -//! in through. Same shape, and the same reason, as -//! `crate::client::log_ring`'s process ring. - #[cfg(not(feature = "bench"))] use crate::client::api::UreqTransport; use crate::client::config::{EnrolledServer, EnrollmentStore}; use std::path::PathBuf; use std::sync::OnceLock; -/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds -/// the view. Set once per process; a second call with a different path is -/// a programmer error rather than something to recover from, and a second -/// call with the same one is what a re-created activity does. static FILES_DIR: OnceLock = OnceLock::new(); pub fn set_files_dir(dir: PathBuf) { @@ -42,17 +15,12 @@ pub fn set_files_dir(dir: PathBuf) { } } -/// `None` before `MainActivity` has handed the directory over -- which is -/// **not** the same as "not enrolled", and is why [`status`] has a state -/// for it (UI_RULES: design the unknown state first). fn store() -> Option { FILES_DIR.get().map(EnrollmentStore::new) } -/// What this app has been told, or why it has not been. pub enum Status { Enrolled(EnrolledServer), - /// Nothing has been enrolled yet: the ordinary first-run state. NotEnrolled, /// The question could not be answered -- the activity never handed a /// files directory over, or the file is there and unreadable. Kept @@ -75,11 +43,6 @@ pub fn status() -> Status { /// One line for the diagnostics pane. The three states read differently on /// purpose: "not enrolled" says what to do about it, and "couldn't tell" /// must not be mistaken for it. -/// -/// Only the bench build has a pane to put this in -- same gate, and the -/// same reason, as `app_log::diagnostics_line`. The transcript build says -/// the same things where they matter to it, in the message -/// [`transport`]'s error becomes on screen. #[cfg(feature = "bench")] pub fn status_line() -> String { match status() { @@ -89,13 +52,6 @@ pub fn status_line() -> String { } } -/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever -/// was enrolled before -- opening a link is how somebody says "this server -/// now", including after the old one's token was rotated. -/// -/// The returned `Err` is the message for a person: this is called from a -/// tap on a link, and a link that did nothing with nothing said is the -/// failure the UI rules are most insistent about. pub fn apply_link(uri: &str) -> Result { let server = EnrolledServer::parse_link(uri)?; let store = store().ok_or("the app has no files directory to save an enrollment in")?; @@ -105,8 +61,6 @@ pub fn apply_link(uri: &str) -> Result { Ok(server) } -/// A transport for the enrolled server, pinning the CA the link carried. -/// /// Gated to the same builds as `transcript_client`, its only caller: the /// bench build opens a checked-in fixture and reaches no server, so /// compiling this into it would be a warning about dead code that is diff --git a/app-rust/src/android/mod.rs b/app-rust/src/android/mod.rs index 24f1085..ce81b7f 100644 --- a/app-rust/src/android/mod.rs +++ b/app-rust/src/android/mod.rs @@ -1,41 +1,3 @@ -//! 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. -//! `crate::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 -- -//! `crate::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::{ @@ -61,14 +23,7 @@ mod app_log; mod bench_client; #[cfg(feature = "bench")] mod bench_jni; -/// This app's log ring, handed to Dev Updater on the phone through a -/// `ContentProvider`. Declared in every build for the reason the module -/// gives: the Java class is in the manifest either way, and a `native` -/// method the library does not export fails the class load. mod devlog; -/// Which server this app talks to, told to it at runtime by an -/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain -/// tabs demo makes no network call and has nothing to enrol against. #[cfg(feature = "transcript-screen")] mod enrollment; #[cfg(all(feature = "transcript-screen", not(feature = "bench")))] @@ -105,12 +60,6 @@ impl AndroidAppState for Client { } fn back_pressed(&mut self, _rsc: &mut AndroidRsc, _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 } } @@ -163,11 +112,6 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> /// and hands out one function pointer, whereas these two are this app's /// own activity and the mangled name is the whole of what is needed. /// -/// Declared in every build, including the tabs demo that has no -/// `client-core` to store anything -- a `native` method Java declares and -/// the library does not export is an `UnsatisfiedLinkError` when the class -/// loads, which would take down a build that merely shares the activity. -/// /// # Safety /// Called by the JVM with the arguments its `native` declaration names. #[unsafe(no_mangle)] @@ -187,14 +131,6 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir log::debug!("iris app: files directory is {dir}"); } -/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the -/// VIEW intent that started or resumed the activity. -/// -/// Logged either way rather than answered: the activity has nothing to do -/// with the result, and where the enrollment shows up is the diagnostics -/// pane (`enrollment::status_line`), which reads the stored answer rather -/// than being told it. -/// /// # Safety /// Called by the JVM with the arguments its `native` declaration names. #[unsafe(no_mangle)] @@ -217,9 +153,6 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll( log::warn!("iris app: {uri} arrived, but this build has no server to enrol with"); } -/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one -- -/// neither is worth taking the app down for, and both are logged where -/// they happen. fn jstring(env: &mut JNIEnv, value: JString) -> Option { if value.is_null() { log::warn!("iris app: the activity passed a null string across JNI"); diff --git a/app-rust/src/android/transcript_client.rs b/app-rust/src/android/transcript_client.rs index a550923..85973de 100644 --- a/app-rust/src/android/transcript_client.rs +++ b/app-rust/src/android/transcript_client.rs @@ -1,42 +1,3 @@ -//! 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 here -- the first session `ApiClient::fetch_sessions` -//! returns is opened automatically, since 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. -//! -//! Which server it opens it against is no longer baked in: it is the -//! enrollment an `aiapp://enroll` link left behind (`crate::android::enrollment`, -//! and `desktop-app`'s identical `--link`), because an APK -//! cross-compiled here cannot pin the CA of a server on the host. -//! -//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** -- -//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from -//! `crate::client::transcript_fold`, a `generation` counter guarding against -//! a stale background response. 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. -//! -//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate -//! showed why it mattered -- 20 events/second means 20 rebuilds/second of -//! a ~3,200-row transcript otherwise): `apply_event` calls -//! `crate::ui::TranscriptScreen::apply` with the item list before and -//! after `fold_event`, which updates only the row(s) that actually -//! changed (almost always just the one open assistant message) instead of -//! refolding and rebuilding every row. `rebuild_transcript` still runs -//! the whole widget tree once, for the opening page and for `apply`'s own -//! rare regroup fallback. - use crate::client::api::{ApiClient, UreqTransport}; use crate::client::event_stream::{StreamItem, follow_session_events}; use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs}; @@ -55,20 +16,8 @@ pub struct TranscriptClient { /// by rebuilding the session list beside it. content: WeakWidget, screen: Option, - /// 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, - /// 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, - /// 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, } @@ -81,14 +30,6 @@ impl HasAndroidUiState for TranscriptClient { } } -/// Builds one `UreqTransport` from the stored enrollment. 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`. -/// -/// Read afresh each time rather than held: opening a new enrolment link -/// while the app is running is how somebody points it at another server, -/// and a cached transport would keep talking to the old one. fn build_transport() -> Result { crate::android::enrollment::transport() } @@ -102,15 +43,6 @@ fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { .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) -> WeakWidget { type Rsc = AndroidRsc; let report_rect = rect(Color::rgb(50, 50, 60)) @@ -224,10 +156,6 @@ impl TranscriptClient { }); } - /// 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, session_id: String) { let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; self.items.clear(); @@ -254,17 +182,9 @@ impl TranscriptClient { }; 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, 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 `crate::client::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() @@ -295,12 +215,6 @@ impl TranscriptClient { 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, @@ -329,10 +243,6 @@ impl TranscriptClient { }); } - /// 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) { let in_progress = self .screen @@ -365,12 +275,7 @@ impl TranscriptClient { let old_items = self.items.clone(); self.items = fold_event(&self.items, event); match &self.screen { - // The common path: update only the row(s) that actually - // changed instead of refolding and rebuilding all ~3,200 of - // them per event (RUST.md's P0 streaming-phase fix). Some(screen) => screen.apply(rsc, &old_items, &self.items), - // No screen yet (the opening page hasn't landed) -- build one - // the ordinary way once it has. None => self.rebuild_transcript(rsc), } } diff --git a/app-rust/src/bin_desktop.rs b/app-rust/src/bin_desktop.rs index 9bb869f..16b47f7 100644 --- a/app-rust/src/bin_desktop.rs +++ b/app-rust/src/bin_desktop.rs @@ -1,32 +1,6 @@ -//! RUST.md's E4: the transcript screen (`transcript-ui`, I5) in a real -//! winit window on the desktop, with a session list beside it, talking to -//! a real `ai-server` over `client-core`'s REST + SSE clients. See -//! `app.rs`'s module doc for the widget tree and the event flow. -//! -//! Usage: -//! -//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B' -//! desktop-app # after the first run above -//! desktop-app --ca /path/to/ca.pem # a link that carries no CA -//! -//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a -//! phone would scan as a QR (decided 2026-09-05) -- pasted rather -//! than scanned, since a desktop has no camera to assume. It is parsed and -//! saved once; later runs read it back and `--link` is only needed again -//! to enrol against a different server. -//! -//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which -//! `ai-server` now always includes) and is saved with it. `--ca` is the -//! override for a link that carries none, and names the same -//! `certs/ca.pem` a `curl --cacert` call uses. - use ai_app::desktop::{app, startup}; fn main() { - // Validated once here so a bad `--ca`/`--link` is reported on stderr - // before any window opens; `Client::new` calls this same function - // again once the window exists, so this first call is a fast-fail - // rather than the only place the values come from. if let Err(e) = startup::load_startup_config() { eprintln!("desktop-app: {e}"); std::process::exit(2); diff --git a/app-rust/src/client/ansi.rs b/app-rust/src/client/ansi.rs index b810849..c23e806 100644 --- a/app-rust/src/client/ansi.rs +++ b/app-rust/src/client/ansi.rs @@ -1,25 +1,3 @@ -//! What a tool printed, with its terminal styling applied and everything -//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the -//! Kotlin version builds a Compose `AnnotatedString`, which does not exist -//! here, so a [`StyledText`] of plain text plus non-overlapping -//! `(Range, Style)` spans stands in for it -- a future UI layer maps -//! [`Style`] onto whatever it draws with. -//! -//! Bash output arrives exactly as the program wrote it, escape sequences -//! included, and drawn verbatim those are line noise in the middle of the -//! thing being read. Stripping them all would be the other half-answer -- -//! colour is often the whole of what a diff or a test run is saying. -//! -//! So the sequences that decide how text *looks* become spans, and every -//! other one is dropped rather than shown: the rest move a cursor around a -//! grid this is not, and "go to column 40" has no meaning in a scrolling -//! document. -//! -//! A carriage return is honoured the way a terminal honours it: what was -//! written since the last line break is thrown away and the line starts -//! again. That is what makes a progress bar show its final state rather -//! than every state it passed through. - use std::ops::Range; /// An RGB colour, the same shape wherever this crate names one -- no alpha, @@ -38,18 +16,10 @@ impl Rgb { } } -/// The sixteen colours a terminal program names, and the two it assumes. -/// -/// Its own palette rather than the syntax one: a program that prints in red -/// has chosen red, where a highlighter's colours are this app's reading of -/// somebody else's code. #[derive(Debug, Clone)] pub struct AnsiPalette { - /// Indexes 0-7, then 8-15 bright, in the terminal's own order. pub colours: [Rgb; 16], - /// What uncoloured text is, needed only where a style has to state a colour. pub foreground: Rgb, - /// What the text sits on, needed for reverse video. pub background: Rgb, } @@ -67,8 +37,6 @@ pub struct Style { pub strikethrough: bool, } -/// Plain text plus the non-overlapping, ordered spans that style parts of it -/// -- this crate's stand-in for Compose's `AnnotatedString`. #[derive(Debug, Clone, PartialEq, Default)] pub struct StyledText { pub text: String, @@ -87,11 +55,7 @@ impl StyledText { const ESC: char = '\u{1B}'; const BELL: char = '\u{7}'; -/// [text] with its terminal styling applied and everything else taken out; -/// see the module doc. pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText { - // The common case by a long way -- nothing to do, and nothing allocated - // to find that out. if !text.contains(ESC) && !text.contains('\r') { return StyledText::plain(text.to_string()); } @@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText { } }); } else if c == '\r' && chars.get(at + 1) != Some(&'\n') { - // A bare carriage return rewrites the line. One before a newline - // is the other half of a Windows line ending: it rewrites - // nothing, and it is dropped rather than kept, since that pair - // is one line break. flush(&mut plain, sgr, &mut runs); drop_line(&mut runs); at += 1; } else if c == '\r' { at += 1; } else if c >= ' ' || c == '\n' || c == '\t' { - // Everything printable, plus the two control characters that are - // layout rather than terminal commands. A stray bell or - // backspace goes for the same reason a cursor move does. plain.push(c); at += 1; } else { @@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText { StyledText { text: out, spans } } -/// Throws away everything written since the last line break, as a carriage -/// return does. fn drop_line(runs: &mut Vec<(String, Option