iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent e9a6562dc6
commit 6d5a231f5c
100 files changed
+924 -3295

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
android-project/.gradle/
android-project/build/
android-project/app/build/
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
# Gradle build -- see RUST.md's I2 for the exact command.
android-project/app/src/main/jniLibs/
target/
Cargo.lock.orig
+5202
View File
File diff suppressed because it is too large. Load diff
+154
View File
@@ -0,0 +1,154 @@
# 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).
[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.
[lib]
name = "ai_app"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "ai-app-desktop"
path = "src/bin_desktop.rs"
required-features = ["screens"]
[[example]]
name = "transcript"
required-features = ["screens"]
[[example]]
name = "phone"
required-features = ["fixture"]
[dependencies]
# 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.
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.
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.
[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`.
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`.
[profile.android-release]
inherits = "release"
panic = "abort"
strip = true
lto = "fat"
codegen-units = 1
opt-level = "s"
[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.
[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"]
[[test]]
name = "fence_fling"
required-features = ["fixture"]
[[test]]
name = "gesture_cancel"
required-features = ["fixture"]
[[test]]
name = "input_log_roundtrip"
required-features = ["fixture"]
[[test]]
name = "phone_screen"
required-features = ["fixture"]
[[test]]
name = "top_edge"
required-features = ["fixture"]
+90
View File
@@ -0,0 +1,90 @@
plugins {
id("com.android.application")
}
// The Rust side (this directory's Cargo.toml) is built separately with
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
// build command.
android {
namespace = "dev.iris.android.demo"
compileSdk = 37
defaultConfig {
applicationId = "dev.iris.android.demo"
// 29, not 26: `iris::android::view`'s touch handler dates each
// sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// 37, matching `compileSdk` and the Compose app in `app/` -- which
// is the one part of this that is measured rather than reasoned:
// that app targets 37 and its keyboard does push the transcript up
// on Iris's phone, and this one targeted 34 and does not
// (2026-09-07). The emulator here is API 36 and the push-up works
// there at either target, so the target is the only difference the
// two devices do not share.
//
// The mechanism, stated as the reading it is: below targetSdk 35
// a window keeps the legacy behaviour, where `adjustResize` shrinks
// the window for the IME and `getInsets(ime()).bottom` therefore
// measures the overlap with an already-shrunk window -- zero, with
// nothing left to push up. `MainActivity`'s
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
// 36 it still takes; Android 16 deprecated it and Android 17 is
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
// the app is handed the real overlap without relying on a
// deprecated call. If the phone still reports `ime_bottom=0` with
// a nonzero `dispatches` in the Diagnostics pane, this reading was
// wrong and the `WindowInsetsAnimation.Callback` in
// `MainActivity` is the other half to look at.
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
// A release build must be signed, and the key is per machine rather than per repo -- same
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
// environment; without it a release build here is unsigned, which is fine for everything
// except installing.
def keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
release {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
debug {
}
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
// itself is built separately with `cargo ndk --release --features "transcript-screen
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
// Gradle here only packages and signs whatever is already there, the same division as the
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
// installable beside a debug build of the tabs demo rather than replacing it.
release {
applicationIdSuffix ".bench"
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
no network call and never noticed this was missing. Absent,
UreqTransport::new's connect failed with EPERM (Operation not
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
server would give: a seccomp-level socket denial reads nothing
like a network problem, which is what made it worth a comment. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="iris android-view demo"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- The enrollment link Dev Updater's Enroll button opens
(what `ai-server` mints), the same one the Compose app
in `app/` registers: which app answers it is the phone
owner's choice at the moment of the tap, and both being
offered is the intended behaviour rather than a clash.
BROWSABLE so a link tapped in another app reaches here,
and `android:host` so this app is not offered for every
aiapp:// URI a future route invents. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
</activity>
<!-- This app's own recent log, for Dev Updater to read on the
phone. Iris runs these builds with no adb, and Android
forbids one app reading another's logcat, so this is the
only way a log::info! here reaches her. The shape is Dev
Updater's contract (its README.md, "An app's own log"), not
something invented for this app.
The authority carries ${applicationId}, so the bench package
and the ordinary one each get their own and neither can read
the other's log. Exported, because the whole point is
another app reading it, and guarded by a permission Dev
Updater declares at protectionLevel="normal" (a signature
permission is not available: the two apps are signed with
different locally generated keys). Read-only: insert,
update and delete throw. -->
<provider
android:name=".DevLogProvider"
android:authorities="${applicationId}.devlog"
android:exported="true"
android:readPermission="dev.updater.permission.READ_DEVLOG" />
</application>
</manifest>
@@ -0,0 +1,193 @@
package dev.iris.android.demo;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
/**
* This app's own recent log, exposed on the device.
*
* Iris runs these builds on a phone with no {@code adb}, and Android
* forbids one app reading another's {@code logcat} -- so nothing outside
* this process can recover what it wrote. The process already keeps a
* bounded copy of its log (Rust: {@code client_core::log_ring}); this
* hands it to Dev Updater, which is on the same phone, so it needs no
* tunnel, no token and no second enrolment.
*
* <p>The shape is <em>Dev Updater's contract</em>, not something invented
* here -- see that project's {@code README.md}, "An app's own log". Any
* app it delivers can implement the same and get the same Runtime tab.
* Two paths:
*
* <ul>
* <li>{@code lines?since=<seq>} -- every held line with a sequence at or
* after {@code since}, oldest first.
* <li>{@code status} -- one row: how many lines are held, how many the
* ring's own bound has dropped, and the newest sequence ({@code -1}
* for a log nothing has been written to, which is also how a reader
* notices this process restarted).
* </ul>
*
* <p>Read-only: there is nothing here for anyone else to change, so the
* three writing methods throw rather than silently doing nothing.
*
* <p>The authority is {@code <applicationId>.devlog}, filled in from
* Gradle so the bench build and the ordinary one each get their own and
* neither can read the other's. Read access is guarded by
* {@code dev.updater.permission.READ_DEVLOG}, declared in the manifest.
*
* <p>No {@code notifyChange}: the ring is filled by a {@code log::Log}
* backend on whatever thread logged, and giving that a way to reach a
* provider would mean plumbing a callback through {@code client-core} for
* every platform. Dev Updater polls while its tab is open, which its
* contract says it does precisely so implementing this stays cheap.
*/
public final class DevLogProvider extends ContentProvider {
static {
// The provider is created before any activity, so it cannot rely
// on MainActivity's own load. Loading twice is a no-op.
System.loadLibrary("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"};
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
private static final int LINES = 1;
private static final int STATUS = 2;
private UriMatcher matcher;
/** Every held line, {@link #FIELDS_PER_LINE} strings each, oldest first. */
private static native String[] nativeLinesSince(long since);
/** Three strings: held, dropped, newest sequence. */
private static native String[] nativeStatus();
/**
* Tells the Rust side which authority this build registered under, so
* the diagnostics pane can name somewhere a reader can actually query
* -- and so "declared but never created" is a state it can say. Only
* the provider knows it was instantiated; Android creates one lazily.
*
* <p>The files directory goes with it because <em>this is usually the
* only thing running</em>: after the app has died, Dev Updater's query
* starts the process for the provider alone, with no activity, so
* {@code MainActivity.nativeSetFilesDir} is never called and the line
* the panic hook left on disk is never replayed into the ring. That is
* exactly the run whose log somebody wants.
*/
private static native void nativeReady(String authority, String filesDir);
@Override
public boolean onCreate() {
// The authority is not a constant here: it is derived from this
// build's applicationId, so the bench package and the ordinary one
// do not share one. Read back from the manifest rather than
// recomposed, so there is one answer to what it is.
String authority = getContext().getPackageName() + ".devlog";
matcher = new UriMatcher(UriMatcher.NO_MATCH);
matcher.addURI(authority, "lines", LINES);
matcher.addURI(authority, "status", STATUS);
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
return true;
}
@Override
public Cursor query(
Uri uri,
String[] projection,
String selection,
String[] selectionArgs,
String sortOrder) {
switch (matcher.match(uri)) {
case LINES:
return lines(sinceOf(uri));
case STATUS:
return status();
default:
// Null rather than an exception: an unknown path is a
// reader asking for something this app does not have, and
// the contract's own answer for that is no cursor.
return null;
}
}
/**
* {@code ?since=} as a number, or 0 for a reader starting from the
* beginning. A value that is not a number is treated as 0 rather than
* refused -- what a caller wants from a malformed cursor is the log,
* not a stack trace about the query string.
*/
private static long sinceOf(Uri uri) {
String since = uri.getQueryParameter("since");
if (since == null) {
return 0;
}
try {
return Long.parseLong(since);
} catch (NumberFormatException ignored) {
return 0;
}
}
private static Cursor lines(long since) {
String[] fields = nativeLinesSince(since);
if (fields == null) {
return null;
}
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
cursor.addRow(
new Object[] {
Long.parseLong(fields[at]),
Long.parseLong(fields[at + 1]),
fields[at + 2],
fields[at + 3],
fields[at + 4],
});
}
return cursor;
}
private static Cursor status() {
String[] fields = nativeStatus();
if (fields == null || fields.length != STATUS_COLUMNS.length) {
return null;
}
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
cursor.addRow(
new Object[] {
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
});
return cursor;
}
@Override
public String getType(Uri uri) {
// A MIME type is for something meant to be handed to another app
// as data; these rows are read by one reader that knows the
// columns. Saying nothing is the honest answer, not a gap.
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
}
@@ -0,0 +1,156 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView;
/**
* android-view's abstract base plus the two native methods it has no hook
* for: window insets and unregistering this view's entry in
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
* comment for why those could not ride along on an existing android-view
* callback the way the back gesture does.
*/
public final class IrisView extends RustView {
@Override
protected native long newViewPeer(Context context);
native void applyWindowInsetsNative(
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
native void unregisterInsetsNative(long peer);
public IrisView(Context context) {
super(context);
}
void applyWindowInsets(
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
}
@Override
protected void onDetachedFromWindow() {
unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow();
}
/**
* Called from the Rust side (iris/src/android/view.rs's
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
* drawing -- an ordinary instance method rather than a `native` one,
* since this call is Rust reaching into Java rather than the other
* direction. Replaces the whole activity content with plain,
* selectable, scrollable text rather than leaving the last frame (or a
* blank surface) on screen with no way to report what happened:
* UI_RULES.md's "a failure is reported where it happened, and says
* what to do next." No dialog and no styling beyond what is needed to
* read and copy the text -- this path exists for exactly the crash it
* replaces, so it must not depend on anything that could itself fail
* to render.
*/
void showRendererError(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setGravity(Gravity.TOP | Gravity.START);
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
text.setPadding(pad, pad, pad, pad);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
activity.setContentView(scroll);
}
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
/**
* The bench build's keyboard diagnostics capture
* (`bench_client.rs`'s `on_insets_changed` /
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
* `PlatformHandle::show_diagnostics_overlay`): unlike
* `showRendererError` above, this adds a panel *over* this view
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
* running) rather than replacing the activity's content, and gives it
* a Copy button and a Close that removes the panel -- so it draws
* (and can be read) whether or not iris itself is still putting
* anything on screen, without abandoning the session that produced
* it. Runs on the UI thread regardless of which thread calls it,
* since the call comes from a background task (a delayed capture
* after the keyboard opens), and touching the view tree off the UI
* thread is undefined.
*/
void showDiagnosticsOverlay(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
activity.runOnUiThread(() -> {
ViewGroup parent = (ViewGroup) getParent();
if (parent == null) {
return;
}
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
if (existing != null) {
parent.removeView(existing);
}
float density = activity.getResources().getDisplayMetrics().density;
int pad = (int) (16 * density);
LinearLayout overlay = new LinearLayout(activity);
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
overlay.setOrientation(LinearLayout.VERTICAL);
overlay.setBackgroundColor(0xEE000000);
overlay.setPadding(pad, pad, pad, pad);
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setTextColor(0xFFFFFFFF);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
overlay.addView(scroll, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
LinearLayout buttonRow = new LinearLayout(activity);
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
buttonRow.setPadding(0, pad, 0, 0);
Button copy = new Button(activity);
copy.setText("Copy");
copy.setOnClickListener(v -> {
ClipboardManager clipboard =
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null) {
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
}
});
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> parent.removeView(overlay));
buttonRow.addView(copy);
buttonRow.addView(close);
overlay.addView(buttonRow);
parent.addView(overlay, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
});
}
}
@@ -0,0 +1,189 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.widget.FrameLayout;
import java.util.List;
/**
* 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);
view.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
view.setFocusable(true);
view.setFocusableInTouchMode(true);
FrameLayout layout = new FrameLayout(this);
layout.addView(view);
setContentView(layout);
view.requestFocus();
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
// `logcat` showed the platform's own IME open/resize happening
// while `setOnApplyWindowInsetsListener` fired only once, at
// attach, and never again for a pure keyboard toggle -- a plain
// (non-edge-to-edge) window is only guaranteed that one initial
// dispatch; `adjustResize` handling the IME entirely by resizing
// the window is not itself a trigger for a fresh one. Opting into
// edge-to-edge (a platform call, API 30+, no new dependency) is
// what makes the system redeliver insets on every change,
// including the ones this activity actually cares about --
// `getSystemWindowInset*` below is unaffected by this (it has
// always reported the raw system-bar/IME overlap regardless of
// who consumes it), so the on-screen bars and the padding Rust
// already derives from those four numbers are unchanged; only the
// callback's firing became reliable.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
}
// **The keyboard's height arrives twice, over two different
// paths, and the phone needs the second one** (Iris, 2026-09-07:
// the emulator pushed the composer up and her Pixel did not).
// `setOnApplyWindowInsetsListener` is the platform's *settled*
// answer; `WindowInsetsAnimation.Callback` is the running one, and
// an IME that animates in delivers every intermediate height
// through the callback with the static dispatch arriving only at
// the ends -- on some devices only at `onEnd`. Registering both
// means neither device depends on the other's timing, and it is
// also what makes the push-up *animate* with the keyboard rather
// than jump when it lands.
//
// The two do not disagree, because they are the same call with the
// same numbers read out of whichever `WindowInsets` is current.
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
// nothing keeps the ordinary dispatch running underneath.
// `onEnd` re-reads the root's insets rather than trusting the last
// `onProgress`: an animation interrupted mid-flight never delivers
// its final frame, which is exactly the fault the Compose app hit
// (AGENTS.md, "the composer can get stuck floating above the
// bottom of the screen").
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@Override
public WindowInsets onProgress(
WindowInsets insets, List<WindowInsetsAnimation> running) {
sendInsets(view, insets);
return insets;
}
@Override
public void onEnd(WindowInsetsAnimation animation) {
WindowInsets settled = view.getRootWindowInsets();
if (settled != null) {
sendInsets(view, settled);
}
}
});
}
view.setOnApplyWindowInsetsListener((v, insets) -> {
sendInsets((IrisView) v, insets);
return insets;
});
}
/**
* A link that arrives while the activity is already up. `singleTop` is
* not set, so this is the resumed case only -- the fresh-launch case
* goes through `onCreate`'s `getIntent`. `setIntent` so a later
* `getIntent` reports the one actually being acted on rather than the
* one this activity started with.
*/
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleEnrollmentIntent(intent);
}
/**
* Hands a VIEW intent's URI to the Rust side, which decides whether it
* is an enrollment link -- the scheme is checked here only so a launch
* intent (which carries no data) costs nothing.
*/
private static void handleEnrollmentIntent(Intent intent) {
if (intent == null) {
return;
}
Uri data = intent.getData();
if (data != null) {
nativeEnroll(data.toString());
}
}
/** Read one `WindowInsets` and hand it to the Rust side. The only
* place that reads these fields, so the static dispatch and the
* animation callback above cannot come to report different things. */
private static void sendInsets(IrisView view, WindowInsets insets) {
int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom();
// **Two separate answers, because they are separate questions**
// (Iris's phone, 2026-09-06: "message box does not push up the
// scroll area"). `isVisible(ime())` says whether the keyboard is
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
// pass sent the boolean *as* the height (0 or 1) because under
// plain `adjustResize` the window shrinks to make room and the ime
// inset therefore measures a zero overlap by construction -- true
// then, and no longer true now that this is an edge-to-edge window
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
// above for the devices below that), which is exactly the case
// where the system stops resizing and hands the app the real
// overlap instead. Sending 1 for it left the Rust side padding the
// composer by one physical pixel, so the keyboard covered the bar
// and the transcript alike.
//
// The visibility is still sent in its own right rather than
// inferred from `height > 0`: the two disagree during the
// keyboard's slide-in and -out (visible, height still climbing),
// and "is the IME up" drives the bench's own state machine
// (`bench_client.rs`'s `ime_state`) where a half-open frame
// reading as "closed" is a miscount.
int imeBottom = 0;
int imeVisible = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
}
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
}
}
@@ -0,0 +1,153 @@
package org.linebender.android.rustview;
import android.os.Bundle;
import android.os.Handler;
import android.view.KeyEvent;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputContentInfo;
class RustInputConnection implements InputConnection {
private final RustView mView;
RustInputConnection(RustView view) {
mView = view;
}
private long getViewPeer() {
return mView.mViewPeer;
}
@Override
public CharSequence getTextBeforeCursor(int n, int flags) {
return mView.getTextBeforeCursorNative(getViewPeer(), n);
}
@Override
public CharSequence getTextAfterCursor(int n, int flags) {
return mView.getTextAfterCursorNative(getViewPeer(), n);
}
@Override
public CharSequence getSelectedText(int flags) {
return mView.getSelectedTextNative(getViewPeer());
}
@Override
public int getCursorCapsMode(int reqModes) {
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
}
@Override
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
return null;
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
}
@Override
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
}
@Override
public boolean setComposingRegion(int start, int end) {
return mView.setComposingRegionNative(getViewPeer(), start, end);
}
@Override
public boolean finishComposingText() {
return mView.finishComposingTextNative(getViewPeer());
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
}
@Override
public boolean commitCompletion(CompletionInfo text) {
return false;
}
@Override
public boolean commitCorrection(CorrectionInfo correctionInfo) {
return false;
}
@Override
public boolean setSelection(int start, int end) {
return mView.setSelectionNative(getViewPeer(), start, end);
}
@Override
public boolean performEditorAction(int editorAction) {
return mView.performEditorActionNative(getViewPeer(), editorAction);
}
@Override
public boolean performContextMenuAction(int id) {
return mView.performContextMenuActionNative(getViewPeer(), id);
}
@Override
public boolean beginBatchEdit() {
return mView.beginBatchEditNative(getViewPeer());
}
@Override
public boolean endBatchEdit() {
return mView.endBatchEditNative(getViewPeer());
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
}
@Override
public boolean clearMetaKeyStates(int states) {
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
}
@Override
public boolean reportFullscreenMode(boolean enabled) {
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
}
@Override
public boolean performPrivateCommand(String action, Bundle data) {
return false;
}
@Override
public boolean requestCursorUpdates(int cursorUpdateMode) {
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
}
@Override
public Handler getHandler() {
return null;
}
@Override
public void closeConnection() {
mView.closeInputConnectionNative(getViewPeer());
}
@Override
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
return false;
}
}
@@ -0,0 +1,291 @@
package org.linebender.android.rustview;
import android.content.Context;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.Choreographer;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeProvider;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
public abstract class RustView extends SurfaceView
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
// with one deliberate change: `protected` rather than package-private, so a
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
// it to the window-insets native call android-view itself has no hook for --
// see iris/src/android/insets.rs's doc comment for why that call exists at
// all. No other line differs from upstream.
protected final long mViewPeer;
final InputMethodManager mInputMethodManager;
protected abstract long newViewPeer(Context context);
public RustView(Context context) {
super(context);
mViewPeer = newViewPeer(context);
getHolder().addCallback(this);
mInputMethodManager =
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
}
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
if (result != null) {
setMeasuredDimension(result[0], result[1]);
} else {
super.onMeasure(widthSpec, heightSpec);
}
}
private native void onLayoutNative(
long peer, boolean changed, int left, int top, int right, int bottom);
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
super.onLayout(changed, left, top, right, bottom);
}
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
super.onSizeChanged(w, h, oldw, oldh);
}
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
}
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
}
private native boolean onTrackballEventNative(long peer, MotionEvent event);
@Override
public boolean onTrackballEvent(MotionEvent event) {
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
}
private native boolean onTouchEventNative(long peer, MotionEvent event);
@Override
public boolean onTouchEvent(MotionEvent event) {
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
}
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
}
private native boolean onHoverEventNative(long peer, MotionEvent event);
@Override
public boolean onHoverEvent(MotionEvent event) {
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
}
private native void onFocusChangedNative(
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
}
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
}
private native void onAttachedToWindowNative(long peer);
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
onAttachedToWindowNative(mViewPeer);
}
private native void onDetachedFromWindowNative(long peer);
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
onDetachedFromWindowNative(mViewPeer);
}
private native void onWindowVisibilityChangedNative(long peer, int visibility);
@Override
protected void onWindowVisibilityChanged(int visibility) {
super.onWindowVisibilityChanged(visibility);
onWindowVisibilityChangedNative(mViewPeer, visibility);
}
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
@Override
public void surfaceCreated(SurfaceHolder holder) {
surfaceCreatedNative(mViewPeer, holder);
}
private native void surfaceChangedNative(
long peer, SurfaceHolder holder, int format, int width, int height);
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
surfaceChangedNative(mViewPeer, holder, format, width, height);
}
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
surfaceDestroyedNative(mViewPeer, holder);
}
void postFrameCallback() {
Choreographer c = Choreographer.getInstance();
c.removeFrameCallback(this);
c.postFrameCallback(this);
}
void removeFrameCallback() {
Choreographer.getInstance().removeFrameCallback(this);
}
private native void doFrameNative(long peer, long frameTimeNanos);
@Override
public void doFrame(long frameTimeNanos) {
doFrameNative(mViewPeer, frameTimeNanos);
}
private native void delayedCallbackNative(long peer);
private final Runnable mDelayedCallback =
new Runnable() {
@Override
public void run() {
delayedCallbackNative(mViewPeer);
}
};
boolean postDelayed(long delayMillis) {
return postDelayed(mDelayedCallback, delayMillis);
}
boolean removeDelayedCallbacks() {
return removeCallbacks(mDelayedCallback);
}
private native boolean hasAccessibilityNodeProviderNative(long peer);
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
long peer, int virtualViewId);
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
private native boolean performAccessibilityActionNative(
long peer, int virtualViewId, int action, Bundle arguments);
@Override
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
return super.getAccessibilityNodeProvider();
}
return new AccessibilityNodeProvider() {
@Override
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
}
@Override
public AccessibilityNodeInfo findFocus(int focusType) {
return accessibilityFindFocusNative(mViewPeer, focusType);
}
@Override
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
return performAccessibilityActionNative(
mViewPeer, virtualViewId, action, arguments);
}
};
}
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
return null;
}
return new RustInputConnection(this);
}
native String getTextBeforeCursorNative(long peer, int n);
native String getTextAfterCursorNative(long peer, int n);
native String getSelectedTextNative(long peer);
native int getCursorCapsModeNative(long peer, int reqModes);
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
native boolean deleteSurroundingTextInCodePointsNative(
long peer, int beforeLength, int afterLength);
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
native boolean setComposingRegionNative(long peer, int start, int end);
native boolean finishComposingTextNative(long peer);
native boolean commitTextNative(long peer, String text, int newCursorPosition);
native boolean setSelectionNative(long peer, int start, int end);
native boolean performEditorActionNative(long peer, int editorAction);
native boolean performContextMenuActionNative(long peer, int id);
native boolean beginBatchEditNative(long peer);
native boolean endBatchEditNative(long peer);
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
native void closeInputConnectionNative(long peer);
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.4.0" apply false
}
+15
View File
@@ -0,0 +1,15 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "iris-android-demo"
include(":app")
+120
View File
@@ -0,0 +1,120 @@
#!/bin/sh
# Builds the Android app end to end: the cdylib (cargo ndk from this
# directory, straight into android-project/app/src/main/jniLibs/) then the
# APK (Gradle, from android-project/). Written to stop re-typing
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
# cargo ndk invocation, the keystore env for a release build, apksigner/
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
# build-apk.sh` (the Compose app's own build script) and `app/
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
#
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
# debug/release default to debug (matches this-machine-android's "the
# emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
# nothing should add it back for the emulator's sake.
#
# **The emulator does not need a GLES build, because it has no hardware
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
# in software, its GLES is the host's real GPU through virgl, and iris's
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
# `Backends::GL` -- takes an ordinary build there by itself. So the
# emulator and the phone run the *same binary* and differ only in what
# that binary finds, which is the whole point: a build flag that changed
# the backend would mean the thing measured here is not the thing
# shipped.
#
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
# frame time goes"), and the desktop is the better place to run it now
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
# to reach a real device, but this script's old default put it in every
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
# build meant for a phone.
set -eu
cd "$(dirname "$0")"
BUILD_TYPE="debug"
ABI="arm64-v8a"
FEATURES="transcript-screen bench"
case "${1:-}" in
debug|release) BUILD_TYPE="$1"; shift ;;
esac
while [ $# -gt 0 ]; do
case "$1" in
--abi) ABI="$2"; shift 2 ;;
--features) FEATURES="$2"; shift 2 ;;
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
SDK_ROOT="$HOME/Android/Sdk"
export ANDROID_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$SDK_ROOT"
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
if [ -z "$NDK_DIR" ]; then
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
exit 1
fi
export ANDROID_NDK_HOME="$NDK_DIR"
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
# whatever earlier builds left here, and Gradle packages every directory it
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
# 339 MB on 2026-09-06.
rm -rf android-project/app/src/main/jniLibs
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
# build that switches ABI packages the previous ABI: an `--abi x86_64`
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
# aborted at startup with `Could not get adapter!: NotFound {
# active_backends: VULKAN }` under libndk_translation -- which reads
# exactly like the phone's own Vulkan problem and is nothing of the kind.
# Scoped to the merge task's directory rather than all of `app/build`, so
# an ABI change costs the native merge and not the whole Gradle build.
rm -rf android-project/app/build/intermediates/merged_native_libs \
android-project/app/build/intermediates/stripped_native_libs \
android-project/app/build/intermediates/merged_jni_libs
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
--profile android-release --no-default-features --features "$FEATURES"
else
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
--profile android-dev --no-default-features --features "$FEATURES"
fi
GRADLE_TASK="assembleDebug"
APK_DIR="android-project/app/build/outputs/apk/debug"
APK_NAME="app-debug.apk"
if [ "$BUILD_TYPE" = "release" ]; then
GRADLE_TASK="assembleRelease"
APK_DIR="android-project/app/build/outputs/apk/release"
APK_NAME="app-release.apk"
# Same key `app/build-apk.sh` (the Compose app) generates once under
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
if [ ! -f "$AI_APP_KEYSTORE" ]; then
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
exit 1
fi
export AI_APP_KEYSTORE_PASSWORD
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
fi
(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain)
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
echo "--- aapt2 dump badging ---"
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
if [ "$BUILD_TYPE" = "release" ]; then
echo "--- apksigner verify ---"
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
fi
echo "$APK_PATH"
+146
View File
@@ -0,0 +1,146 @@
//! 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
//! (IRIS.md, 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<f32> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--ime" {
return args.next()?.parse().ok();
}
}
None
}
/// The `--message TEXT` argument, with `\n` taken as a newline so a
/// multi-line message survives one shell word.
fn message_argv() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--message" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
/// 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<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--typed" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
#[allow(dead_code)]
screen: Option<ai_app::ui::TranscriptScreen>,
}
impl DefaultAppState for Client {
fn window_attributes() -> WindowAttributes {
WindowAttributes::default()
.with_title("iris transcript (bench fixture)")
.with_inner_size(PhysicalSize::new(
ai_app::ui::fixture::PHONE_WIDTH,
ai_app::ui::fixture::PHONE_HEIGHT,
))
}
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
Ok(opened) => {
if let Some(message) = message_argv() {
opened.screen.composer.field.edit(rsc).set(&message);
}
if let Some(text) = typed_argv() {
let field = opened.screen.composer.field;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
for ch in text.chars() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
ctx.update(move |state: &mut Client, rsc| {
state.set_focus(Some(field));
let end = rsc[field].text().len();
let mut edit = field.edit(rsc);
if edit.text.caret().is_none() {
edit.set_cursor_byte(end);
}
edit.insert(&ch.to_string());
});
redraw.request_redraw();
}
});
}
if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset);
}
Some(opened.screen)
}
// 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)
.wrap(true)
.pad(dp(16))
.add_strong(rsc)
.any();
ui_state.set_root(text);
None
}
};
Self { ui_state, screen }
}
}
+270
View File
@@ -0,0 +1,270 @@
//! 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::*;
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
#[allow(dead_code)]
screen: ai_app::ui::TranscriptScreen,
}
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
FoldedRow::Single(if from_user {
TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
}
} else {
TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled: true,
}
})
}
/// 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,
tool: &str,
input: &str,
result: Option<(&str, bool)>,
) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 3,
id: id.into(),
run_id: run.into(),
tool: tool.into(),
input: input.into(),
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
done: result.is_some(),
failed: result.is_some_and(|(_, failed)| failed),
asks: Vec::new(),
images: Vec::new(),
}
}
/// 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 {
asks.push(QuestionCard {
seq: 9,
id: format!("{id}-q"),
prompt: "Allow this command?".into(),
header: None,
options: vec![
QuestionOption {
label: "Allow".into(),
description: None,
preview: None,
},
QuestionOption {
label: "Deny".into(),
description: None,
preview: None,
},
],
multi_select: false,
answers: Vec::new(),
});
}
call
}
/// 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"))
.collect::<Vec<_>>()
.join("\n")
}
fn synthetic_rows() -> Vec<FoldedRow> {
vec![
msg(
1,
true,
"Can you show me a **bold** word, some *italic* text, and `inline code`?",
),
msg(
2,
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",
"Read",
r#"{"file_path": "src/main.rs"}"#,
Some(("fn main() {}\n", false)),
),
tool_call(
"t2",
"Bash",
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
Some((
"error: could not compile `iris`\nCaused by: linker not found",
true,
)),
),
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
]),
// 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",
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
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
Iris **fold** render measure session window anchor context transcript \
iris measure iris scroll call transcript layout *cursor* context, and a \
[bench](https://example.com/bench) link.
```rust
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
// a comment worth keeping: this is the fold the app's own screen runs
let mut out = items;
out.push(Item::new(seq));
out
}
```
| column | value |
|---|---|
| a | measure place draw tool call token context window anchor |
- one bullet
- another, with `inline code`
- nested one level
1. first numbered
2. second numbered
> A quoted line, to show the bar and the indent.
";
impl DefaultAppState for Client {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> 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 {
seq: 8,
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![
tool_call_in(
"run2",
"t6",
"Read",
r#"{"file_path": "docs/RUST.md"}"#,
Some(("# Moving the app to Rust\n", false)),
),
tool_call_in(
"run2",
"t7",
"Bash",
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
Some(("error: unused variable `x`", true)),
),
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
// 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",
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
),
]),
);
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),
"the newest row must be the tool run this flag is about"
);
}
Self { ui_state, screen }
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# Installs and runs the iris `bench` build on this checkout's own emulator
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
# job. No coordinates: the button is found by its accessibility label
# through `ui-trace`, per AGENTS.md's "Driving the UI".
#
# Usage: ./run-bench.sh [--apk PATH]
# Defaults to this checkout's own release APK
# (android-project/app/build/outputs/apk/release/app-release.apk) if it
# exists, else the
# debug one -- build one first with ./build-apk.sh.
set -eu
cd "$(dirname "$0")"
APK=""
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ -z "$APK" ]; then
if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
APK=android-project/app/build/outputs/apk/release/app-release.apk
else
APK=android-project/app/build/outputs/apk/debug/app-debug.apk
fi
fi
if [ ! -f "$APK" ]; then
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
exit 1
fi
SERIAL=$(emu serial)
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
if [ -z "$PKG" ]; then
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
fi
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
adb -s "$SERIAL" install -r "$APK" >/dev/null
adb -s "$SERIAL" shell am force-stop "$PKG"
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Which adapter drew, before any number is printed. The emulator is a GLES
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
# magnitude apart and are otherwise indistinguishable in this report, so
# the line is printed rather than left in logcat for somebody to think of.
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
if [ -n "$ADAPTER" ]; then
echo "run-bench.sh: $ADAPTER"
else
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
fi
# Poll for the report line rather than a fixed sleep -- the run itself is
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run.
# The report's own first line, not the bare "iris bench report:" prefix:
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
# first", which the app emits at startup), so polling for the prefix
# returned instantly and the script printed a report that was never run.
REPORT_LINE="iris bench report: iris bench report"
i=0
while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
if [ -n "$LINE" ]; then
break
fi
i=$((i + 1))
sleep 1
done
if [ -z "$LINE" ]; then
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
exit 1
fi
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
+11
View File
@@ -0,0 +1,11 @@
# 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"
components = ["clippy", "rustfmt"]
targets = ["aarch64-linux-android", "x86_64-linux-android"]
+186
View File
@@ -0,0 +1,186 @@
//! 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
//! `docs/DECISIONS.md`, 2026-09-07.
use crate::client::log_ring::{self, LogRing};
/// Installs the ring in front of `android_logger`, so `logcat` still sees
/// exactly what it saw before and the ring sees it too.
///
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
/// itself; the message says which caller, since two initialisation paths
/// is a programmer error rather than something to recover from.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(max_level)
.with_tag("iris-android-app"),
);
if log_ring::install_process_logger(
Box::new(inner),
max_level,
iris::diagnostics::trace_enabled,
)
.is_err()
{
// Not a panic: a logger already installed means logging works,
// just without the ring, and taking the app down over a
// diagnostic would be worse than the diagnostic being missing.
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
}
/// The process's ring -- what `Copy report` appends, what the diagnostics
/// pane counts, and what `devlog`'s provider hands to Dev Updater.
pub fn ring() -> &'static LogRing {
log_ring::process_ring()
}
/// Only the bench build has a diagnostics pane to put this in; the
/// transcript build's screen is the app's own and has no room for a
/// readout. Gated rather than left dead so the build stays warning-clean.
#[cfg(feature = "bench")]
/// Two lines for the diagnostics pane: how much of this app's log is held,
/// and where it can be read from.
///
/// The second names the provider's authority rather than saying "logging
/// is on", so a screenshot of this pane is enough to tell whether the
/// contract is live and which package's log it is -- the bench build and
/// the ordinary one have different ones.
pub fn diagnostics_line() -> String {
let where_to_read = match crate::android::devlog::authority() {
Some(authority) => format!("devlog provider: content://{authority}"),
// Not "off": Android creates a provider lazily, so this is what
// "nobody has asked for it yet" looks like, and it is a different
// thing from a build that does not have one.
None => "devlog provider: declared, not created yet".to_string(),
};
format!("{}\n{where_to_read}", ring().summary())
}
/// Where the panic hook leaves its report, under the app's private
/// directory. Read back and dropped by [`set_crash_dir`] on the next
/// start.
const CRASH_FILE: &str = "last-panic.txt";
/// How many of the dying run's own log lines the panic hook saves with
/// the panic, and [`set_crash_dir`] replays.
///
/// The panic's message and location say *what* broke; these say what the
/// app was doing on the way there, which is the half that is otherwise
/// unrecoverable -- the ring is memory only, so an abort takes every line
/// before the panic with it. Bounded rather than the whole ring because
/// this is written by a hook on a process that is about to die, and
/// because the replay pushes each line into the new run's ring, where an
/// unbounded paste would evict the run that is actually being watched.
const CRASH_CONTEXT_LINES: usize = 80;
/// The target the replayed context lines carry, so a reader can tell a
/// line from the run that died from one this run wrote. They keep their
/// original timestamp and level inside the text, which is why the level
/// they are re-pushed at is not meaningful and the target has to be.
const PREVIOUS_RUN_TARGET: &str = "previous_run";
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Installs a `log`-level panic hook, so a panic's message and location
/// reach the ring and `logcat` rather than only the tombstone.
///
/// **Why this is needed at all**: these builds are `panic = "abort"`
/// (`Cargo.toml`), and the default hook writes to `stderr` plus
/// `android_set_abort_message` -- the crash report. Iris runs these on a
/// phone with no `adb`, so the crash report is exactly the surface she
/// cannot read, and an `assert!` that fired said nothing anywhere she
/// could see it. Routing it through `log::error!` puts it in front of
/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev
/// Updater.
///
/// The ring is memory only, so after an abort the process that holds it
/// is gone -- hence the file half. [`set_crash_dir`] replays it.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
};
// `info`'s own `Display` repeats the location and a newline;
// the payload alone keeps this to the one line the ring wants.
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
// The panic line first, then what the app was doing before
// it: one file, split again on that first newline by
// `set_crash_dir`.
let context = ring()
.try_tail_text(CRASH_CONTEXT_LINES)
// Said rather than left empty, so "the ring was locked as
// we died" cannot be read as "nothing had been logged".
.unwrap_or_else(|| {
"(the log ring was locked as this run died; no context)".to_string()
});
// Best effort by design: a panic is already the failure, and
// failing to record it must not become a second one.
let _ = std::fs::write(path, format!("{line}\n{context}"));
}
previous(info);
}));
}
/// Tells the panic hook where to leave its report, and replays the report
/// a previous run left there into the ring before deleting it.
///
/// Called from **both** `MainActivity.nativeSetFilesDir` and
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
/// this process, since after a crash Dev Updater's query starts the
/// process for the provider alone and no activity ever runs. Safe to call
/// twice: the file is gone after the first, so the second finds nothing
/// and says nothing. The panic itself is replayed at `error` level and
/// says it is from the previous run, so a crash loop shows the reason it
/// is looping in the Runtime tab of the run that is still up.
pub fn set_crash_dir(dir: &std::path::Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = std::fs::read_to_string(&path) {
// Delete before replaying rather than after: a replay that itself
// panicked would otherwise leave the file to be replayed again on
// every start, and a crash loop nothing can get out of is worse
// than one report lost.
let _ = std::fs::remove_file(&path);
replay_crash(&previous);
}
let _ = CRASH_PATH.set(path);
}
/// Puts a previous run's report back in the ring: its context lines in
/// the order they happened, then the panic itself.
///
/// Chronological, so the Runtime tab reads as one story -- the lines that
/// led to the crash, then the crash, then this run. The context goes in
/// through `LogRing::push` rather than through `log::info!` so it is not
/// stamped with this run's clock: each line already carries the time and
/// level it was written at, and [`PREVIOUS_RUN_TARGET`] is what says
/// whose run it was.
fn replay_crash(report: &str) {
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
for line in context.lines().filter(|line| !line.is_empty()) {
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
}
log::error!(
"iris app log: the previous run died -- {}",
panic_line.trim()
);
}
File diff suppressed because it is too large. Load diff
+252
View File
@@ -0,0 +1,252 @@
//! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
//! report's real late-frame budget and `InputMethodManager.
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
//! of these are part of `android_view::context`'s own `Context`/
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
//! this calls them directly rather than growing that crate's wrapper for
//! calls this crate alone needs.
//!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
//! whichever thread calls it -- the battery sampler runs on a background
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
//! already attached (the `jni` crate detects it and does not double
//! attach), so no caller here needs to know or care which thread it is.
use android_view::jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JObject, JValue},
};
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
/// as a constant anywhere reachable without the Android SDK jar, so named
/// here with its source rather than left as a bare `2`.
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
pub struct PlatformHandle {
vm: JavaVM,
view: GlobalRef,
}
impl PlatformHandle {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
env.call_method(
self.view.as_obj(),
"getContext",
"()Landroid/content/Context;",
&[],
)
.ok()?
.l()
.ok()
}
fn system_service<'e>(
&self,
env: &mut JNIEnv<'e>,
context: &JObject<'e>,
name: &str,
) -> Option<JObject<'e>> {
let jname = env.new_string(name).ok()?;
env.call_method(
context,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(jname.as_ref())],
)
.ok()?
.l()
.ok()
}
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
/// on any JNI failure, on a device with no `BatteryManager` service,
/// or when the platform itself answers "not supported" -- `0` or
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
/// both would read as a real (and wrong) measurement if folded into an
/// average rather than named apart. UI_RULES.md: never present an
/// inferred value as a measured one.
pub fn battery_current_ua(&self) -> Option<i32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let battery_manager = self.system_service(env, &context, "batterymanager")?;
let value = env
.call_method(
&battery_manager,
"getIntProperty",
"(I)I",
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
)
.ok()?
.i()
.ok()?;
if value == 0 || value == i32::MIN {
None
} else {
Some(value)
}
}
/// Puts `text` on the system clipboard through `ClipboardManager` --
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
/// `setPrimaryClip`) succeeded.
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
self.try_copy_to_clipboard(label, text).is_some()
}
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let clipboard = self.system_service(env, &context, "clipboard")?;
let jlabel = env.new_string(label).ok()?;
let jtext = env.new_string(text).ok()?;
let clip = env
.call_static_method(
"android/content/ClipData",
"newPlainText",
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
&[
JValue::Object(jlabel.as_ref()),
JValue::Object(jtext.as_ref()),
],
)
.ok()?
.l()
.ok()?;
env.call_method(
&clipboard,
"setPrimaryClip",
"(Landroid/content/ClipData;)V",
&[JValue::Object(&clip)],
)
.ok()?;
Some(())
}
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
/// frames are judged against *this* device's real budget, not an
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
/// "late" that met their own faster deadline. `None` if the view is
/// not yet attached to a window (`getDisplay` returns `null`) or the
/// platform reports a non-positive rate, which is not a real answer
/// either.
pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let display = env
.call_method(
self.view.as_obj(),
"getDisplay",
"()Landroid/view/Display;",
&[],
)
.ok()?
.l()
.ok()?;
if display.is_null() {
return None;
}
let rate = env
.call_method(&display, "getRefreshRate", "()F", &[])
.ok()?
.f()
.ok()?;
if rate > 0.0 { Some(rate) } else { None }
}
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
/// own show, called directly rather than through the focus-driven
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
/// since RUST.md's "Benchmark v2" spec asks for this "through the
/// shell's InputMethodManager" independent of focus state. `true` only
/// if the platform itself reports the request succeeded -- whether the
/// IME actually became visible is confirmed separately, from
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
/// value as a measured one").
pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false)
}
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false)
}
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let imm = self.system_service(env, &context, "input_method")?;
if show {
env.call_method(
&imm,
"showSoftInput",
"(Landroid/view/View;I)Z",
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
)
.ok()?
.z()
.ok()
} else {
let token = env
.call_method(
self.view.as_obj(),
"getWindowToken",
"()Landroid/os/IBinder;",
&[],
)
.ok()?
.l()
.ok()?;
env.call_method(
&imm,
"hideSoftInputFromWindow",
"(Landroid/os/IBinder;I)Z",
&[JValue::Object(&token), JValue::Int(0)],
)
.ok()?
.z()
.ok()
}
}
/// Shows `report` in the shell's plain-view diagnostics overlay
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
/// and Close controls, added over whatever iris itself is drawing
/// rather than replacing it (unlike `android::view::show_renderer_error`,
/// which exists for the case the renderer can never recover from and
/// intentionally never returns). Called from a background task after
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
/// so the Java side hops onto the UI thread itself before touching the
/// view tree -- see that method's own comment.
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
self.try_show_diagnostics_overlay(report).is_some()
}
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let jreport = env.new_string(report).ok()?;
env.call_method(
self.view.as_obj(),
"showDiagnosticsOverlay",
"(Ljava/lang/String;)V",
&[JValue::Object(jreport.as_ref())],
)
.ok()?;
Some(())
}
}
+211
View File
@@ -0,0 +1,211 @@
//! The JNI half of `DevLogProvider`: reading this process's own log ring
//! for a `ContentProvider` that Dev Updater queries.
//!
//! **Why**: Iris runs these builds on a phone with no `adb`, and Android
//! forbids one app reading another's `logcat`, so nothing outside this
//! process can recover what it wrote. The app already keeps a bounded copy
//! (`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`).
#[cfg(feature = "transcript-screen")]
const FIELDS_PER_LINE: usize = 5;
/// The authority the provider registered itself under, once it has been
/// created. `None` until then, which is a state worth being able to say:
/// a provider Android never instantiated and one that is answering look
/// the same from inside this process otherwise.
static AUTHORITY: OnceLock<String> = OnceLock::new();
/// Where this app's log can be read from, for the diagnostics pane.
///
/// The provider's own answer rather than one composed from the package
/// name here: what makes the line worth showing is that it names an
/// authority somebody can actually query, and only the provider knows it
/// registered.
#[cfg(feature = "bench")]
pub fn authority() -> Option<&'static str> {
AUTHORITY.get().map(String::as_str)
}
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
/// it registered under and the app's private directory, from its own
/// `onCreate`.
///
/// The directory is taken here as well as in
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
/// only thing running**: once the app has died, Dev Updater's query
/// starts the process for the provider alone, so no activity ever runs
/// and the panic hook's file would never be replayed into the ring. That
/// is precisely the run whose log is being asked for. Whichever of the
/// two arrives first does the replay; `set_crash_dir` deletes the file,
/// so the second finds nothing and says nothing.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
mut env: JNIEnv,
_class: JClass,
authority: JString,
files_dir: JString,
) {
// Before the authority line, so the previous run's death is above the
// line announcing this one rather than buried under it.
#[cfg(feature = "transcript-screen")]
if let Some(dir) = string_arg(&mut env, &files_dir) {
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
}
#[cfg(not(feature = "transcript-screen"))]
let _ = &files_dir;
let Some(authority) = string_arg(&mut env, &authority) else {
return;
};
log::info!("iris devlog: serving this app's log at content://{authority}");
let _ = AUTHORITY.set(authority);
}
/// One `String` argument, or `None` for a null or unreadable one.
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
if value.is_null() {
return None;
}
env.get_string(value).ok().map(Into::into)
}
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
/// three strings.
///
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
/// what tells a reader holding a cursor that this process **restarted**:
/// the ring is in memory, so a new process starts again at zero and a
/// stale cursor would otherwise skip everything silently.
///
/// Exported by name rather than registered, matching this crate's other
/// activity-side natives: the mangled name is the whole of what a class
/// this app owns needs.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
mut env: JNIEnv,
_class: JClass,
) -> jobjectArray {
string_array(&mut env, &status_fields())
}
/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at
/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each.
///
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
/// is, and one definition of the cursor is what keeps the app's own
/// uploaded report and this provider describing the same lines.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
mut env: JNIEnv,
_class: JClass,
since: jlong,
) -> jobjectArray {
// A negative cursor is a caller asking for everything, not an error to
// take the app down over: the provider is a diagnostic.
string_array(&mut env, &line_fields(since.max(0) as u64))
}
/// The three status numbers, as the provider's row.
#[cfg(feature = "transcript-screen")]
fn status_fields() -> Vec<String> {
let ring = crate::client::log_ring::process_ring();
vec![
ring.len().to_string(),
ring.dropped().to_string(),
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
]
}
/// The tabs demo links no `client-core` and keeps no ring, so it holds
/// nothing and has never dropped anything -- which is the truth, not a
/// stand-in. The natives are still exported there, because a `native`
/// method Java declares and the library does not is an
/// `UnsatisfiedLinkError` the moment the class loads.
#[cfg(not(feature = "transcript-screen"))]
fn status_fields() -> Vec<String> {
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
}
#[cfg(feature = "transcript-screen")]
fn line_fields(since: u64) -> Vec<String> {
let (lines, _next) = crate::client::log_ring::process_ring().since(since);
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
for line in lines {
fields.push(line.seq.to_string());
fields.push(line.at_ms.to_string());
fields.push(line.level.to_string());
fields.push(line.target);
fields.push(line.message);
}
fields
}
#[cfg(not(feature = "transcript-screen"))]
fn line_fields(_since: u64) -> Vec<String> {
Vec::new()
}
/// A Java `String[]` of those, or a null array if the JVM refused one.
///
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
/// reads it as "the provider could not answer" and returns no cursor,
/// which Dev Updater already draws as a distinct state. Taking the app
/// down to report that its diagnostic is unavailable would be worse than
/// the diagnostic being unavailable.
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
let null = std::ptr::null_mut();
let Ok(class) = env.find_class("java/lang/String") else {
return null;
};
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
return null;
};
for (index, field) in fields.iter().enumerate() {
let Ok(value) = env.new_string(field) else {
return null;
};
if env
.set_object_array_element(&array, index as i32, value)
.is_err()
{
return null;
}
}
array.into_raw()
}
+133
View File
@@ -0,0 +1,133 @@
//! Which `ai-server` this app talks to, and how it was told.
//!
//! The parsing, the file and its owner-only mode are
//! `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<PathBuf> = OnceLock::new();
pub fn set_files_dir(dir: PathBuf) {
if let Err(existing) = FILES_DIR.set(dir.clone()) {
assert_eq!(
existing, dir,
"the app's files directory was set twice with different paths"
);
}
}
/// `None` before `MainActivity` has handed the directory over -- which is
/// **not** the same as "not enrolled", and is why [`status`] has a state
/// for it (UI_RULES: design the unknown state first).
fn store() -> Option<EnrollmentStore> {
FILES_DIR.get().map(EnrollmentStore::new)
}
/// What this app has been told, or why it has not been.
pub enum Status {
Enrolled(EnrolledServer),
/// Nothing has been enrolled yet: the ordinary first-run state.
NotEnrolled,
/// The question could not be answered -- the activity never handed a
/// files directory over, or the file is there and unreadable. Kept
/// apart from `NotEnrolled` because the two want different actions
/// from whoever is looking.
Unknown(String),
}
pub fn status() -> Status {
let Some(store) = store() else {
return Status::Unknown("the activity never handed over a files directory".to_string());
};
match store.load() {
Ok(Some(server)) => Status::Enrolled(server),
Ok(None) => Status::NotEnrolled,
Err(error) => Status::Unknown(error.to_string()),
}
}
/// One line for the diagnostics pane. The three states read differently on
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
/// must not be mistaken for it.
///
/// Only the bench build has a pane to put this in -- same gate, and the
/// same reason, as `app_log::diagnostics_line`. The transcript build says
/// the same things where they matter to it, in the message
/// [`transport`]'s error becomes on screen.
#[cfg(feature = "bench")]
pub fn status_line() -> String {
match status() {
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
}
}
/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever
/// was enrolled before -- opening a link is how somebody says "this server
/// now", including after the old one's token was rotated.
///
/// The returned `Err` is the message for a person: this is called from a
/// tap on a link, and a link that did nothing with nothing said is the
/// failure the UI rules are most insistent about.
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
let server = EnrolledServer::parse_link(uri)?;
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
store
.save(&server)
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
Ok(server)
}
/// A transport for the enrolled server, pinning the CA the link carried.
///
/// Gated to the same builds as `transcript_client`, its only caller: the
/// bench build opens a checked-in fixture and reaches no server, so
/// compiling this into it would be a warning about dead code that is
/// dead on purpose.
///
/// Every failure here is a sentence a screen can show, because there is
/// nowhere else for it to go: this app has no `logcat` on the phone it is
/// built for.
#[cfg(not(feature = "bench"))]
pub fn transport() -> Result<UreqTransport, String> {
let server = match status() {
Status::Enrolled(server) => server,
Status::NotEnrolled => {
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
}
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
};
let ca_pem = server.ca_pem.as_ref().ok_or(
"The enrollment link carried no CA, so there is nothing to pin. \
Enrol again with a link minted by this server.",
)?;
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
.map_err(|error| error.message)
}
+235
View File
@@ -0,0 +1,235 @@
//! 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::{
JNIEnv, JavaVM,
objects::{JClass, JString},
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
},
register_view_class,
};
#[cfg(not(feature = "transcript-screen"))]
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
#[cfg(not(feature = "transcript-screen"))]
use iris::prelude::*;
use log::LevelFilter;
use std::ffi::c_void;
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
/// tabs demo keeps `android_logger` alone, as it always had.
#[cfg(feature = "transcript-screen")]
mod app_log;
#[cfg(feature = "bench")]
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")))]
mod transcript_client;
/// The app's `View` subclass, matching the Java side's package --
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
#[cfg(not(feature = "transcript-screen"))]
pub struct Client {
ui_state: AndroidUiState,
}
#[cfg(not(feature = "transcript-screen"))]
impl HasAndroidUiState for Client {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
#[cfg(not(feature = "transcript-screen"))]
impl AndroidAppState for Client {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
// `widgets.info` is the winit example's frame-debug readout, kept
// current from `DefaultAppState::window_event` -- android-view has
// no per-frame hook to drive the equivalent from here yet, so it
// is left at its built "" text rather than wired to nothing.
let _ = tabs_ui::build(rsc, &mut ui_state);
Self { ui_state }
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
// Nothing in the tabs example has a back stack of its own to pop --
// declining lets the activity finish, which is the same "no
// handler" behaviour the default impl gives. Present as an
// explicit override (rather than relying on the default) so a
// reader checking "does the back gesture reach this app" finds an
// answer here rather than nothing.
false
}
}
#[cfg(not(feature = "transcript-screen"))]
type ActiveClient = Client;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
type ActiveClient = transcript_client::TranscriptClient;
#[cfg(feature = "bench")]
type ActiveClient = bench_client::BenchClient;
extern "system" fn new_view_peer<'local>(
env: JNIEnv<'local>,
view: View<'local>,
context: Context<'local>,
) -> jlong {
iris::android::new_peer::<ActiveClient>(env, view, context)
}
/// # Safety
/// Interacting with JNI at load time is always unsafe at some level --
/// mirrors android-view's own demo, which carries the same comment.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
// The ring in front of `android_logger` where there is one (see
// `app_log`), and `android_logger` alone otherwise. Both install the
// same tag and level, so `logcat` cannot tell the two builds apart --
// the ring only adds a second reader.
#[cfg(feature = "transcript-screen")]
app_log::install(LevelFilter::Debug);
#[cfg(not(feature = "transcript-screen"))]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Debug)
.with_tag("iris-android-app"),
);
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
let mut env = vm.get_env().unwrap();
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
iris::android::register_native_methods(&mut env, VIEW_CLASS);
JNI_VERSION_1_6
}
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
/// over before the view exists because that is where the enrollment is
/// read from and written to (`enrollment`'s module doc).
///
/// Exported by name rather than registered through `RegisterNatives`: the
/// view's methods are registered because `android-view` owns that class
/// and hands out one function pointer, whereas these two are this app's
/// own activity and the mangled name is the whole of what is needed.
///
/// Declared in every build, including the tabs demo that has no
/// `client-core` to store anything -- a `native` method Java declares and
/// the library does not export is an `UnsatisfiedLinkError` when the class
/// loads, which would take down a build that merely shares the activity.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
mut env: JNIEnv,
_class: JClass,
dir: JString,
) {
let Some(dir) = jstring(&mut env, dir) else {
return;
};
#[cfg(feature = "transcript-screen")]
{
app_log::set_crash_dir(std::path::Path::new(&dir));
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}
/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the
/// VIEW intent that started or resumed the activity.
///
/// Logged either way rather than answered: the activity has nothing to do
/// with the result, and where the enrollment shows up is the diagnostics
/// pane (`enrollment::status_line`), which reads the stored answer rather
/// than being told it.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
mut env: JNIEnv,
_class: JClass,
uri: JString,
) {
let Some(uri) = jstring(&mut env, uri) else {
return;
};
#[cfg(feature = "transcript-screen")]
match enrollment::apply_link(&uri) {
// Never the token: `wg-app-link`'s enroll module forbids logging
// it, and this line would otherwise be the one place it leaked.
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
}
#[cfg(not(feature = "transcript-screen"))]
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
}
/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one --
/// neither is worth taking the app down for, and both are logged where
/// they happen.
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
if value.is_null() {
log::warn!("iris app: the activity passed a null string across JNI");
return None;
}
match env.get_string(&value) {
Ok(value) => Some(value.into()),
Err(error) => {
log::warn!("iris app: couldn't read a string from the activity -- {error}");
None
}
}
}
+386
View File
@@ -0,0 +1,386 @@
//! 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};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub struct TranscriptClient {
ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed
/// [`frame_report_controls`] bar, which is built once (`new`, below)
/// and never touched by `show_message`/`rebuild_transcript`'s own
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
/// by rebuilding the session list beside it.
content: WeakWidget<WidgetPtr>,
screen: Option<crate::ui::TranscriptScreen>,
/// The folded transcript as of the last rebuild -- kept here (not
/// re-derived) for the same reason `desktop-app`'s `Client::items`
/// exists: a live `StreamEvent` only carries one new wire event, and
/// `fold_event` needs everything folded so far to fold it in.
items: Vec<TranscriptItem>,
/// The session currently open -- `None` only before the first fetch
/// resolves. Read back by `apply_event`'s rebuild, which has no session
/// id of its own (a live `SeqEvent` doesn't carry one).
session_id: Option<String>,
/// Bumped every time a new session load starts; a background response
/// checks it before touching state, so a slow reply for a session this
/// screen has moved on from can't overwrite what replaced it. There is
/// only ever one session here (no list to switch away to), but the
/// guard still matters for the *first* fetch racing a `stop`/`start`.
generation: Arc<AtomicU64>,
}
impl HasAndroidUiState for TranscriptClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
/// Builds one `UreqTransport` from the 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<UreqTransport, String> {
crate::android::enrollment::transport()
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
/// frames at all -- this is the screen's own equivalent of the Compose
/// app's "Copy render timings" control, logged rather than clipboarded
/// (no clipboard wiring exists here) under this crate's own fixed
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
/// grep-able on the fixed string `"iris frame report"` the way
/// `transcript-bench.sh` greps `"ai-app render report"`.
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Color::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
.state
.android_state()
.frame_report
.report()
{
Some(stats) => log::info!("iris frame report: {stats}"),
None => log::info!(
"iris frame report: no frames recorded -- scroll first, then press this"
),
},
)
.label("Frame report");
let report = (
report_rect,
wtext("Frame report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
let reset_rect = rect(Color::rgb(70, 40, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
ctx.state.android_state_mut().frame_report.reset();
log::info!("iris frame report: reset");
},
)
.label("Reset frame report");
let reset = (
reset_rect,
wtext("Reset").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
}
impl AndroidAppState for TranscriptClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading sessions...");
content(rsc).set(loading);
let tree = (frame_report_controls(rsc), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(tree);
let mut client = Self {
ui_state,
content,
screen: None,
items: Vec::new(),
session_id: None,
generation: Arc::new(AtomicU64::new(0)),
};
client.spawn_fetch_sessions(rsc);
client
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
// No screen stack of its own -- same "let the activity finish"
// answer `iris-android-app`'s tabs `Client` already gives.
false
}
}
impl TranscriptClient {
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
let redraw = rsc.tasks.redraw_handle();
let my_generation = self.generation.load(Ordering::SeqCst);
let generation = self.generation.clone();
rsc.spawn_task(async move |mut ctx| {
let outcome = match build_transport() {
Ok(transport) => ApiClient::new(transport)
.fetch_sessions()
.map_err(|e| e.to_string()),
Err(e) => Err(format!("couldn't set up TLS: {e}")),
};
ctx.update(move |state: &mut TranscriptClient, rsc| {
if generation.load(Ordering::SeqCst) != my_generation {
return;
}
match outcome {
Ok(sessions) => match sessions.into_iter().next() {
Some(session) => state.select_session(rsc, session.id),
None => state.show_message(rsc, "No sessions on the sandbox server."),
},
Err(message) => {
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
}
}
});
redraw.request_redraw();
});
}
/// Loads the opening page, then follows the live SSE stream for the
/// rest of this session's life -- `desktop-app`'s `select_session`
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
/// `redraw.request_redraw()` (see this module's doc).
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.items.clear();
self.session_id = Some(session_id.clone());
self.show_message(rsc, "Loading transcript...");
let redraw = rsc.tasks.redraw_handle();
let live_generation = self.generation.clone();
rsc.spawn_task(async move |mut ctx| {
let transports =
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
let (rest, stream_transport) = match transports {
Ok(pair) => pair,
Err(e) => {
let message = format!("couldn't set up TLS: {e}");
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) == my_generation {
state.show_message(rsc, &message);
}
});
redraw.request_redraw();
return;
}
};
let api = ApiClient::new(rest);
// The most recent 200 events, coalesced -- the same page size
// `desktop-app` uses; RUST.md's I3/history-paging work is what
// a real scrollback would reuse (out of scope here, same as
// E4).
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
// The wire `seq` of the last line, not a folded item's `seq()`
// -- see `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()
.and_then(|values| values.last())
.and_then(crate::client::transcript_fold::raw_seq)
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
{
let live_generation = live_generation.clone();
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
match result {
Ok(items) => {
state.items = items;
state.rebuild_transcript(rsc);
}
Err(message) => {
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
}
}
});
}
redraw.request_redraw();
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
// The outer closure here is an `FnMut` -- `follow_session_events`
// calls it once per line -- so it captures `live_generation` by
// move and re-clones it for each inner `ctx.update` closure
// rather than moving a shared `stop`-style helper into itself:
// a value moved out of an `FnMut`'s captures on one call leaves
// nothing there for the next.
let _ =
follow_session_events(
&stream_transport,
&session_id,
after,
move |item| match item {
StreamItem::Open | StreamItem::Reset => {
live_generation.load(Ordering::SeqCst) == my_generation
}
StreamItem::Event { event, .. } => {
if live_generation.load(Ordering::SeqCst) != my_generation {
return false;
}
let live_generation = live_generation.clone();
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
state.apply_event(rsc, &event);
});
redraw.request_redraw();
true
}
},
);
});
}
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
/// Reads `self.session_id` rather than taking one, since every caller
/// (the opening page, and every live event) already has it set there.
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
let in_progress = self
.screen
.as_ref()
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
}
if let Some(session_id) = self.session_id.clone() {
let field = screen.composer.field;
rsc.register_event(field, Submit, move |ctx, rsc| {
let text = field.edit(rsc).take();
let text = text.trim().to_string();
if !text.is_empty() {
ctx.state.send_message(session_id.clone(), text);
}
});
}
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, event);
match &self.screen {
// The common path: update only the row(s) that actually
// changed instead of refolding and rebuilding all ~3,200 of
// them per event (RUST.md's P0 streaming-phase fix).
Some(screen) => screen.apply(rsc, &old_items, &self.items),
// No screen yet (the opening page hasn't landed) -- build one
// the ordinary way once it has.
None => self.rebuild_transcript(rsc),
}
}
fn send_message(&mut self, session_id: String, text: String) {
std::thread::spawn(move || {
if let Ok(transport) = build_transport() {
let api = ApiClient::new(transport);
let _ = api.send_message(&session_id, &text, &[]);
}
});
}
}
+35
View File
@@ -0,0 +1,35 @@
//! 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 (DECISIONS.md, 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);
}
app::run();
}
+534
View File
@@ -0,0 +1,534 @@
//! What a tool printed, with its terminal styling applied and everything
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
//! here, so a [`StyledText`] of plain text plus non-overlapping
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
//! [`Style`] onto whatever it draws with.
//!
//! Bash output arrives exactly as the program wrote it, escape sequences
//! included, and drawn verbatim those are line noise in the middle of the
//! thing being read. Stripping them all would be the other half-answer --
//! colour is often the whole of what a diff or a test run is saying.
//!
//! So the sequences that decide how text *looks* become spans, and every
//! other one is dropped rather than shown: the rest move a cursor around a
//! grid this is not, and "go to column 40" has no meaning in a scrolling
//! document.
//!
//! A carriage return is honoured the way a terminal honours it: what was
//! written since the last line break is thrown away and the line starts
//! again. That is what makes a progress bar show its final state rather
//! than every state it passed through.
use std::ops::Range;
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
/// because the one place that needs partial transparency (dimming) says so
/// with a separate flag rather than baking it into the colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
}
/// The sixteen colours a terminal program names, and the two it assumes.
///
/// Its own palette rather than the syntax one: a program that prints in red
/// has chosen red, where a highlighter's colours are this app's reading of
/// somebody else's code.
#[derive(Debug, Clone)]
pub struct AnsiPalette {
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
pub colours: [Rgb; 16],
/// What uncoloured text is, needed only where a style has to state a colour.
pub foreground: Rgb,
/// What the text sits on, needed for reverse video.
pub background: Rgb,
}
/// One span's worth of styling. `None` fields mean "unspecified", the same
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Style {
pub color: Option<Rgb>,
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
pub alpha: Option<f32>,
pub background: Option<Rgb>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
}
/// Plain text plus the non-overlapping, ordered spans that style parts of it
/// -- this crate's stand-in for Compose's `AnnotatedString`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText {
pub text: String,
pub spans: Vec<(Range<usize>, Style)>,
}
impl StyledText {
fn plain(text: String) -> Self {
Self {
text,
spans: Vec::new(),
}
}
}
const ESC: char = '\u{1B}';
const BELL: char = '\u{7}';
/// [text] with its terminal styling applied and everything else taken out;
/// see the module doc.
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
// The common case by a long way -- nothing to do, and nothing allocated
// to find that out.
if !text.contains(ESC) && !text.contains('\r') {
return StyledText::plain(text.to_string());
}
let chars: Vec<char> = text.chars().collect();
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
let mut sgr = Sgr::PLAIN;
let mut at = 0usize;
let mut plain = String::new();
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
if !plain.is_empty() {
runs.push((std::mem::take(plain), sgr.span(palette)));
}
};
while at < chars.len() {
let c = chars[at];
if c == ESC {
flush(&mut plain, sgr, &mut runs);
at = skip_escape(&chars, at, |params, final_byte| {
if final_byte == 'm' {
sgr = sgr.apply(params, palette);
}
});
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
// A bare carriage return rewrites the line. One before a newline
// is the other half of a Windows line ending: it rewrites
// nothing, and it is dropped rather than kept, since that pair
// is one line break.
flush(&mut plain, sgr, &mut runs);
drop_line(&mut runs);
at += 1;
} else if c == '\r' {
at += 1;
} else if c >= ' ' || c == '\n' || c == '\t' {
// Everything printable, plus the two control characters that are
// layout rather than terminal commands. A stray bell or
// backspace goes for the same reason a cursor move does.
plain.push(c);
at += 1;
} else {
at += 1;
}
}
flush(&mut plain, sgr, &mut runs);
let mut out = String::new();
let mut spans = Vec::new();
for (run_text, style) in runs {
let start = out.len();
out.push_str(&run_text);
if let Some(style) = style {
spans.push((start..out.len(), style));
}
}
StyledText { text: out, spans }
}
/// Throws away everything written since the last line break, as a carriage
/// return does.
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
while let Some((text, style)) = runs.pop() {
if let Some(break_at) = text.rfind('\n') {
runs.push((text[..=break_at].to_string(), style));
return;
}
}
}
/// The bytes that end a CSI sequence.
fn is_csi_final(c: char) -> bool {
('@'..='~').contains(&c)
}
/// Steps over the escape sequence starting at `at`, reporting a CSI's
/// parameters and final byte. One reader for every kind, because the point
/// is to *leave* them all behind: a sequence this did not recognise would
/// otherwise have its body printed as ordinary text. Three shapes -- the CSI
/// (`ESC [ ... letter`), the string escapes which run to a terminator, and
/// the two-character ones.
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
let Some(&next) = chars.get(at + 1) else {
return at + 1;
};
match next {
'[' => {
let mut end = at + 2;
while end < chars.len() && !is_csi_final(chars[end]) {
end += 1;
}
if end >= chars.len() {
// Cut off mid-sequence, which is what a stream that has not
// finished arriving looks like: drop the fragment rather
// than printing it, and the whole sequence arrives with the
// next delta.
chars.len()
} else {
let params: String = chars[at + 2..end].iter().collect();
on_csi(&params, chars[end]);
end + 1
}
}
']' | 'P' | 'X' | '^' | '_' => {
// Runs to a string terminator: `ESC \`, or the bell that xterm
// allows after an OSC.
let mut end = at + 2;
while end < chars.len() {
if chars[end] == BELL {
return end + 1;
}
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
return end + 2;
}
end += 1;
}
chars.len()
}
_ => at + 2,
}
}
/// Everything an SGR sequence can turn on, as the terminal tracks it.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Sgr {
fg: Option<Rgb>,
bg: Option<Rgb>,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
strike: bool,
reverse: bool,
}
/// How much of its colour dim text keeps: enough to read, little enough to recede.
const DIM_ALPHA: f32 = 0.65;
impl Sgr {
const PLAIN: Sgr = Sgr {
fg: None,
bg: None,
bold: false,
dim: false,
italic: false,
underline: false,
strike: false,
reverse: false,
};
/// `None` while nothing is set, so unstyled output costs no spans at all.
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
if *self == Sgr::PLAIN {
return None;
}
let front = if self.reverse {
Some(self.bg.unwrap_or(palette.background))
} else {
self.fg
};
let back = if self.reverse {
Some(self.fg.unwrap_or(palette.foreground))
} else {
self.bg
};
// Dim has to have a colour to dim, so where none was named it dims
// the ordinary one.
let stated = front.or(if self.dim {
Some(palette.foreground)
} else {
None
});
Some(Style {
color: stated,
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
background: back,
bold: self.bold,
italic: self.italic,
underline: self.underline,
strikethrough: self.strike,
})
}
/// This state with `params` applied -- one `ESC[...m`, which carries any
/// number of them.
///
/// A code this does not model is ignored rather than reset from: the
/// program meant something by it, and starting again would also drop
/// the codes beside it that are understood.
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
// zero too.
let codes: Vec<i64> = params
.split(';')
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
.collect();
let mut state = *self;
let mut at = 0usize;
while at < codes.len() {
let code = codes[at];
state = match code {
0 => Sgr::PLAIN,
1 => Sgr {
bold: true,
..state
},
2 => Sgr { dim: true, ..state },
3 => Sgr {
italic: true,
..state
},
4 => Sgr {
underline: true,
..state
},
7 => Sgr {
reverse: true,
..state
},
9 => Sgr {
strike: true,
..state
},
21 | 22 => Sgr {
bold: false,
dim: false,
..state
},
23 => Sgr {
italic: false,
..state
},
24 => Sgr {
underline: false,
..state
},
27 => Sgr {
reverse: false,
..state
},
29 => Sgr {
strike: false,
..state
},
30..=37 => Sgr {
fg: Some(palette.colours[(code - 30) as usize]),
..state
},
90..=97 => Sgr {
fg: Some(palette.colours[(code - 90 + 8) as usize]),
..state
},
40..=47 => Sgr {
bg: Some(palette.colours[(code - 40) as usize]),
..state
},
100..=107 => Sgr {
bg: Some(palette.colours[(code - 100 + 8) as usize]),
..state
},
39 => Sgr { fg: None, ..state },
49 => Sgr { bg: None, ..state },
38 | 48 => {
let (colour, last) = extended_colour(&codes, at, palette);
at = last;
if code == 38 {
Sgr {
fg: colour,
..state
}
} else {
Sgr {
bg: colour,
..state
}
}
}
_ => state,
};
at += 1;
}
state
}
}
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
/// last parameter.
///
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
/// one. The first sixteen of that table are the palette's own, so a program
/// asking for "colour 1" through either spelling gets the same red.
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
match codes.get(at + 1) {
Some(&5) => match codes.get(at + 2) {
None => (None, at + 1),
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
},
Some(&2) => {
let r = codes.get(at + 2);
let g = codes.get(at + 3);
let b = codes.get(at + 4);
match (r, g, b) {
(Some(&r), Some(&g), Some(&b)) => (
Some(Rgb::new(
r.clamp(0, 255) as u8,
g.clamp(0, 255) as u8,
b.clamp(0, 255) as u8,
)),
at + 4,
),
_ => (None, at + 1),
}
}
_ => (None, at + 1),
}
}
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
/// grey ramp.
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
if n < 0 {
palette.foreground
} else if n < 16 {
palette.colours[n as usize]
} else if n < 232 {
let i = (n - 16) as usize;
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
} else if n < 256 {
let grey = (8 + (n - 232) * 10) as u8;
Rgb::new(grey, grey, grey)
} else {
palette.foreground
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
/// white foreground, black background.
fn palette() -> AnsiPalette {
let mut colours = [Rgb::new(0, 0, 0); 16];
for (i, c) in colours.iter_mut().enumerate() {
*c = Rgb::new(i as u8, 0, 0);
}
AnsiPalette {
colours,
foreground: Rgb::new(255, 255, 255),
background: Rgb::new(0, 0, 0),
}
}
fn styled(text: &str) -> StyledText {
ansi_styled(text, &palette())
}
/// The style covering the first character of `word`, or `None` where
/// nothing styles it.
fn style_over(text: &str, word: &str) -> Option<Style> {
let out = styled(text);
let at = out
.text
.find(word)
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
out.spans
.iter()
.find(|(range, _)| range.contains(&at))
.map(|(_, style)| *style)
}
#[test]
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
assert_eq!(styled(&text).text, "plain red plain");
assert_eq!(
style_over(&text, "red").unwrap().color,
Some(Rgb::new(1, 0, 0))
);
assert!(style_over(&text, "plain").is_none());
}
#[test]
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
assert_eq!(
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
Some(Rgb::new(9, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
Some(Rgb::new(4, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
Some(Rgb::new(1, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
Some(Rgb::new(0, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
Some(Rgb::new(255, 255, 255))
);
assert_eq!(
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
.unwrap()
.color,
Some(Rgb::new(10, 20, 30))
);
}
#[test]
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
// A cursor move, an erase, an OSC window title with its bell, and a
// bare two-character escape.
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
assert_eq!(styled(&text).text, "abcde");
}
#[test]
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
}
#[test]
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
}
#[test]
fn unstyled_text_costs_no_spans_at_all() {
assert_eq!(styled("nothing to do here").spans.len(), 0);
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
}
}
+609
View File
@@ -0,0 +1,609 @@
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
//! module doc for the table); the SSE half is [`crate::client::event_stream`].
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
//! which do not.
//!
//! Network I/O sits behind the [`Transport`] trait so the rest of this
//! crate, and anything built on it, can be tested against a fake one with
//! no server involved. [`UreqTransport`] is the only real implementation.
use std::io::Read;
use event_model::SeqEvent;
use serde::Deserialize;
use serde_json::Value;
/// A request that did not produce what it asked for, carrying the server's
/// own wording where it sent some.
///
/// `status` is the HTTP status where there was a response at all, and
/// `None` where the server was never reached -- mirroring `ApiException` in
/// `Api.kt`.
#[derive(Debug, Clone)]
pub struct ApiError {
pub message: String,
pub status: Option<u16>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ApiError {}
/// A request body to send, in whichever of the two shapes the surface
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
pub enum Body {
Json(Value),
Bytes {
content_type: String,
bytes: Vec<u8>,
},
}
/// What a transport hands back for a REST call: the status and the body
/// read whole. A streamed body ([`Transport::stream`]) is a different
/// method because its whole point is not reading it whole.
pub struct RawResponse {
pub status: u16,
pub body: Vec<u8>,
}
/// The network boundary this crate's pure logic is kept out from behind.
/// `server/src/routes.rs`'s module doc is the surface this drives.
pub trait Transport: Send + Sync {
/// One request/response call -- everything but the long-lived SSE GETs.
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError>;
/// Opens `path` and answers a reader over the response body, for a
/// caller that reads it as a stream rather than all at once (the SSE
/// connections in [`crate::client::event_stream`]). Fails the same way
/// [`Transport::request`] does for a non-2xx response.
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
}
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
/// field means and why `setup` is never shown.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub id: String,
pub setup: String,
#[serde(default)]
pub keeps_own_transcript: bool,
pub setup_name: String,
pub provider: String,
pub title: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub permission_mode: Option<String>,
#[serde(default)]
pub imported: bool,
#[serde(default = "default_true")]
pub notify: bool,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub context_tokens: Option<u64>,
#[serde(default)]
pub max_image_edge: Option<u32>,
pub status: String,
pub last_activity: f64,
}
fn default_true() -> bool {
true
}
/// A client-core equivalent of `requestFromServer` plus the typed calls
/// built on it. Holds no state of its own beyond the transport -- the
/// session id or setup id a call is about is a parameter, per this
/// project's "ask for the least you need".
pub struct ApiClient<T: Transport> {
transport: T,
}
impl<T: Transport> ApiClient<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
/// The transport underneath, for a caller that needs the raw SSE
/// stream (`event_stream::follow_session_events`) rather than one of
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
/// is the one that does.
pub fn transport(&self) -> &T {
&self.transport
}
fn json_request<R: for<'de> Deserialize<'de>>(
&self,
method: &str,
path: &str,
body: Option<Value>,
) -> Result<R, ApiError> {
let raw = self.transport.request(method, path, body.map(Body::Json))?;
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
message: format!("Reached the server but couldn't read its response ({e})"),
status: Some(raw.status),
})
}
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
self.transport.request(method, path, body.map(Body::Json))?;
Ok(())
}
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
self.json_request("GET", "/sessions", None)
}
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
self.json_request("GET", &format!("/sessions/{session_id}"), None)
}
pub fn send_message(
&self,
session_id: &str,
text: &str,
attachment_ids: &[String],
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/message"),
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
)
}
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/unqueue"),
Some(serde_json::json!({ "messageId": message_id })),
)
}
pub fn answer_question(
&self,
session_id: &str,
question_id: &str,
answers: &[String],
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/answer"),
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
)
}
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
}
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
}
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
}
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/title"),
Some(serde_json::json!({ "title": title })),
)
}
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/cwd"),
Some(serde_json::json!({ "cwd": cwd })),
)
}
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/model"),
Some(serde_json::json!({ "model": model })),
)
}
pub fn set_session_permission_mode(
&self,
session_id: &str,
mode: &str,
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/permission-mode"),
Some(serde_json::json!({ "permissionMode": mode })),
)
}
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/notify"),
Some(serde_json::json!({ "notify": notify })),
)
}
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/command"),
Some(serde_json::json!({ "text": text })),
)
}
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
}
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
let path = if delete_foreign {
format!("/sessions/{session_id}?deleteForeign=true")
} else {
format!("/sessions/{session_id}")
};
self.empty_request("DELETE", &path, None)
}
/// A page of transcript history. `before` is the newest-first cursor
/// (server default is "the newest page" when absent, which a caller
/// gets by passing `None`); the events themselves are handed back as
/// [`event_model::SeqEvent`] via `crate::client::event_stream`'s parsing, kept
/// out of this method's signature so a caller that only wants the raw
/// lines (for the transcript cache) is not forced to parse them.
pub fn fetch_transcript_page(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
) -> Result<Vec<Value>, ApiError> {
self.json_request(
"GET",
&transcript_path(session_id, before, limit, coalesce, None),
None,
)
}
/// A page of transcript history, each line handed back paired with the
/// exact text it came from, and bounded below by `after` -- the shape
/// `crate::client::transcript_source::TranscriptSource` needs to store what it
/// fetched in the transcript cache without a second round trip to fetch
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
///
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
/// parsed [`Value`], so the stored line is the exact bytes the server
/// sent (key order and float literal included) rather than this
/// crate's own idea of how to write them back out -- the cache and a
/// live SSE frame must agree byte-for-byte on the same event, which is
/// exactly what caught the `serde_json` float-rounding bug this
/// project's `AGENTS.md` records.
pub fn fetch_transcript_lines(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
let path = transcript_path(session_id, before, limit, coalesce, after);
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
raw.into_iter()
.map(|value| {
let line = value.get().to_string();
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
message: format!(
"the server sent a transcript line this build couldn't parse: {e}"
),
status: None,
})?;
Ok((line, event))
})
.collect()
}
}
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
/// parameter is written rather than keeping two copies to drift.
fn transcript_path(
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> String {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before {
path.push_str(&format!("&before={before}"));
}
if coalesce {
path.push_str("&coalesce=true");
}
if let Some(after) = after {
path.push_str(&format!("&after={after}"));
}
path
}
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
/// poll). Verifies the server's leaf against a single pinned CA, the way
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
/// store -- the server's certificate is self-signed on purpose (see
/// `wg-app-link`).
pub struct UreqTransport {
agent: ureq::Agent,
base_url: String,
token: String,
}
impl UreqTransport {
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
/// exactly as read from `certs/ca.pem`.
pub fn new(
base_url: impl Into<String>,
token: impl Into<String>,
ca_pem: &[u8],
) -> Result<Self, ApiError> {
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
message: format!("The pinned CA certificate could not be read: {e}"),
status: None,
})?;
let tls_config = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls_config)
// Read the body ourselves on every status, the way
// `requestFromServer` does: the server's own error wording is
// in the body of a 4xx/5xx, and the default behaviour throws
// it away before this code can read it.
.http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(5)))
.build()
.into();
Ok(Self {
agent,
base_url: base_url.into(),
token: token.into(),
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
}
impl Transport for UreqTransport {
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError> {
let url = self.url(path);
let auth = format!("Bearer {}", self.token);
let mut builder = ureq::http::Request::builder()
.method(method)
.uri(&url)
.header("Authorization", &auth);
let response = match body {
None => builder
.body(())
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req)),
Some(Body::Json(value)) => {
builder = builder.header("Content-Type", "application/json");
builder
.body(serde_json::to_vec(&value).unwrap_or_default())
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req))
}
Some(Body::Bytes {
content_type,
bytes,
}) => {
builder = builder.header("Content-Type", content_type);
builder
.body(bytes)
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req))
}
};
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
let status = response.status().as_u16();
let mut body = Vec::new();
response
.body_mut()
.as_reader()
.read_to_end(&mut body)
.map_err(|e| ApiError {
message: format!("Reached {url} but couldn't read its response ({e})"),
status: Some(status),
})?;
if !(200..300).contains(&status) {
return Err(response_error(status, &body, path));
}
Ok(RawResponse { status, body })
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
let url = self.url(path);
let auth = format!("Bearer {}", self.token);
let response = self
.agent
.get(&url)
.header("Authorization", &auth)
.header("Accept", "text/event-stream")
// No read timeout: between events there is nothing to read for
// as long as the thing being followed is idle, mirroring
// `EventStream.kt`'s `readTimeout = 0`.
.config()
.timeout_recv_response(None)
.build()
.call();
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
let status = response.status().as_u16();
if status != 200 {
let mut body = Vec::new();
let _ = response.body_mut().as_reader().read_to_end(&mut body);
return Err(response_error(status, &body, path));
}
Ok(Box::new(response.into_body().into_reader()))
}
}
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
ApiError {
message: format!(
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
device able to reach that address (WireGuard up)? [{path}]"
),
status: None,
}
}
/// The 401 wording matches `Api.kt`'s, since that message is instructions
/// for the reader rather than a diagnostic -- see this project's UI rule
/// about shortening a failure in one place rather than at each display site.
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
let detail = String::from_utf8_lossy(body).trim().to_string();
let message = if status == 401 {
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
rotate with --rotate-token and scan the new one)."
.to_string()
} else if detail.is_empty() {
format!("Server returned HTTP {status} for {path}")
} else {
detail
};
ApiError {
message,
status: Some(status),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::sync::Mutex;
/// A transport with no network at all, for the pure-logic tests this
/// module can run without a server.
#[derive(Default)]
struct FakeTransport {
responses: Mutex<Vec<(String, String, RawResponse)>>,
}
impl FakeTransport {
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
self.responses.lock().unwrap().push((
method.to_string(),
path.to_string(),
RawResponse {
status,
body: body.as_bytes().to_vec(),
},
));
}
}
impl Transport for FakeTransport {
fn request(
&self,
method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
let mut responses = self.responses.lock().unwrap();
let index = responses
.iter()
.position(|(m, p, _)| m == method && p == path)
.ok_or_else(|| ApiError {
message: format!("no fake response for {method} {path}"),
status: None,
})?;
let (_, _, response) = responses.remove(index);
if !(200..300).contains(&response.status) {
return Err(response_error(response.status, &response.body, path));
}
Ok(response)
}
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(Vec::new())))
}
}
#[test]
fn fetch_sessions_parses_the_list() {
let transport = FakeTransport::default();
transport.respond(
"GET",
"/sessions",
200,
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
"title":"hi","status":"idle","lastActivity":1.0}]"#,
);
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].setup_name, "desktop");
// Defaults for fields the server omits.
assert!(sessions[0].notify);
assert_eq!(sessions[0].model, None);
}
#[test]
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
let transport = FakeTransport::default();
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
let client = ApiClient::new(transport);
let err = client.interrupt_session("s1").unwrap_err();
assert!(err.message.contains("Re-enroll"));
assert_eq!(err.status, Some(401));
}
#[test]
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
let transport = FakeTransport::default();
transport.respond("POST", "/sessions/s1/stop", 500, "");
let client = ApiClient::new(transport);
let err = client.stop_session("s1").unwrap_err();
assert!(err.message.contains("500"));
}
#[test]
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
let transport = FakeTransport::default();
transport.respond(
"POST",
"/sessions/s1/cwd",
409,
"that path does not exist on this machine",
);
let client = ApiClient::new(transport);
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
assert_eq!(err.message, "that path does not exist on this machine");
}
}
+368
View File
@@ -0,0 +1,368 @@
//! What a Rust client needs to reach one enrolled server: host, port and
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
//! the same text a phone would scan as a QR, with no second format
//! invented for it (RUST.md's E4).
//!
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
//! desktop app, the app-private files directory on Android. **Which**
//! directory is the only part left to the platform: the format, the file
//! mode and the "nothing saved yet is not an error" answer are the same on
//! both, and were written twice before this.
//!
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
//! rules (`format`) are for configs a person hand-edits, and this file
//! never is one -- only the app itself writes or reads it.
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
/// with `token` as a bearer header.
///
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
/// because an app built on the machine its server runs on pins the CA at
/// build time and needs nothing from the link; one built elsewhere -- the
/// iris Android client is cross-compiled in a VM and run against the
/// host's server -- has no other way to get it. A public certificate
/// rather than a secret, so it costs the link nothing but length.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledServer {
pub host: String,
pub port: u16,
pub token: String,
/// `#[serde(default)]` so an enrollment saved before this field
/// existed still loads, as the enrolled server it always was.
#[serde(default)]
pub ca_pem: Option<String>,
}
impl EnrolledServer {
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
/// does not matter; unrecognised keys are ignored). `token` is
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
/// a raw token can contain `+`, which turns into a space if left to a
/// naive splitter.
///
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
/// here, because that is what every consumer of it wants
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
/// at). A `ca` that does not decode fails the whole link rather than
/// enrolling a server with no trust anchor: the link said which
/// certificate to pin, and quietly not pinning it is the one outcome
/// nothing downstream could notice.
pub fn parse_link(link: &str) -> Result<Self, String> {
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
format!(
"'{link}' has no query string (expected \
aiapp://enroll?host=...&port=...&token=...)"
)
})?;
let mut host = None;
let mut port = None;
let mut token = None;
let mut ca = None;
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
let value = percent_decode(value);
match key {
"host" => host = Some(value),
"port" => port = Some(value),
"token" => token = Some(value),
"ca" => ca = Some(value),
_ => {}
}
}
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
let port: u16 = port_str
.parse()
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
Ok(Self {
host,
port,
token,
ca_pem,
})
}
/// Where a `crate::client::api::UreqTransport` reaches this server.
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
fn pem_from_link_param(ca: &str) -> Result<String, String> {
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(ca.as_bytes())
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
let body = base64::engine::general_purpose::STANDARD.encode(&der);
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
for line in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE-----\n");
Ok(pem)
}
/// Where one client keeps the enrollment it should not have to be told
/// about a second time. `dir` is the caller's, because that is the only
/// part that differs by platform -- see this module's doc.
pub struct EnrollmentStore {
dir: PathBuf,
}
impl EnrollmentStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn file(&self) -> PathBuf {
self.dir.join("enrollment.json")
}
/// Writes `server` under `dir`, creating it if needed, and sets the
/// file owner-only -- it carries a bearer token, the same reason
/// `server/`'s own token store is 0600.
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let path = self.file();
let json = serde_json::to_vec_pretty(server)
.expect("EnrolledServer holds nothing that fails to serialise");
std::fs::write(&path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
/// -- "not enrolled" is an ordinary first-run state, not a failure
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
/// just as well to a file that simply hasn't been written yet).
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
let path = self.file();
match std::fs::read(&path) {
Ok(bytes) => {
let server = serde_json::from_slice(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is not a valid enrollment ({e})", path.display()),
)
})?;
Ok(Some(server))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_host_port_and_token() {
let server =
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
.unwrap();
assert_eq!(
server,
EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "abcDEF123".to_string(),
ca_pem: None,
}
);
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
}
#[test]
fn field_order_does_not_matter() {
let server =
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
.unwrap();
assert_eq!(server.host, "example.com");
assert_eq!(server.port, 443);
assert_eq!(server.token, "tok");
}
#[test]
fn a_percent_encoded_token_is_decoded() {
// ui-sandbox.sh's own reason for encoding: a raw '+' would
// otherwise arrive as a space.
let server =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
assert_eq!(server.token, "a+b/c");
}
#[test]
fn a_missing_field_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
assert!(
err.contains("token"),
"error should name the missing field: {err}"
);
}
/// The CA travels as base64url of the DER and comes back out as the
/// PEM every consumer of it wants -- the same round trip
/// `wg_app_link::enroll::ca_param` mints.
#[test]
fn a_ca_in_the_link_comes_back_as_pem() {
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
let server =
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
.unwrap();
let pem = server.ca_pem.expect("the link carried a CA");
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
assert!(
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
"{pem}"
);
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(
pem.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<String>()
)
.unwrap(),
der
);
}
/// A link with no `ca` is an ordinary link, not a broken one: an app
/// that pins at build time mints and reads exactly these.
#[test]
fn no_ca_parameter_is_none_not_an_error() {
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
assert_eq!(server.ca_pem, None);
}
/// The half that cannot be noticed later: a `ca` that does not decode
/// must fail the link rather than enrolling with nothing pinned.
#[test]
fn a_ca_that_does_not_decode_fails_the_link() {
let err =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
.unwrap_err();
assert!(err.contains("ca"), "{err}");
}
#[test]
fn a_saved_enrollment_reads_back_the_same() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
let server = EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "tok".to_string(),
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
};
store.save(&server).unwrap();
assert_eq!(store.load().unwrap(), Some(server));
}
#[test]
fn nothing_saved_yet_is_none_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
}
/// An enrollment written before `ca_pem` existed still loads.
#[test]
fn an_enrollment_without_a_ca_still_loads() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(
dir.path().join("enrollment.json"),
br#"{"host":"h","port":1,"token":"t"}"#,
)
.unwrap();
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
}
#[test]
#[cfg(unix)]
fn the_saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
store
.save(&EnrolledServer {
host: "h".to_string(),
port: 1,
token: "t".to_string(),
ca_pem: None,
})
.unwrap();
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn a_corrupt_file_is_named_in_the_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
assert!(err.to_string().contains("enrollment.json"));
}
#[test]
fn a_non_numeric_port_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
assert!(
err.contains("port"),
"error should name the offending field: {err}"
);
}
}
+100
View File
@@ -0,0 +1,100 @@
//! A span of milliseconds, written the way somebody reads it -- the port
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
//!
//! Only the tool-timeout half is here. `formatSpan` (the usage
//! countdown's rounding-up rule) belongs with whatever draws the usage
//! bar, and nothing in this crate needs it yet.
/// A span of milliseconds, written the way somebody reads it.
///
/// A tool's timeout arrives as `480000`, which nobody reads as eight
/// minutes. The rule has two halves, because a short span and a long one
/// are read for different things. Under a minute the question is "roughly
/// how long", so only the largest unit is shown and a fraction carries the
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
/// units are left out rather than written as zero.
///
/// Sub-second precision is dropped past a minute: nothing that takes days
/// is measured in milliseconds.
pub fn format_millis(ms: i64) -> String {
if ms < 0 {
return format!("-{}", format_millis(-ms));
}
if ms < 1000 {
return format!("{ms}ms");
}
if ms < 60_000 {
let tenths = (ms + 50) / 100;
let (whole, rest) = (tenths / 10, tenths % 10);
return if rest == 0 {
format!("{whole}s")
} else {
format!("{whole}.{rest}s")
};
}
let seconds = ms / 1000;
[
("d", seconds / 86_400),
("h", seconds / 3600 % 24),
("m", seconds / 60 % 60),
("s", seconds % 60),
]
.iter()
.filter(|(_, n)| *n > 0)
.map(|(unit, n)| format!("{n}{unit}"))
.collect::<Vec<_>>()
.join(" ")
}
/// `text` as a span when it is a whole number of milliseconds, and
/// unchanged when it is not.
pub fn format_millis_text(text: &str) -> String {
match text.trim().parse::<i64>() {
Ok(ms) => format_millis(ms),
Err(_) => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The two ways a span of time is written here, and the rule each of
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
/// both are read off a screen to make a decision, so what matters is
/// that the shortest form that answers the question is what appears.
#[test]
fn under_a_minute_is_the_largest_unit_alone() {
assert_eq!(format_millis(30), "30ms");
assert_eq!(format_millis(999), "999ms");
assert_eq!(format_millis(1000), "1s");
assert_eq!(format_millis(2500), "2.5s");
// One decimal, rounded rather than cut: 2.46s is nearer two and a
// half than two and four.
assert_eq!(format_millis(2460), "2.5s");
assert_eq!(format_millis(59_900), "59.9s");
}
#[test]
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
// The figure this rule was written for: a tool timeout, which
// arrives as milliseconds and is unreadable as 480000.
assert_eq!(format_millis(480_000), "8m");
assert_eq!(format_millis(60_000), "1m");
assert_eq!(format_millis(90_000), "1m 30s");
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
// Empty units are left out rather than written as zero: the labels
// say which is which, and "5d 0h 4m" is only longer.
assert_eq!(format_millis(432_240_000), "5d 4m");
}
#[test]
fn only_a_whole_number_of_milliseconds_is_rewritten() {
assert_eq!(format_millis_text(" 480000 "), "8m");
// A timeout a tool expressed some other way is its own words,
// passed through rather than guessed at.
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
assert_eq!(format_millis_text(""), "");
}
}
+142
View File
@@ -0,0 +1,142 @@
//! The SSE half of the API: one long-lived GET per open session screen,
//! replaying the transcript after a cursor and then following it live.
//! Ported from `app/.../EventStream.kt`; the framing itself is
//! [`crate::client::sse`].
use std::io::{BufRead, BufReader};
use event_model::SeqEvent;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
/// The frame name the server uses to say a cursor was too far behind to
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
const RESET_EVENT: &str = "reset";
/// One frame of a session's event stream, folded from the wire shape the
/// caller needs to act on -- mirroring what `EventStream.kt`'s three
/// callbacks were for, as a single enum instead, since Rust has no
/// equivalent of handing three closures to one blocking call.
pub enum StreamItem {
/// The connection was accepted; the measured moment the stream is live
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
/// event, is what clears a previous failure on screen).
Open,
/// The cursor was too far behind to continue from: everything already
/// displayed is stale, and the events that follow are a fresh window.
/// Arrives before those events, so a caller that clears on it stays in
/// order.
Reset,
/// One event, as both the raw line the transcript cache stores and the
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
/// line, so both travel together rather than being parsed twice from
/// two call sites.
Event { raw: String, event: SeqEvent },
}
/// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for
/// each [`StreamItem`] until the connection drops or `on_item` asks to
/// stop (by returning `false`). Reconnecting -- with the last seq seen as
/// the new cursor -- is the caller's job, same as in the Kotlin version.
pub fn follow_session_events(
transport: &dyn Transport,
session_id: &str,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let path = format!("/sessions/{session_id}/events?after={after}");
let body = transport.stream(&path)?;
if !on_item(StreamItem::Open) {
return Ok(());
}
let mut lines = BufReader::new(body).lines();
let mut reader = SseReader::new();
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
message: format!("Can't reach the server -- retrying. ({e})"),
status: None,
})? {
let Some(frame) = reader.feed_line(&line) else {
continue;
};
// A named frame carries no payload and a data frame has no name.
if frame.name.as_deref() == Some(RESET_EVENT) {
if !on_item(StreamItem::Reset) {
return Ok(());
}
} else if !frame.data.is_empty() {
let event: SeqEvent = serde_json::from_str(&frame.data).map_err(|e| ApiError {
message: format!("The server sent an event this build couldn't parse: {e}"),
status: None,
})?;
if !on_item(StreamItem::Event {
raw: frame.data,
event,
}) {
return Ok(());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
body: &'static str,
}
impl Transport for FixtureTransport {
fn request(
&self,
_method: &str,
_path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
unimplemented!("this fixture only serves a stream")
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
}
}
#[test]
fn events_and_a_reset_frame_are_told_apart() {
let transport = FixtureTransport {
body: "event:reset\n\ndata:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
};
let mut items = Vec::new();
follow_session_events(&transport, "s1", 0, |item| {
items.push(match item {
StreamItem::Open => "open".to_string(),
StreamItem::Reset => "reset".to_string(),
StreamItem::Event { event, .. } => format!("event:{}", event.seq),
});
true
})
.unwrap();
assert_eq!(items, vec!["open", "reset", "event:1"]);
}
#[test]
fn the_caller_can_stop_early() {
let transport = FixtureTransport {
body: "data:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n\
data:{\"seq\":2,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
};
let mut count = 0;
follow_session_events(&transport, "s1", 0, |item| {
if matches!(item, StreamItem::Event { .. }) {
count += 1;
}
count < 1
})
.unwrap();
assert_eq!(count, 1);
}
}
+581
View File
@@ -0,0 +1,581 @@
//! A language the highlighter can colour, and the data-driven [`Rules`] each
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
//! why nearly every language is a row of data read by one shared scanner,
//! with Markdown the one exception (`super::markdown`).
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
C,
Coffeescript,
Cpp,
Csharp,
Dart,
Fish,
Go,
Java,
Javascript,
Json,
Kotlin,
Markdown,
Perl,
Php,
Python,
Ron,
Ruby,
Rust,
Shell,
Swift,
Toml,
Typescript,
}
impl Language {
/// Every value, for the same exhaustiveness check the Kotlin test runs
/// (`Language.entries`).
pub const ALL: [Language; 22] = [
Language::C,
Language::Coffeescript,
Language::Cpp,
Language::Csharp,
Language::Dart,
Language::Fish,
Language::Go,
Language::Java,
Language::Javascript,
Language::Json,
Language::Kotlin,
Language::Markdown,
Language::Perl,
Language::Php,
Language::Python,
Language::Ron,
Language::Ruby,
Language::Rust,
Language::Shell,
Language::Swift,
Language::Toml,
Language::Typescript,
];
}
/// What [`super::scan`] needs to know about one language -- data, not code,
/// so that adding a language is a row here rather than a branch anywhere.
#[derive(Debug, Clone, Default)]
pub struct Rules {
/// Words drawn as keywords. Only plain words; the scanner cannot reach
/// anything else.
pub keywords: HashSet<&'static str>,
/// Tokens that open a comment running to the end of the line.
pub line_comments: Vec<&'static str>,
/// Whether `line_comments` count only at the start of a word. The shells
/// need it: `$#`, `${#x}` and `a#b` are not comments.
pub line_comments_at_word_start: bool,
pub block_comment: Option<BlockComment>,
/// The string forms. The longest opener that matches wins, so `"""` is
/// tried before `"`.
pub quotes: Vec<Quote>,
pub attributes: Attributes,
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
pub raw_strings: bool,
/// Rust: `'` opens a character literal only when a backslash or one
/// character and a `'` follow. Otherwise it is a lifetime or a label.
pub lifetimes: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct BlockComment {
pub open: &'static str,
pub close: &'static str,
pub nests: bool,
}
/// One string form. `escapes` is whether a backslash escapes the closer
/// (and itself).
#[derive(Debug, Clone, Copy)]
pub struct Quote {
pub open: &'static str,
pub close: &'static str,
pub escapes: bool,
}
/// What opens a metadata span, of the shapes that exist across these languages.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Attributes {
#[default]
None,
/// `@` and a word: Kotlin and Java annotations, Python decorators.
AtWord,
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
HashBracket,
/// `#` at the start of a line, to the end of it: the C preprocessor.
HashLine,
/// `[` at the start of a line through the matching `]`: a TOML table header.
LineBracket,
}
const C_STYLE: BlockComment = BlockComment {
open: "/*",
close: "*/",
nests: false,
};
const NESTING: BlockComment = BlockComment {
open: "/*",
close: "*/",
nests: true,
};
const DOUBLE: Quote = Quote {
open: "\"",
close: "\"",
escapes: true,
};
const SINGLE: Quote = Quote {
open: "'",
close: "'",
escapes: true,
};
const TRIPLE_DOUBLE: Quote = Quote {
open: "\"\"\"",
close: "\"\"\"",
escapes: true,
};
const TRIPLE_SINGLE: Quote = Quote {
open: "'''",
close: "'''",
escapes: true,
};
fn words(list: &'static str) -> HashSet<&'static str> {
list.split_whitespace().collect()
}
/// The rules for one language. A `match` rather than a lazily-built map --
/// there is no once-per-process cost worth paying for in a language table
/// this small, and it sidesteps the Kotlin version's own workaround for
/// property initialization order.
pub fn rules_for(language: Language) -> Rules {
match language {
Language::C => Rules {
keywords: words(KEYWORDS_C),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashLine,
..Default::default()
},
Language::Cpp => Rules {
keywords: words(KEYWORDS_CPP),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashLine,
..Default::default()
},
Language::Csharp => Rules {
keywords: words(KEYWORDS_CSHARP),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
// `###` opens and closes a block comment and `#` opens a line one,
// which is why the scanner tries the block opener first.
Language::Coffeescript => Rules {
keywords: words(KEYWORDS_COFFEESCRIPT),
line_comments: vec!["#"],
block_comment: Some(BlockComment {
open: "###",
close: "###",
nests: false,
}),
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
..Default::default()
},
Language::Dart => Rules {
keywords: words(KEYWORDS_DART),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Fish => Rules {
keywords: words(KEYWORDS_FISH),
line_comments: vec!["#"],
line_comments_at_word_start: true,
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Go => Rules {
keywords: words(KEYWORDS_GO),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: false,
},
],
..Default::default()
},
Language::Java => Rules {
keywords: words(KEYWORDS_JAVA),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Javascript => Rules {
keywords: words(KEYWORDS_JAVASCRIPT),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: true,
},
],
..Default::default()
},
Language::Json => Rules {
keywords: words(KEYWORDS_JSON),
quotes: vec![DOUBLE],
..Default::default()
},
Language::Kotlin => Rules {
keywords: words(KEYWORDS_KOTLIN),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![
Quote {
open: "\"\"\"",
close: "\"\"\"",
escapes: false,
},
DOUBLE,
SINGLE,
],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Perl => Rules {
keywords: words(KEYWORDS_PERL),
line_comments: vec!["#"],
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Php => Rules {
keywords: words(KEYWORDS_PHP),
line_comments: vec!["//", "#"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Python => Rules {
keywords: words(KEYWORDS_PYTHON),
line_comments: vec!["#"],
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Ron => Rules {
keywords: words(KEYWORDS_RON),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashBracket,
raw_strings: true,
..Default::default()
},
Language::Ruby => Rules {
keywords: words(KEYWORDS_RUBY),
line_comments: vec!["#"],
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Rust => Rules {
keywords: words(KEYWORDS_RUST),
line_comments: vec!["//"],
block_comment: Some(NESTING),
// No `'` here: `lifetimes` decides when one opens a character literal.
quotes: vec![DOUBLE],
attributes: Attributes::HashBracket,
raw_strings: true,
lifetimes: true,
..Default::default()
},
Language::Shell => Rules {
keywords: words(KEYWORDS_SHELL),
line_comments: vec!["#"],
line_comments_at_word_start: true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes: vec![
DOUBLE,
Quote {
open: "'",
close: "'",
escapes: false,
},
],
..Default::default()
},
Language::Swift => Rules {
keywords: words(KEYWORDS_SWIFT),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Toml => Rules {
keywords: words(KEYWORDS_TOML),
line_comments: vec!["#"],
quotes: vec![
TRIPLE_DOUBLE,
Quote {
open: "'''",
close: "'''",
escapes: false,
},
DOUBLE,
Quote {
open: "'",
close: "'",
escapes: false,
},
],
attributes: Attributes::LineBracket,
..Default::default()
},
Language::Typescript => Rules {
keywords: words(KEYWORDS_TYPESCRIPT),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: true,
},
],
attributes: Attributes::AtWord,
..Default::default()
},
// Markdown has no token rules; see `super::markdown::scan_markdown`.
Language::Markdown => Rules::default(),
}
}
// The keyword sets. Every list below other than RON, TOML, fish and JSON
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
// Kotlin scanner replaced, so that no fence which was coloured there turns
// plain here either.
const KEYWORDS_C: &str =
"auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned
void volatile while";
const KEYWORDS_CPP: &str =
"asm auto bool break case catch char class const const_cast continue default delete do
double dynamic_cast else enum explicit export extern false float for friend goto if inline
int long mutable namespace new operator private protected public register reinterpret_cast
return short signed sizeof static static_cast struct switch template this throw true try
typedef typeid typename union unsigned using virtual void volatile wchar_t while";
const KEYWORDS_CSHARP: &str =
"abstract as base bool break byte case catch char checked class const continue decimal
default delegate do double else enum event explicit extern false finally fixed float for
foreach goto if implicit in int interface internal is lock long namespace new null object
operator out override params private protected public readonly ref return sbyte sealed short
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
unsafe ushort using virtual void volatile while";
const KEYWORDS_COFFEESCRIPT: &str =
"Infinity NaN and arguments await break by case catch class continue debugger delete defer
default do else export extends false finally for function if import in instanceof is isnt
let loop new no not null of on or package return super switch this throw true try typeof
unless undefined var wait when with yield";
const KEYWORDS_DART: &str =
"abstract as assert async await base break case catch class const continue covariant
default deferred do dynamic else enum export extends external factory false final finally
for get if implements import in interface is late library mixin new null on operator part
required rethrow return sealed set show static super switch this throw true try var void
when with while yield";
/// fish is not in the library at all, so its fences are drawn plain today.
/// The list is the shell's own words, which is what a fish fence is mostly
/// made of.
const KEYWORDS_FISH: &str =
"and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source";
const KEYWORDS_GO: &str =
"break case chan const continue default defer else fallthrough false for func go goto if
import interface map package range return select struct switch true type var";
const KEYWORDS_JAVA: &str =
"abstract assert boolean break byte case catch char class const continue default do double
else enum extends final finally float for goto if implements import instanceof int interface
long native new null package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while";
const KEYWORDS_JAVASCRIPT: &str =
"async await boolean break case catch class const continue debugger default delete do else
enum export extends false finally for function if implements import in instanceof interface
let new null package private protected public return super switch this throw true try typeof
var void while with yield";
const KEYWORDS_JSON: &str = "true false null";
const KEYWORDS_KOTLIN: &str =
"actual abstract annotation as break by catch class companion const constructor continue
coroutine crossinline data delegate dynamic do else enum expect external false final finally
for fun get if import in infix inline interface internal is lazy lateinit native null object
open operator out override package private protected public reified return sealed set super
suspend tailrec this throw true try typealias typeof val var vararg when while yield";
const KEYWORDS_PERL: &str =
"__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
use while xor";
const KEYWORDS_PHP: &str =
"__halt_compiler abstract and array as break callable case catch class clone const continue
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
endwhile eval exit extends final finally fn for foreach function global goto if implements
include include_once instanceof insteadof interface isset list match new or print private
protected public require require_once return static switch throw trait try unset use var
while xor yield";
const KEYWORDS_PYTHON: &str =
"False True and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with
yield";
/// RON is not in the library either; these are the words a RON file can hold.
const KEYWORDS_RON: &str = "true false Some None inf NaN";
const KEYWORDS_RUBY: &str =
"__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
else elsif end ensure false for if in module next nil not or redo rescue retry return self
super then true undef unless until when while yield";
const KEYWORDS_RUST: &str =
"as async await break const continue crate dyn else enum extern false fn for if impl in
let loop match mod move mut pub ref return Self self static struct super trait true type
union unsafe use where while abstract become box do final macro override priv try typeof
unsized virtual yield";
const KEYWORDS_SHELL: &str =
"alias bg bind break builtin caller cd command compgen complete compopt continue declare
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
test";
const KEYWORDS_SWIFT: &str =
"_ associatedtype class deinit enum extension fileprivate func import init inout internal
let open operator private precedencegroup protocol public rethrows static struct subscript
typealias var break case catch continue default defer do else fallthrough for guard if in
repeat return throw switch where while Any as await false is nil self Self super throws true
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet";
/// TOML is not in the library; `inf` and `nan` are values rather than
/// names, like the booleans.
const KEYWORDS_TOML: &str = "true false inf nan";
const KEYWORDS_TYPESCRIPT: &str =
"abstract as asserts await break case catch class const constructor continue debugger
default delete do else enum export extends false finally for from function get if implements
import in infer instanceof interface is keyof let module namespace new null number object
package private protected public readonly require global return set static string super
switch this throw true try type typeof undefined unique unknown var void while with yield";
/// The highlighter's language for a fence's info word, or `None` for one it
/// has no rules for. Also what `super::file_language` reads for a file's
/// extension -- one table, so a language added for fences is a language
/// added for files.
pub fn fence_language(name: Option<&str>) -> Option<Language> {
let name = name?.trim().to_lowercase();
FENCE_LANGUAGES
.iter()
.find(|(alias, _)| *alias == name)
.map(|(_, language)| *language)
}
/// The highlighter's language for a *file*, from its name.
///
/// The extension is the part after the *last* dot, which is what makes
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
/// extension, it has a name that starts with a dot. A name with no dot at
/// all -- `Makefile` -- is likewise `None`.
pub fn file_language(name: &str) -> Option<Language> {
let dot = name.rfind('.')?;
if dot < 1 {
return None;
}
fence_language(Some(&name[dot + 1..]))
}
const FENCE_LANGUAGES: &[(&str, Language)] = &[
("kotlin", Language::Kotlin),
("kt", Language::Kotlin),
("kts", Language::Kotlin),
("rust", Language::Rust),
("rs", Language::Rust),
("sh", Language::Shell),
("bash", Language::Shell),
("shell", Language::Shell),
("zsh", Language::Shell),
("console", Language::Shell),
("python", Language::Python),
("py", Language::Python),
("javascript", Language::Javascript),
("js", Language::Javascript),
("jsx", Language::Javascript),
("typescript", Language::Typescript),
("ts", Language::Typescript),
("tsx", Language::Typescript),
("java", Language::Java),
("c", Language::C),
("h", Language::C),
("cpp", Language::Cpp),
("c++", Language::Cpp),
("cc", Language::Cpp),
("hpp", Language::Cpp),
("csharp", Language::Csharp),
("cs", Language::Csharp),
("c#", Language::Csharp),
("go", Language::Go),
("golang", Language::Go),
("swift", Language::Swift),
("dart", Language::Dart),
("ruby", Language::Ruby),
("rb", Language::Ruby),
("php", Language::Php),
("perl", Language::Perl),
("pl", Language::Perl),
("coffeescript", Language::Coffeescript),
("coffee", Language::Coffeescript),
("ron", Language::Ron),
("toml", Language::Toml),
("fish", Language::Fish),
("json", Language::Json),
("markdown", Language::Markdown),
("md", Language::Markdown),
];
+680
View File
@@ -0,0 +1,680 @@
//! Markdown read into the spans that carry a colour -- a ```markdown fence
//! in a reply, and a `.md` file in the viewer. Ported from
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
//! scanner rather than a row of [`super::Rules`] (what a character means
//! depends on where it sits, not on what it is) and why an indented code
//! block is deliberately not recognised.
//!
//! Structure is read a line at a time and each line's prose left to right,
//! except the two decisions that are not: a fenced block is state carried
//! forward, and a table is found by its delimiter row, which comes after
//! the header it belongs to (the one place here that looks ahead).
use super::{Kind, Span};
/// The characters an unordered list may be bulleted with.
const BULLETS: &str = "-*+";
/// The characters a thematic break, or a setext heading's underline, can be
/// drawn with.
const RULE_MARKERS: &str = "-*_=";
/// The characters that can open emphasis, strong emphasis or a strikethrough.
const EMPHASIS: &str = "*_~";
/// Characters that end a bare URL wherever they appear, and ones only
/// trimmed off the end.
const URL_STOPS: &str = "<>\"'`|";
const URL_TRAILING: &str = ".,:;!?";
pub fn scan_markdown(code: &str) -> Vec<Span> {
MarkdownScanner::new(code).run()
}
struct MarkdownScanner {
code: Vec<char>,
spans: Vec<Span>,
}
impl MarkdownScanner {
fn new(code: &str) -> Self {
Self {
code: code.chars().collect(),
spans: Vec::new(),
}
}
fn run(mut self) -> Vec<Span> {
let mut at = 0usize;
// The delimiter run that opened the fenced block we are inside, or
// None between them.
let mut fence: Option<Vec<char>> = None;
// Whether the row above was part of a table, which is what makes
// this one a body row.
let mut table = false;
loop {
let end = self.line_end(at);
if let Some(open) = fence.clone() {
// The content and the closing line alike: a fence is one
// block of code, and its own delimiters belong to it the
// way a string's quotes belong to the string.
self.emit(at, end, Kind::String);
if self.closes_fence(at, end, &open) {
fence = None;
}
} else {
let opened = self.opens_fence(at, end);
if opened.is_some() {
table = false;
fence = opened;
} else {
table = self.row(at, end, table);
}
}
if end == self.code.len() {
break;
}
at = end + 1;
}
self.spans
}
/// The end of the line beginning at `at`: the newline, or the end of the text.
fn line_end(&self, at: usize) -> usize {
self.code[at..]
.iter()
.position(|&c| c == '\n')
.map(|p| at + p)
.unwrap_or(self.code.len())
}
/// One line that is not inside a fence, and whether the table it may be
/// part of is still open.
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
if self.table_delimiter(start, end) {
let indented = self.indented(start, end);
self.emit(indented, end, Kind::Mark);
return true;
}
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
if (table || header) && self.has_pipe(start, end) {
self.table_row(start, end);
return true;
}
self.structure(start, end);
false
}
/// A line of nothing but pipes, dashes, alignment colons and space, with
/// one of each needed.
fn table_delimiter(&self, start: usize, end: usize) -> bool {
let mut dashes = false;
let mut pipes = false;
for c in &self.code[self.indented(start, end)..end] {
match c {
'-' => dashes = true,
'|' => pipes = true,
':' | ' ' | '\t' => {}
_ => return false,
}
}
dashes && pipes
}
fn has_pipe(&self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end {
if self.code[at] == '\\' {
at += 2;
} else if self.code[at] == '|' {
return true;
} else {
at += 1;
}
}
false
}
/// A table row: the pipes are the structure, and what is between them is prose.
fn table_row(&mut self, start: usize, end: usize) {
let mut at = self.indented(start, end);
let mut cell = at;
while at < end {
match self.code[at] {
'\\' => at += 2,
'|' => {
self.inline(cell, at);
self.emit(at, at + 1, Kind::Mark);
at += 1;
cell = at;
}
_ => at += 1,
}
}
self.inline(cell, end);
}
/// Spans, coalesced with the one before when they touch and agree.
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
if end <= start {
return;
}
if let Some(last) = self.spans.last_mut()
&& last.kind == kind
&& last.end == start
{
last.end = end;
return;
}
self.spans.push(Span { start, end, kind });
}
/// The first character of the line at or after `start` that is not indentation.
fn indented(&self, start: usize, end: usize) -> usize {
let mut at = start;
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
at += 1;
}
at
}
/// The run of backticks or tildes that could open or close a fence on
/// this line, or `None`.
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
let at = self.indented(start, end);
if at == end {
return None;
}
let marker = self.code[at];
if marker != '`' && marker != '~' {
return None;
}
let mut run = at;
while run < end && self.code[run] == marker {
run += 1;
}
if run - at >= 3 { Some((at, run)) } else { None }
}
/// Draws an opening fence line and answers its delimiter, or `None` if
/// this is not one.
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
let (run_start, run_end) = self.fence_run(start, end)?;
self.emit(run_start, run_end, Kind::String);
// The info word is what the fence is a fence *of*, which is
// metadata about the block rather than part of it.
let indented = self.indented(run_end, end);
self.emit(indented, end, Kind::Metadata);
Some(self.code[run_start..run_end].to_vec())
}
/// Whether this line closes a fence opened by `open`: the same
/// character, at least as many of them, and nothing else on the line.
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
let Some((run_start, run_end)) = self.fence_run(start, end) else {
return false;
};
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
return false;
}
self.indented(run_end, end) == end
}
/// One ordinary line: what its opening characters make it, and then its prose.
fn structure(&mut self, start: usize, end: usize) {
let mut at = start;
// Quote markers come before everything else and can be several
// deep, and what follows one is an ordinary line again -- a heading
// inside a quote is still a heading.
while at < end && self.code[at] == '>' {
at += 1;
self.emit(at - 1, at, Kind::Mark);
at = self.indented(at, end);
}
if at == end {
return;
}
if self.heading(at, end) || self.thematic_break(at, end) {
return;
}
let text_start = self.bullet(at, end);
self.inline(text_start, end);
}
/// `#` to `######` and a space. Without the space it is a word
/// beginning with a hash.
fn heading(&mut self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end && self.code[at] == '#' {
at += 1;
}
let depth = at - start;
if !(1..=6).contains(&depth) {
return false;
}
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
return false;
}
self.emit(start, end, Kind::Keyword);
true
}
/// A line made of one repeated rule character and nothing else.
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
let marker = self.code[start];
if !RULE_MARKERS.contains(marker) {
return false;
}
let mut seen = 0usize;
for &c in &self.code[start..end] {
if c == marker {
seen += 1;
} else if !c.is_whitespace() {
return false;
}
}
if seen < if marker == '=' { 1 } else { 3 } {
return false;
}
self.emit(start, end, Kind::Mark);
true
}
/// Draws a list marker if the line opens with one, and answers where
/// the item's text starts.
fn bullet(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
self.emit(start, start + 1, Kind::Mark);
return self.indented(start + 1, end);
}
let mut digits = start;
while digits < end && self.code[digits].is_ascii_digit() {
digits += 1;
}
let delimiter = self.code.get(digits).copied();
if digits > start
&& (delimiter == Some('.') || delimiter == Some(')'))
&& self.space_or_end(digits + 1, end)
{
self.emit(start, digits + 1, Kind::Mark);
return self.indented(digits + 1, end);
}
start
}
fn space_or_end(&self, at: usize, end: usize) -> bool {
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
}
/// The inline forms, left to right. Every branch answers a position
/// strictly after `start` of its call, so this terminates.
fn inline(&mut self, start: usize, end: usize) {
let mut at = start;
while at < end {
let c = self.code[at];
at = if c == '\\' {
// A backslash takes the character after it out of the
// running entirely, which is how `\*` stays an asterisk
// rather than opening emphasis.
at + 2
} else if c == '`' {
self.code_span(at, end)
} else if c == '[' {
self.link(at, at, end)
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
self.link(at, at + 1, end)
} else if c == '<' {
self.autolink(at, end)
} else if EMPHASIS.contains(c) {
self.emphasis(at, end)
} else {
self.url(at, end).unwrap_or(at + 1)
};
}
}
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
fn code_span(&mut self, start: usize, end: usize) -> usize {
let mut open = start;
while open < end && self.code[open] == '`' {
open += 1;
}
let ticks = open - start;
let mut at = open;
while at < end {
if self.code[at] != '`' {
at += 1;
continue;
}
let mut close = at;
while close < end && self.code[close] == '`' {
close += 1;
}
if close - at == ticks {
self.emit(start, close, Kind::String);
return close;
}
at = close;
}
// Nothing closes it on this line, so those were ordinary backticks.
open
}
/// `[text](destination)`, and the same with a leading `!` for an image.
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
let mut depth = 0i32;
let mut close = bracket;
while close < end {
match self.code[close] {
'\\' => close += 1,
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
close += 1;
}
if close >= end {
return start + 1;
}
let destination = close + 1;
if self.code.get(destination) != Some(&'(') {
return start + 1;
}
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
return start + 1;
};
let paren = destination + paren_rel;
if paren >= end {
return start + 1;
}
self.emit(start, bracket + 1, Kind::Mark);
self.inline(bracket + 1, close);
self.emit(close, destination, Kind::Mark);
self.emit(destination, paren + 1, Kind::Metadata);
paren + 1
}
/// `<https://example.com>` and `<name@example.com>`, drawn as the
/// destination they are.
fn autolink(&mut self, start: usize, end: usize) -> usize {
let mut at = start + 1;
let mut addressed = false;
while at < end {
let c = self.code[at];
if c.is_whitespace() || c == '<' {
return start + 1;
}
if c == '>' {
if !addressed {
return start + 1;
}
self.emit(start, at + 1, Kind::Metadata);
return at + 1;
}
if c == ':' || c == '@' {
addressed = true;
}
at += 1;
}
start + 1
}
/// A bare `scheme://...` written in prose, or `None` if one does not
/// start here.
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
if start > 0 && is_word(self.code[start - 1]) {
return None;
}
let mut scheme = start;
while scheme < end && self.code[scheme].is_alphabetic() {
scheme += 1;
}
if scheme == start || !starts_with(&self.code, scheme, "://") {
return None;
}
let body = scheme + 3;
let mut at = body;
let mut openers = 0i32;
let mut closers = 0i32;
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
if self.code[at] == '(' {
openers += 1;
} else if self.code[at] == ')' {
closers += 1;
}
at += 1;
}
while at > body {
let last = self.code[at - 1];
if URL_TRAILING.contains(last) {
at -= 1;
} else if last == ')' && closers > openers {
closers -= 1;
at -= 1;
} else {
break;
}
}
if at == body {
return None;
}
self.emit(start, at, Kind::Metadata);
Some(at)
}
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
/// all.
fn emphasis(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
let mut open = start;
while open < end && self.code[open] == marker {
open += 1;
}
let length = open - start;
if marker == '~' && length != 2 {
return open;
}
if length > 3 {
return open;
}
if open == end || self.code[open].is_whitespace() {
return open;
}
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
return open;
}
let mut at = open;
while at < end {
if self.code[at] == '\\' {
at += 2;
continue;
}
if self.code[at] != marker {
at += 1;
continue;
}
let mut close = at;
while close < end && self.code[close] == marker {
close += 1;
}
let finish = at + length;
if close - at >= length
&& !self.code[at - 1].is_whitespace()
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
{
self.emit(start, finish, Kind::Literal);
return finish;
}
at = close;
}
open
}
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
return false;
}
code[at..at + token.len()] == token[..]
}
#[cfg(test)]
mod tests {
use super::super::{Kind, Language, span_text, spans_of};
fn spans(code: &str, kind: Kind) -> Vec<String> {
let chars: Vec<char> = code.chars().collect();
spans_of(code, Language::Markdown)
.into_iter()
.filter(|s| s.kind == kind)
.map(|s| span_text(&chars, &s))
.collect()
}
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
}
#[test]
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
let code = "## Layout\nissue #12 is fixed\n#hashtag";
assert_spans(code, Kind::Keyword, &["## Layout"]);
}
#[test]
fn seven_hashes_are_not_a_heading() {
assert_spans("####### deep", Kind::Keyword, &[]);
}
#[test]
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
let code = "text\n```kotlin\nval x = 1\n```\nmore";
assert_spans(code, Kind::Metadata, &["kotlin"]);
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
}
#[test]
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
let code = "````\n```\n# not a heading\n````\nafter";
assert_spans(code, Kind::Keyword, &[]);
assert_spans(
code,
Kind::String,
&["````", "```", "# not a heading", "````"],
);
}
#[test]
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
}
#[test]
fn list_markers_and_quote_markers_colour_without_their_text() {
let code = "- one\n2. two\n> quoted";
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
}
#[test]
fn a_rule_and_a_setext_underline_are_the_same_mark() {
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
}
#[test]
fn emphasis_needs_something_on_both_sides_of_it() {
assert_spans(
"**bold** and *thin*",
Kind::Literal,
&["**bold**", "*thin*"],
);
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
}
#[test]
fn an_underscore_inside_a_word_emphasises_nothing() {
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
}
#[test]
fn a_code_span_holds_a_backtick_when_opened_with_two() {
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
}
#[test]
fn an_unclosed_code_span_is_ordinary_text() {
assert_spans("a ` b", Kind::String, &[]);
}
#[test]
fn a_link_marks_its_brackets_and_colours_its_destination() {
let code = "see [the plan](PLAN.md) now";
assert_spans(code, Kind::Mark, &["[", "]"]);
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
}
#[test]
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
assert_spans(
code,
Kind::Mark,
&["|", "|", "|", "|---|---|", "|", "|", "|"],
);
}
#[test]
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
let code = "a | b\n--- | ---\nnot a row";
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
}
#[test]
fn an_autolink_colours_and_an_html_tag_does_not() {
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
assert_spans(
code,
Kind::Metadata,
&["<https://example.com>", "<a@b.com>", "http://x"],
);
}
#[test]
fn a_bare_url_gives_back_the_sentences_punctuation() {
assert_spans(
"see https://example.com/a., and ssh://host/x)",
Kind::Metadata,
&["https://example.com/a", "ssh://host/x"],
);
}
#[test]
fn a_bracket_a_url_opened_itself_stays_in_it() {
assert_spans(
"https://en.wikipedia.org/wiki/A_(b) here",
Kind::Metadata,
&["https://en.wikipedia.org/wiki/A_(b)"],
);
}
#[test]
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
assert_spans(
"[x](https://example.com)",
Kind::Metadata,
&["(https://example.com)"],
);
}
#[test]
fn a_bracket_with_no_destination_after_it_is_left_plain() {
assert_spans("an [aside] here", Kind::Mark, &[]);
}
}
+702
View File
@@ -0,0 +1,702 @@
//! `code` read once, left to right, into the spans that carry a colour.
//! Ported from `app/.../Highlighter.kt`.
//!
//! One pass with a small state -- in a comment, in a string, or in ordinary
//! code -- rather than a locator per token kind over the whole text, which
//! is what the library this replaced did and is why it found comments
//! before it knew the language: a `#` inside a shell string, a `//` inside
//! a URL and a block-comment opener inside a shell glob each commented out
//! the rest of a line that was nothing of the sort.
//!
//! Every span is produced by advancing an index forward, so the result is
//! ordered, non-overlapping and inside the code by construction. Nothing
//! here panics: an unterminated string or comment runs to the end of the
//! code, which is also what it looks like while a fence is still being
//! written.
//!
//! **Indices are char offsets, not byte offsets** -- the scanner works over
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
//! back into the text it covers.
pub mod languages;
pub mod markdown;
pub use languages::{
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
};
/// What a span of code is, in the terms a palette has a colour for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Keyword,
String,
Literal,
Comment,
Metadata,
Punctuation,
Mark,
}
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
pub kind: Kind,
}
/// The text a [`Span`] covers, for a caller working in char indices (every
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
pub fn span_text(code: &[char], span: &Span) -> String {
code[span.start..span.end].iter().collect()
}
/// The spans `language` colours in `code` -- the one way to ask, whatever
/// the language turns out to be made of. `None` draws plain.
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
if language == Language::Markdown {
markdown::scan_markdown(code)
} else {
scan(code, &rules_for(language))
}
}
/// `code` read into the spans [`Rules`] describes. Also reachable directly
/// for a caller that already has a [`Rules`] (there is currently only one:
/// [`spans_of`]), kept public because the Kotlin original exposed it the
/// same way.
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
Scanner::new(code, rules).run()
}
/// Characters coloured as punctuation, and as marks. Both sets are the ones
/// the library this replaced used.
const PUNCTUATION: &str = ",.:;";
const MARKS: &str = "()={}<>-+[]|&";
struct Scanner<'a> {
code: Vec<char>,
rules: &'a Rules,
spans: Vec<Span>,
at: usize,
}
impl<'a> Scanner<'a> {
fn new(code: &str, rules: &'a Rules) -> Self {
Self {
code: code.chars().collect(),
rules,
spans: Vec::new(),
at: 0,
}
}
fn run(mut self) -> Vec<Span> {
while self.at < self.code.len() {
// Every branch that answers true has advanced `self.at`, so
// this terminates.
let consumed = self.block_comment()
|| self.line_comment()
|| self.raw_string()
|| self.character_or_lifetime()
|| self.string()
|| self.attribute()
|| self.number()
|| self.word()
|| self.single_character();
if !consumed {
self.at += 1;
}
}
self.spans
}
fn emit(&mut self, start: usize, kind: Kind) {
if self.at > start {
self.spans.push(Span {
start,
end: self.at,
kind,
});
}
}
fn starts(&self, token: &str) -> bool {
starts_with_at(&self.code, self.at, token)
}
/// Whether a line comment token here opens one; see
/// [`Rules::line_comments_at_word_start`].
fn at_word_start(&self) -> bool {
self.at == 0
|| self.code[self.at - 1].is_whitespace()
|| ";|&(".contains(self.code[self.at - 1])
}
/// Whether only whitespace stands between the start of this line and here.
fn at_line_start(&self) -> bool {
let mut back = self.at as isize - 1;
while back >= 0 && self.code[back as usize] != '\n' {
if !self.code[back as usize].is_whitespace() {
return false;
}
back -= 1;
}
true
}
fn advance_to_end_of_line(&mut self) {
while self.at < self.code.len() && self.code[self.at] != '\n' {
self.at += 1;
}
}
/// From an open bracket through the one that matches it, or to the end
/// if none does.
fn advance_to_matching_bracket(&mut self) {
let mut depth = 0i32;
while self.at < self.code.len() {
match self.code[self.at] {
'[' => depth += 1,
']' => depth -= 1,
_ => {}
}
self.at += 1;
if depth == 0 {
return;
}
}
}
fn block_comment(&mut self) -> bool {
let Some(comment) = self.rules.block_comment else {
return false;
};
if !self.starts(comment.open) {
return false;
}
let start = self.at;
self.at += comment.open.chars().count();
let mut depth = 1i32;
while self.at < self.code.len() && depth > 0 {
// The closer is tried first so that a language whose two
// delimiters are the same string -- CoffeeScript's `###` --
// closes rather than nesting forever.
if self.starts(comment.close) {
depth -= 1;
self.at += comment.close.chars().count();
} else if comment.nests && self.starts(comment.open) {
depth += 1;
self.at += comment.open.chars().count();
} else {
self.at += 1;
}
}
self.emit(start, Kind::Comment);
true
}
fn line_comment(&mut self) -> bool {
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
return false;
}
if self.rules.line_comments_at_word_start && !self.at_word_start() {
return false;
}
let start = self.at;
self.advance_to_end_of_line();
self.emit(start, Kind::Comment);
true
}
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
fn raw_string(&mut self) -> bool {
if !self.rules.raw_strings {
return false;
}
let mut ahead = self.at;
if self.code.get(ahead) == Some(&'b') {
ahead += 1;
}
if self.code.get(ahead) != Some(&'r') {
return false;
}
ahead += 1;
let mut hashes = 0usize;
while self.code.get(ahead) == Some(&'#') {
ahead += 1;
hashes += 1;
}
if self.code.get(ahead) != Some(&'"') {
return false;
}
let start = self.at;
let closer: String = std::iter::once('"')
.chain(std::iter::repeat_n('#', hashes))
.collect();
let closer_chars: Vec<char> = closer.chars().collect();
let closed = find_from(&self.code, ahead + 1, &closer_chars);
self.at = match closed {
Some(index) => index + closer_chars.len(),
None => self.code.len(),
};
self.emit(start, Kind::String);
true
}
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
/// literal opens nothing.
fn character_or_lifetime(&mut self) -> bool {
if !self.rules.lifetimes || self.code[self.at] != '\'' {
return false;
}
let Some(&next) = self.code.get(self.at + 1) else {
return false;
};
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
self.quoted(Quote {
open: "'",
close: "'",
escapes: true,
});
} else {
self.at += 1;
}
true
}
fn string(&mut self) -> bool {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
// than an empty string followed by a quote.
let mut quote: Option<Quote> = None;
for candidate in &self.rules.quotes {
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
quote = Some(*candidate);
}
}
let Some(quote) = quote else {
return false;
};
self.quoted(quote);
true
}
fn quoted(&mut self, quote: Quote) {
let start = self.at;
self.at += quote.open.chars().count();
while self.at < self.code.len() {
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
self.at += 2;
continue;
}
if self.starts(quote.close) {
self.at += quote.close.chars().count();
break;
}
self.at += 1;
}
self.at = self.at.min(self.code.len());
self.emit(start, Kind::String);
}
fn attribute(&mut self) -> bool {
let start = self.at;
match self.rules.attributes {
Attributes::None => return false,
Attributes::AtWord => {
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
{
return false;
}
self.at += 1;
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
self.at += 1;
}
}
Attributes::HashBracket => {
if self.code[self.at] != '#' {
return false;
}
let mut ahead = self.at + 1;
if self.code.get(ahead) == Some(&'!') {
ahead += 1;
}
if self.code.get(ahead) != Some(&'[') {
return false;
}
self.at = ahead;
self.advance_to_matching_bracket();
}
Attributes::HashLine => {
if self.code[self.at] != '#' || !self.at_line_start() {
return false;
}
self.advance_to_end_of_line();
}
Attributes::LineBracket => {
if self.code[self.at] != '[' || !self.at_line_start() {
return false;
}
self.advance_to_matching_bracket();
}
}
self.emit(start, Kind::Metadata);
true
}
/// A number is a run starting with a digit and carrying on through
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
/// and `3.14` without a grammar for any of them.
fn number(&mut self) -> bool {
if !self.code[self.at].is_ascii_digit() {
return false;
}
let start = self.at;
while self.at < self.code.len() {
let c = self.code[self.at];
if c.is_alphanumeric() || c == '_' || c == '.' {
self.at += 1;
} else {
break;
}
}
self.emit(start, Kind::Literal);
true
}
fn word(&mut self) -> bool {
if !is_word_start(Some(self.code[self.at])) {
return false;
}
let start = self.at;
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
self.at += 1;
}
let word: String = self.code[start..self.at].iter().collect();
if self.rules.keywords.contains(word.as_str()) {
self.emit(start, Kind::Keyword);
}
true
}
fn single_character(&mut self) -> bool {
let kind = if PUNCTUATION.contains(self.code[self.at]) {
Kind::Punctuation
} else if MARKS.contains(self.code[self.at]) {
Kind::Mark
} else {
return false;
};
self.at += 1;
self.emit(self.at - 1, kind);
true
}
}
fn is_word_start(c: Option<char>) -> bool {
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
}
fn is_word_part(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Whether `code[at..]` starts with `token`, both read as chars.
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
return false;
}
code[at..at + token.len()] == token[..]
}
/// The first index at or after `from` where `code` contains `needle`, or
/// `None`.
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || from > code.len() {
return None;
}
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
}
#[cfg(test)]
mod tests {
use super::*;
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
let chars: Vec<char> = code.chars().collect();
spans_of(code, language)
.into_iter()
.filter(|s| s.kind == kind)
.map(|s| span_text(&chars, &s))
.collect()
}
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
assert_eq!(
spans(code, language, kind),
expected.to_vec(),
"{kind:?} in: {code}"
);
}
#[test]
fn a_quoted_glob_is_one_string_not_a_comment() {
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
}
#[test]
fn a_find_with_globs_has_no_comment_in_it() {
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
assert_spans(
code,
Language::Shell,
Kind::String,
&["'*/.git/*'", "'*.kt'"],
);
assert_spans(code, Language::Shell, Kind::Comment, &[]);
}
#[test]
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
let code = "curl https://example.com/x && echo done";
assert_spans(code, Language::Shell, Kind::Comment, &[]);
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
}
#[test]
fn a_url_inside_a_kotlin_string_stays_a_string() {
let code = "val url = \"https://example.com\"\nfun f() = 1";
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
assert_spans(
code,
Language::Kotlin,
Kind::String,
&["\"https://example.com\""],
);
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
}
#[test]
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
assert_spans(code, Language::Rust, Kind::Comment, &[]);
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
}
#[test]
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
let code = "#![allow(dead_code)]\nfn f() {}";
assert_spans(
code,
Language::Rust,
Kind::Metadata,
&["#![allow(dead_code)]"],
);
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
}
#[test]
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
let code = "#include <stdio.h>\nint main() { return 0; }";
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
assert_spans(code, Language::C, Kind::Comment, &[]);
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
}
#[test]
fn a_kotlin_annotation_is_metadata() {
assert_spans(
"@Composable fun f() {}",
Language::Kotlin,
Kind::Metadata,
&["@Composable"],
);
}
#[test]
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
let code = "val c = \"#FF0000\"\nval d = 1";
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
}
#[test]
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
let code = "val a = \"don't\"\nval b = \"x\"";
assert_spans(
code,
Language::Kotlin,
Kind::String,
&["\"don't\"", "\"x\""],
);
}
#[test]
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
}
#[test]
fn an_escaped_quote_is_inside_the_rust_character_literal() {
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
}
#[test]
fn a_rust_raw_string_keeps_its_inner_quotes() {
let code = "let s = r#\"a \"quoted\" b\"#;";
assert_spans(
code,
Language::Rust,
Kind::String,
&["r#\"a \"quoted\" b\"#"],
);
}
#[test]
fn a_kotlin_triple_quoted_string_is_one_string() {
assert_spans(
"val s = \"\"\"a \"b\" c\"\"\"",
Language::Kotlin,
Kind::String,
&["\"\"\"a \"b\" c\"\"\""],
);
}
#[test]
fn a_shell_single_quoted_string_takes_no_escapes() {
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
}
#[test]
fn rust_and_kotlin_nest_block_comments() {
let code = "/* a /* b */ c */ x";
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
assert_spans(
code,
Language::Kotlin,
Kind::Comment,
&["/* a /* b */ c */"],
);
}
#[test]
fn c_ends_a_block_comment_at_the_first_close() {
assert_spans(
"/* a /* b */ c */ x",
Language::C,
Kind::Comment,
&["/* a /* b */"],
);
}
#[test]
fn a_shell_comment_starts_only_at_a_word_boundary() {
let code = "${#x} $# a#b # real";
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
}
#[test]
fn a_hash_anywhere_is_a_python_comment() {
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
}
#[test]
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
}
#[test]
fn a_ron_attribute_and_its_values_colour() {
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
assert_spans(
code,
Language::Ron,
Kind::Metadata,
&["#![enable(implicit_some)]"],
);
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
}
#[test]
fn an_unknown_fence_language_is_none() {
assert_eq!(fence_language(Some("brainfuck")), None);
}
#[test]
fn every_language_the_fence_table_knows_has_a_scanner() {
for language in Language::ALL {
spans_of("x", language);
}
}
/// The scanner must never panic and must never answer a span the code
/// does not contain: the library this replaced answered a reversed
/// range here, which crashed a card, and a fence still being written is
/// an unterminated string or comment on every keystroke.
#[test]
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
let nasty = [
"",
"'",
"\"",
"\"unterminated",
"/* unterminated",
"###",
"#",
"#![",
"[",
"r#\"",
"\\",
"'''",
"\"\"\"",
"0x",
"1.2.3",
"a#b//c/*d*/'e\"f",
"```",
"*",
"**",
"~~",
"> ",
"- ",
"1.",
"[x](",
"#######",
"|",
"|---|",
"<",
"<>",
"http://",
"a://",
"\n\n \n",
];
for language in Language::ALL {
for code in nasty {
let chars: Vec<char> = code.chars().collect();
let spans = spans_of(code, language);
for s in &spans {
assert!(
s.start <= s.end && s.end <= chars.len(),
"{language:?} answered {s:?} for {code:?}"
);
}
let mut sorted = spans.clone();
sorted.sort_by_key(|s| s.start);
assert_eq!(
spans, sorted,
"{language:?} answered spans out of order for {code:?}"
);
}
}
}
}
+751
View File
@@ -0,0 +1,751 @@
//! The app's own recent log, held in memory so it can be read back
//! without `logcat`.
//!
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
//! no `adb`, and Android forbids one app reading another's logcat, so
//! nothing outside the process can recover what it wrote. The only way a
//! line reaches her is for the app to carry its own copy. This is that
//! copy: a bounded ring every `log::info!` in the process lands in, on top
//! of whichever platform logger was already installed (`android_logger`,
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
//!
//! Three consumers, all reading the same ring rather than each keeping
//! their own: whatever hands the log out of the process -- on Android, the
//! `DevLogProvider` Dev Updater queries, which reads [`LogRing::since`]
//! and [`LogRing::newest_seq`] -- the bench app's diagnostics pane, which
//! only counts it ([`LogRing::summary`]), and the panic hook
//! ([`LogRing::try_tail_text`]). That is why reading does not consume: a
//! line already handed over must still be readable, and a report taken
//! twice must say the same thing.
//!
//! Nothing inlines the log into a copied report any more (2026-09-08):
//! Dev Updater reads it directly, so a second copy on the clipboard was
//! the same lines twice.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// How many lines a default ring holds, and how many bytes of message.
///
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
/// only by bytes can be emptied by a single line.
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
/// One recorded line. `seq` is assigned by the ring and only ever
/// increases, so a reader that remembers where it got to can ask for what
/// came after -- and a gap in the sequence is exactly the lines the bound
/// dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
/// Milliseconds since the unix epoch, from the app's own clock. The
/// app's rather than the receiver's: a line is timestamped when it
/// happened, and an upload can be minutes later or never.
pub at_ms: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
impl LogLine {
/// Roughly what the line costs the ring. The two `String`s dominate;
/// the fixed fields are counted as a flat overhead so a ring of empty
/// messages still has a bound.
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
/// `12:34:56.789 INFO iris::android: the message`, the shape a
/// person skims. Time of day only -- the date is in the report's own
/// header, and a ring never spans one.
pub fn format(&self) -> String {
format!(
"{} {:<5} {}: {}",
clock_time(self.at_ms),
self.level,
self.target,
self.message
)
}
}
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
/// library: the only field this needs is the time of day, and dividing out
/// the day is the whole calculation. Deliberately not local time -- the
/// phone's offset is not knowable here, and a report that says UTC is
/// comparable with the server's log, which is what it gets read against.
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
ms
)
}
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
/// the app down for.
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug)]
struct Inner {
lines: VecDeque<LogLine>,
bytes: usize,
max_lines: usize,
max_bytes: usize,
next_seq: u64,
/// How many lines the bounds have discarded since the ring was made.
/// Reported rather than inferred, so "the log starts here" and "the
/// log was cut off here" are distinguishable -- the unknown state the
/// UI rules ask for.
dropped: u64,
}
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
/// there is one per process and every holder sees the same lines.
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
impl LogRing {
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
assert!(
max_lines > 0 && max_bytes > 0,
"a ring with no room holds nothing"
);
Self(Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
bytes: 0,
max_lines,
max_bytes,
next_seq: 0,
dropped: 0,
})))
}
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
/// [`DEFAULT_MAX_BYTES`].
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
/// A poisoned lock is a bug in a panicking logger, not a reason to
/// take the app down a second time -- the ring is a diagnostic, and
/// losing it must not be worse than the fault it was recording.
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
/// Records a line, evicting the oldest until both bounds hold again.
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
seq: inner.next_seq,
at_ms: now_ms(),
level,
target: target.to_string(),
message,
};
inner.next_seq += 1;
inner.bytes += line.weight();
inner.lines.push_back(line);
// `!is_empty()` rather than `len() > 1`: one line larger than
// the whole byte bound is kept, because dropping it would
// leave the ring silently empty while lines were arriving.
while inner.lines.len() > inner.max_lines
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
{
if let Some(evicted) = inner.lines.pop_front() {
inner.bytes -= evicted.weight();
inner.dropped += 1;
}
}
})
}
/// Every line held, oldest first.
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
/// The lines with a sequence number at or after `seq`, oldest first,
/// and the sequence to ask from next time. Does not consume: see this
/// module's doc for why.
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
.lines
.iter()
.filter(|line| line.seq >= seq)
.cloned()
.collect();
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
(lines, next)
})
}
pub fn len(&self) -> usize {
self.with(|inner| inner.lines.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn dropped(&self) -> u64 {
self.with(|inner| inner.dropped)
}
/// The sequence number of the newest line held, or `None` for a ring
/// nothing has been written to.
///
/// What a reader needs to notice that this process **restarted**: the
/// ring is in memory, so a new process starts again at zero, and a
/// reader holding a cursor from the previous one would otherwise ask
/// for lines after a number nothing will reach for hours and see
/// nothing at all -- silently, which is worse than seeing the log
/// begin again. Answering `None` rather than 0 for an empty ring is
/// the same distinction [`Self::summary`] draws: "nothing has been
/// logged" is not a sequence number.
pub fn newest_seq(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.seq))
}
/// When the newest line was written, in unix milliseconds, or `None`
/// for a ring nothing has been written to.
pub fn last_at_ms(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
/// Every line held, formatted one per line -- what `Copy report`
/// appends.
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n")
}
/// The newest `max_lines` lines, formatted, or `None` if the ring is
/// locked at this instant.
///
/// For the one caller that must not block: **the panic hook**. A panic
/// raised while this ring's own lock was held -- an allocation failing
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
/// -- would deadlock the hook against the thread that is panicking,
/// and the process would hang instead of aborting, with nothing
/// written anywhere. Losing the context lines is the right trade
/// against that, and `None` says which happened rather than looking
/// like an empty log.
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
let guard = match self.0.try_lock() {
Ok(guard) => guard,
// A poisoned lock is uncontended, so its contents are still
// readable -- the same judgement as `with`.
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
let lines = &guard.lines;
let from = lines.len().saturating_sub(max_lines);
Some(
lines
.iter()
.skip(from)
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n"),
)
}
/// One line for a diagnostics pane: how much is held, how much was
/// dropped, and when the last line arrived. "no lines yet" is its own
/// wording rather than a count of zero with a made-up time, because
/// "nothing has been logged" and "logging is not running" would
/// otherwise look the same.
pub fn summary(&self) -> String {
let (len, dropped, last) = self.with(|inner| {
(
inner.lines.len(),
inner.dropped,
inner.lines.back().map(|line| line.at_ms),
)
});
match last {
None => "app log: no lines yet".to_string(),
Some(at) => {
let dropped = if dropped > 0 {
format!(", {dropped} dropped")
} else {
String::new()
};
format!(
"app log: {len} lines held{dropped}, last {}",
clock_time(at)
)
}
}
}
}
/// Whether a target belongs to this app or to `iris` rather than to a
/// dependency -- `starts_with` guarded by an
/// exact match or a `::` so an unrelated crate that merely begins with the
/// same letters (there is no such crate today, but the check should not
/// rely on that) is never mistaken for one of ours.
fn is_own_target(target: &str) -> bool {
target == "iris"
|| target.starts_with("iris::")
|| target == "ai_app"
|| target.starts_with("ai_app::")
}
/// Whether a line at `level` from `target` belongs in the ring, given
/// whether tracing is on right now.
///
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
/// asked for, applied once here rather than at each `debug!` call site:
/// Info and above always ring, from anything, because a real warning or
/// error from a dependency is worth keeping. Debug and Trace ring only
/// from this app's own targets, and only while tracing is switched on --
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
/// (the process logger's own level, set once at install and unrelated to
/// tracing), which is what filled the ring with 1339 lines of it and
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
/// (commit 992c472); this is the backstop for lines this crate does not
/// control.
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
level <= log::Level::Info || (trace_enabled && is_own_target(target))
}
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
/// logger the platform already installs, so nothing that reads the
/// platform's log (`logcat`, a terminal) changes.
///
/// The inner logger is passed in rather than chosen here: `client-core`
/// has no business depending on `android_logger` or `env_logger`, and
/// which one is right is exactly what differs between the two platforms
/// (the sharing rule in AGENTS.md).
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
/// direction"), so the platform crate that depends on both is the one
/// that wires this closure through, the same way it already supplies
/// `inner`.
trace_enabled: fn() -> bool,
}
impl RingLogger {
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
Self {
ring,
inner,
trace_enabled,
}
}
}
impl log::Log for RingLogger {
/// True for anything `log`'s own max level lets through: the ring
/// wants everything the *inner* logger might also want, even where the
/// platform logger would filter it out. Which lines the ring itself
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
self.ring
.push(record.level(), record.target(), record.args().to_string());
}
if self.inner.enabled(record.metadata()) {
self.inner.log(record);
}
}
fn flush(&self) {
self.inner.flush();
}
}
/// Installs a [`RingLogger`] as the process logger and answers the ring it
/// records into.
///
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
pub fn install(
ring: LogRing,
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
log::set_max_level(max_level);
Ok(())
}
/// The one ring this process records into.
///
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
/// anywhere in the binary goes to it. A ring handed around as a parameter
/// would be a *second* answer to "which lines exist" -- the report would
/// show one ring while the logger filled another, and which one a caller
/// got would depend on how far down the call tree it was. The tests above
/// all use their own [`LogRing`], so nothing here needs this to be
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
/// The process's ring, created on first use with the default bounds.
/// Safe to call before [`install_process_logger`] -- it will simply be
/// empty.
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
/// Installs [`process_ring`] as the recording half of the process logger,
/// forwarding to `inner` (the platform's own logger, already configured).
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
/// else is shared. `trace_enabled` is the platform's own trace toggle
/// (`iris::diagnostics::trace_enabled` on Android) -- see
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
/// passed in rather than called directly.
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
install(process_ring().clone(), inner, max_level, trace_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
fn fill(ring: &LogRing, count: usize) {
for n in 0..count {
ring.push(Level::Info, "test", format!("line {n}"));
}
}
#[test]
fn lines_come_back_oldest_first() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 0", "line 1", "line 2"]);
}
#[test]
fn the_line_bound_drops_the_oldest_and_says_how_many() {
let ring = LogRing::new(3, 1 << 20);
fill(&ring, 5);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
assert_eq!(ring.len(), 3);
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
}
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
// Room for 1000 lines but only a few hundred bytes.
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
}
assert!(
ring.len() < 10,
"the byte bound evicted: {} held",
ring.len()
);
assert!(ring.dropped() > 0);
assert!(
ring.snapshot().last().unwrap().message.starts_with('9'),
"and it evicted from the old end"
);
}
/// The case the `len() > 1` guard exists for: one line larger than the
/// whole bound must still be readable, or a ring that is over budget
/// reads as a ring nothing was written to.
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
ring.push(Level::Error, "t", "y".repeat(5000));
assert_eq!(ring.len(), 1);
assert_eq!(ring.dropped(), 0);
}
#[test]
fn sequence_numbers_only_increase_and_survive_eviction() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 5);
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
}
#[test]
fn since_returns_only_what_is_new_and_the_next_cursor() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 3);
let (first, cursor) = ring.since(0);
assert_eq!(first.len(), 3);
assert_eq!(cursor, 3);
let (none, cursor) = ring.since(cursor);
assert!(none.is_empty(), "nothing new yet");
assert_eq!(cursor, 3, "and the cursor does not move");
ring.push(Level::Warn, "test", "later".into());
let (more, cursor) = ring.since(cursor);
assert_eq!(more.len(), 1);
assert_eq!(more[0].message, "later");
assert_eq!(cursor, 4);
}
/// The restart signal: a reader that saw sequence 4 and is now told
/// the newest is 0 knows the process is not the one it was reading.
#[test]
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
let ring = LogRing::new(100, 1 << 20);
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
fill(&ring, 5);
assert_eq!(ring.newest_seq(), Some(4));
let restarted = LogRing::new(100, 1 << 20);
fill(&restarted, 1);
assert_eq!(
restarted.newest_seq(),
Some(0),
"a fresh ring starts again, which is exactly what a reader has to notice"
);
}
#[test]
fn reading_does_not_consume() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 2);
let (sent, _) = ring.since(0);
assert_eq!(sent.len(), 2);
assert_eq!(ring.len(), 2, "the report still has them after an upload");
assert_eq!(ring.to_text().lines().count(), 2);
}
#[test]
fn try_tail_text_gives_the_newest_lines_with_no_header() {
let ring = LogRing::new(1000, 1 << 20);
fill(&ring, 200);
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
}
/// The whole point of the `try_`: the panic hook calls this from a
/// thread that may already hold the ring's lock, and a blocking read
/// there would hang the process instead of aborting it.
#[test]
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let held = ring.0.lock().expect("fresh ring");
assert_eq!(ring.try_tail_text(80), None);
drop(held);
assert!(ring.try_tail_text(80).is_some());
}
#[test]
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
let ring = LogRing::with_defaults();
assert_eq!(ring.summary(), "app log: no lines yet");
assert_eq!(ring.last_at_ms(), None);
assert!(ring.is_empty());
}
#[test]
fn the_summary_names_dropped_lines_only_when_there_are_some() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 2);
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
fill(&ring, 2);
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
}
#[test]
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
// hand rather than against another clock.
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
message: "surface created".into(),
}
.format();
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
/// The forwarding half: a line reaches the ring *and* the logger the
/// platform already had, and one the inner logger filters out is still
/// in the ring.
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
impl Log for Collect {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.1
}
fn log(&self, record: &log::Record) {
self.0.lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
// Own target, tracing on: this is the case where the ring and the
// inner logger disagree, which is the thing under test -- a
// foreign target is covered separately below.
let logger = RingLogger::new(
ring.clone(),
Box::new(Collect(seen.clone(), Level::Info)),
|| true,
);
logger.log(
&log::Record::builder()
.args(format_args!("kept"))
.level(Level::Info)
.target("iris::test")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("filtered"))
.level(Level::Debug)
.target("iris::test")
.build(),
);
assert_eq!(
*seen.lock().unwrap(),
["kept"],
"the inner logger's own filter still applies"
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["kept", "filtered"],
"own-target debug still rings while tracing is on"
);
}
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
/// unconditionally, and used to flood the ring even though nothing in
/// this app asked for their Debug output. A foreign target's Debug
/// line must not ring even while tracing is on -- tracing controls
/// this app's own diagnostics, not a dependency's chatter.
#[test]
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
use log::Log;
struct Discard;
impl Log for Discard {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, _: &log::Record) {}
fn flush(&self) {}
}
let ring = LogRing::with_defaults();
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
logger.log(
&log::Record::builder()
.args(format_args!("naga debug spam"))
.level(Level::Debug)
.target("naga::front")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("naga warning"))
.level(Level::Warn)
.target("wgpu_core::device")
.build(),
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["naga warning"],
"Info-and-above always rings; foreign Debug never does"
);
}
#[test]
fn ring_accepts_is_own_target_debug_only_while_tracing() {
assert!(
ring_accepts(Level::Info, "wgpu_core::device", false),
"Info+ from anything, tracing off"
);
assert!(
ring_accepts(Level::Warn, "jni", true),
"Info+ from anything, tracing on"
);
assert!(
!ring_accepts(Level::Debug, "jni", true),
"foreign Debug, tracing on: still excluded"
);
assert!(
!ring_accepts(Level::Debug, "iris::sense", false),
"own Debug, tracing off: excluded"
);
assert!(
ring_accepts(Level::Debug, "iris::sense", true),
"own Debug, tracing on: included"
);
assert!(
ring_accepts(Level::Trace, "ai_app::api", true),
"own Trace, tracing on: included"
);
}
#[test]
fn is_own_target_matches_the_crate_or_its_modules_only() {
assert!(is_own_target("iris"));
assert!(is_own_target("iris::sense"));
assert!(is_own_target("ai_app"));
assert!(is_own_target("ai_app::log_ring"));
assert!(!is_own_target("iris_something_else"));
assert!(!is_own_target("naga::front"));
assert!(!is_own_target("jni"));
}
}
+325
View File
@@ -0,0 +1,325 @@
//! Split a markdown message into its top-level **blocks** -- one
//! paragraph, heading, fenced code block, list, table or quote each, as a
//! byte slice of the original source.
//!
//! This exists for streaming. A transcript row used to be one text widget
//! holding the whole message, so a single streamed delta re-shaped every
//! paragraph of it through the text engine again; the phone's bench v2 put
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
//! block now, and a delta that lands in the last block leaves every
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
//! what that rejected and why the split lives here rather than in the UI
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
//! keeping it here means iris stays a text renderer that knows nothing
//! about markdown.
//!
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
//! renderer's own job, per block -- this deliberately does not build a
//! full AST, because nothing needs one yet.
//!
//! ## Appending is not guaranteed to leave earlier blocks alone
//!
//! It nearly always does, which is what makes the fast path worth having,
//! but markdown has no such rule: appending a "```" line can turn text
//! that was three paragraphs into one fenced block, and appending "---"
//! under a paragraph turns that paragraph into a heading. So a caller
//! taking the O(last block) path **must compare the prefix it is about to
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
//! it is cheap next to laying the text out again.
use pulldown_cmark::{Event, Options, Parser, Tag};
/// What a block is, for a renderer that wants to style or space blocks
/// differently. `Other` is deliberately present rather than a panic or a
/// silent fallback to `Paragraph`: markdown has more block kinds than this
/// list and more get added, and a renderer treating an unknown one as
/// prose is right, but it should be able to *tell* that is what it is
/// doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Paragraph,
Heading,
/// A fenced or indented code block.
Code,
List,
Table,
Quote,
/// A thematic break, raw HTML, a footnote -- anything with no
/// distinguished treatment here.
Other,
}
/// One top-level block: its kind and the exact source that produced it.
/// `source` is a slice of the input with trailing whitespace removed, so
/// two splits of the same prefix compare equal even when one of them had a
/// delta arriving after it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
pub source: String,
}
fn kind_of(tag: &Tag) -> BlockKind {
match tag {
Tag::Paragraph => BlockKind::Paragraph,
Tag::Heading { .. } => BlockKind::Heading,
Tag::CodeBlock(_) => BlockKind::Code,
Tag::List(_) => BlockKind::List,
Tag::Table(_) => BlockKind::Table,
Tag::BlockQuote(_) => BlockKind::Quote,
_ => BlockKind::Other,
}
}
fn options() -> Options {
// The same set `transcript-ui`'s renderer parses with, so a block
// boundary here and the styling there cannot disagree about what the
// source means.
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
/// Split `src` into its top-level blocks, in source order. An empty or
/// whitespace-only input gives no blocks; text the parser does not put
/// inside any block (a stray fence marker mid-stream) still comes back,
/// as `Other`, rather than being dropped.
pub fn split_blocks(src: &str) -> Vec<Block> {
let mut out: Vec<Block> = Vec::new();
let mut depth = 0usize;
let mut kind = BlockKind::Other;
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
match event {
Event::Start(tag) => {
if depth == 0 {
kind = kind_of(&tag);
}
depth += 1;
}
Event::End(_) => {
depth -= 1;
if depth == 0 {
push(&mut out, kind, &src[range]);
}
}
// A top-level event that is not part of any block -- a
// thematic break, a block of raw HTML. Inside one, it is the
// enclosing block's business and this does nothing.
_ => {
if depth == 0 {
push(&mut out, BlockKind::Other, &src[range]);
}
}
}
}
out
}
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
let source = source.trim_end();
if source.is_empty() {
return;
}
out.push(Block {
kind,
source: source.to_string(),
});
}
/// How many leading blocks of `old` and `new` are identical -- what a
/// caller may keep the laid-out widgets for. See the module doc for why
/// this is a comparison rather than an assumption.
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
old.iter().zip(new).take_while(|(a, b)| a == b).count()
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(src: &str) -> Vec<BlockKind> {
split_blocks(src).into_iter().map(|b| b.kind).collect()
}
#[test]
fn a_message_splits_into_its_top_level_blocks() {
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
assert_eq!(
kinds(src),
vec![
BlockKind::Heading,
BlockKind::Paragraph,
BlockKind::Code,
BlockKind::List
]
);
let blocks = split_blocks(src);
assert_eq!(blocks[1].source, "First para.");
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
}
#[test]
fn blank_input_has_no_blocks() {
assert!(split_blocks("").is_empty());
assert!(split_blocks(" \n\n ").is_empty());
}
/// The property the streaming fast path rests on, in its ordinary
/// shape: a delta landing in the last paragraph must leave every
/// earlier block byte-identical.
#[test]
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(before.len(), 3);
assert_eq!(after.len(), 3);
assert_ne!(before[2], after[2]);
}
/// A delta that starts a *new* block keeps every old block, including
/// the one that was last -- so the fast path appends rather than
/// replacing.
#[test]
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
let before = split_blocks("First para.\n\nSecond para.");
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(after.len(), 3);
}
/// A code fence arrives one delta at a time and is unterminated for
/// most of its life. It must still be *one* block the whole way, or
/// every delta would re-split the message into a different number of
/// pieces.
#[test]
fn an_unterminated_fence_is_one_block_while_it_streams() {
for src in [
"Here:\n\n```rust\n",
"Here:\n\n```rust\nfn main() {\n",
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
] {
assert_eq!(
kinds(src),
vec![BlockKind::Paragraph, BlockKind::Code],
"{src:?}"
);
}
}
/// The half the fast path had no reason to touch, and the reason
/// `common_prefix` is a comparison rather than an assumption:
/// appending can rewrite what came before. `---` under a paragraph
/// turns that paragraph into a setext heading, so the block that was
/// already laid out is not the block it is now.
#[test]
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
let before = split_blocks("Not a heading\n\nsecond");
let after = split_blocks("Not a heading\n\nsecond\n---");
assert_eq!(before[1].kind, BlockKind::Paragraph);
assert_eq!(after[1].kind, BlockKind::Heading);
assert_eq!(
common_prefix(&before, &after),
1,
"the rewritten block must not be reported as keepable"
);
}
#[test]
fn a_thematic_break_is_its_own_block() {
assert_eq!(
kinds("one\n\n---\n\ntwo"),
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
);
}
/// The shapes a real transcript actually contains, each checked for
/// the one property the streaming fast path needs: the *number* of
/// blocks and every earlier block's source stay put while the message
/// grows. A fence's own blank lines, a `---` inside one, a nested
/// list and a table are all places where a naive line-based split
/// would break the message into more pieces than there are blocks.
#[test]
fn the_transcripts_own_block_shapes_survive_a_split() {
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
assert_eq!(
kinds(fence_with_blanks),
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
"a blank line inside a fence is not a block boundary"
);
assert_eq!(
kinds("```\n---\n```"),
vec![BlockKind::Code],
"a thematic break inside a fence is code, not a break"
);
assert_eq!(
kinds("- a\n - a1\n - a2\n- b"),
vec![BlockKind::List],
"a nested list is one top-level block"
);
assert_eq!(
kinds("## Heading\n```sh\nls\n```"),
vec![BlockKind::Heading, BlockKind::Code],
"a fence directly under a heading, with no blank line"
);
assert_eq!(
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
vec![BlockKind::Table]
);
assert_eq!(
kinds("> quoted\n> more\n\nplain"),
vec![BlockKind::Quote, BlockKind::Paragraph]
);
}
/// `apply_delta`'s precondition, stated as the property rather than
/// the arithmetic: for every prefix of a realistic streamed message,
/// the blocks before the last one must be exactly the blocks the
/// previous prefix had. Where markdown breaks that (the `---` case
/// above), `common_prefix` has to *say* so -- which is what the
/// `>= len - 1` assertion below checks: the split may rewrite the
/// last block, never an earlier one, or `RowBlocks::apply_delta`
/// would keep a widget whose text is no longer what it holds.
#[test]
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
// Every character boundary, so a delta landing mid-word and one
// landing exactly on a fence's closing backtick are both covered.
let mut prev = Vec::new();
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
let now = split_blocks(&full[..end]);
let common = common_prefix(&prev, &now);
assert!(
prev.is_empty() || common + 1 >= prev.len(),
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
prev.len()
);
prev = now;
}
}
/// The half a growing message cannot show: a fence that never closes.
/// The stream ends there and the block must still be the code block
/// it has been all along, not re-split into paragraphs.
#[test]
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
let blocks = split_blocks(src);
assert_eq!(
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
vec![BlockKind::Paragraph, BlockKind::Code]
);
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
}
/// A delta that closes a fence changes the *last* block only, so the
/// fast path takes it -- the case the module doc says is the reason
/// `common_prefix` is a comparison.
#[test]
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
let before = split_blocks("Text.\n\n```\ncode\n");
let after = split_blocks("Text.\n\n```\ncode\n```");
assert_eq!(before.len(), after.len());
assert_eq!(common_prefix(&before, &after), 1);
assert_ne!(before[1], after[1]);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! The app's pure logic, shared between the server and any Rust client --
//! see `docs/CLIENT_CORE.md` for what lives here and what does
//! not yet.
pub mod ansi;
pub mod api;
pub mod config;
pub mod durations;
pub mod event_stream;
pub mod highlight;
pub mod log_ring;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
pub mod text_cap;
pub mod tool_summary;
pub mod transcript_cache;
pub mod transcript_fold;
pub mod transcript_source;
pub use event_model::*;
+162
View File
@@ -0,0 +1,162 @@
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
//! places, never both" describes. Ported from the parsing half of
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
//! `Notification`/`NotificationKind`).
//!
//! What is deliberately **not** here, because it is a decision rather than
//! logic: whether a given notification is shown at all (the session on
//! screen gets nothing), handed to the app as a banner, or posted to the
//! platform's own notification drawer. That three-way choice reads
//! process-wide state (what screen is open, whether the app is in front)
//! that has no meaning to a pure crate with no UI and no Android in it --
//! see `android-shell` for where it lives for this port.
use std::io::{BufRead, BufReader};
use serde::Deserialize;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
/// `Notification` field for field.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
pub session_id: String,
pub title: String,
pub kind: NotificationKind,
/// Epoch seconds, so a phone that was asleep can say how long ago.
pub at: f64,
}
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
/// the same way, so this deserializes the wire's `"awaitingInput"` /
/// `"finished"` directly rather than through a string match.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NotificationKind {
AwaitingInput,
Finished,
}
impl NotificationKind {
/// What a notification asks of the reader, in the words they see --
/// ported verbatim from `Notifications.kt`'s `attentionLine`. One
/// function because the same fact is shown in two places (the
/// platform's drawer and the app's own banner) and two mappings of one
/// word drift.
pub fn attention_line(self) -> &'static str {
match self {
NotificationKind::AwaitingInput => "Waiting for you",
NotificationKind::Finished => "Finished",
}
}
}
/// Follows `/notifications`, calling `on_notification` for each frame until
/// the connection drops or the callback asks to stop (by returning
/// `false`). Reconnecting is the caller's job -- mirroring
/// `NotificationService.follow`'s retry loop, which is a platform policy
/// (how long to wait, whether to give up) rather than parsing logic.
pub fn follow_notifications(
transport: &dyn Transport,
mut on_notification: impl FnMut(SessionNotification) -> bool,
) -> Result<(), ApiError> {
let body = transport.stream("/notifications")?;
let mut lines = BufReader::new(body).lines();
let mut reader = SseReader::new();
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
message: format!("Can't reach the server -- retrying. ({e})"),
status: None,
})? {
let Some(frame) = reader.feed_line(&line) else {
continue;
};
if frame.data.is_empty() {
continue;
}
let notification: SessionNotification =
serde_json::from_str(&frame.data).map_err(|e| ApiError {
message: format!("The server sent a notification this build couldn't parse: {e}"),
status: None,
})?;
if !on_notification(notification) {
return Ok(());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
body: &'static str,
}
impl Transport for FixtureTransport {
fn request(
&self,
_method: &str,
_path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
unimplemented!("this fixture only serves a stream")
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
}
}
#[test]
fn a_notification_frame_parses_both_kinds() {
let transport = FixtureTransport {
body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\
data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n",
};
let mut seen = Vec::new();
follow_notifications(&transport, |n| {
seen.push((n.session_id, n.kind));
true
})
.unwrap();
assert_eq!(
seen,
vec![
("s1".to_string(), NotificationKind::AwaitingInput),
("s2".to_string(), NotificationKind::Finished),
]
);
}
#[test]
fn the_caller_can_stop_early() {
let transport = FixtureTransport {
body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\
data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n",
};
let mut count = 0;
follow_notifications(&transport, |_| {
count += 1;
count < 1
})
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn attention_line_matches_the_kotlin_original() {
assert_eq!(
NotificationKind::AwaitingInput.attention_line(),
"Waiting for you"
);
assert_eq!(NotificationKind::Finished.attention_line(), "Finished");
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
//! `event:` lines accumulate until a blank line ends the frame, comments
//! start with `:`, and a frame is either named with no payload or a payload
//! with no name.
//!
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
//! caller here feeds lines from wherever they came from (a real connection,
//! a test fixture) and gets frames back with no I/O of its own -- which is
//! what lets this be tested with no server, per RUST.md's "pure logic
//! first" for this crate.
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub name: Option<String>,
pub data: String,
}
/// Accumulates lines into [`Frame`]s. One instance per connection --
/// `feed_line` is called for every line the transport reads (with line
/// endings already stripped), and answers a frame when a blank line closes
/// one.
#[derive(Debug, Default)]
pub struct SseReader {
data: String,
name: Option<String>,
}
impl SseReader {
pub fn new() -> Self {
Self::default()
}
/// Feeds one line (no trailing `\n`). Answers the frame this line
/// completed, if any.
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
if line.is_empty() {
if self.name.is_some() || !self.data.is_empty() {
let frame = Frame {
name: self.name.take(),
data: std::mem::take(&mut self.data),
};
return Some(frame);
}
return None;
}
if let Some(rest) = line.strip_prefix("data:") {
self.data.push_str(rest.trim());
} else if let Some(rest) = line.strip_prefix("event:") {
self.name = Some(rest.trim().to_string());
}
// `id:`, comments -- nothing to do.
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frames(lines: &[&str]) -> Vec<Frame> {
let mut reader = SseReader::new();
lines.iter().filter_map(|l| reader.feed_line(l)).collect()
}
#[test]
fn a_data_only_frame_has_no_name() {
assert_eq!(
frames(&["data:hello", ""]),
vec![Frame {
name: None,
data: "hello".to_string()
}]
);
}
#[test]
fn a_named_frame_with_no_payload_still_completes() {
assert_eq!(
frames(&["event:reset", ""]),
vec![Frame {
name: Some("reset".to_string()),
data: String::new()
}]
);
}
#[test]
fn a_blank_line_with_nothing_pending_yields_no_frame() {
assert_eq!(frames(&[""]), vec![]);
}
#[test]
fn a_comment_and_an_id_line_are_ignored() {
assert_eq!(
frames(&[":keepalive", "id:5", "data:hi", ""]),
vec![Frame {
name: None,
data: "hi".to_string()
}]
);
}
#[test]
fn two_frames_in_a_row_are_both_reported() {
assert_eq!(
frames(&["data:one", "", "data:two", ""]),
vec![
Frame {
name: None,
data: "one".to_string()
},
Frame {
name: None,
data: "two".to_string()
},
]
);
}
}
+134
View File
@@ -0,0 +1,134 @@
//! How much of a long thing a transcript draws before offering the rest
//! behind a tap.
//!
//! One rule, four surfaces: a tool call's input, its output, and a user or
//! assistant message. It lives here rather than at any one of them because
//! four copies would eventually disagree about what "too long" is, and
//! because the Compose app has to answer the same question the same way --
//! `TextCap.kt` is the Kotlin half, and the two are checked against the
//! same numbers so a benchmark comparing the apps is comparing renderers
//! rather than policies.
//!
//! **Lines and bytes both, whichever runs out first**, because they run
//! out on different things: a diff is thousands of short lines, a minified
//! file or a base64 blob is one enormous one, and a cap that only counted
//! one of them draws the whole of the other.
//!
//! **Cut at the head, keeping the beginning.** A tool's output is read
//! from the top and the line saying what went wrong is nearly always the
//! first; a message is read from the top for the obvious reason. (A path
//! is identified by its other end -- none of these is a path.)
/// The default bound on a verbatim block -- a tool call's input or its
/// output. Short, because this text is a machine's and the reader is
/// looking for one line of it.
pub const VERBATIM_LINES: usize = 80;
pub const VERBATIM_BYTES: usize = 4096;
/// The bound on a message, a person's or the model's. Larger than a
/// verbatim block's in bytes and smaller in lines: prose is read whole and
/// wraps, so a screenful of it is far fewer lines than a screenful of a
/// log, and cutting a reply at 80 lines would cut most long answers that
/// nobody would call long.
pub const MESSAGE_LINES: usize = 200;
pub const MESSAGE_BYTES: usize = 16 * 1024;
/// A cap of nothing would draw an empty panel and a "Show all" for
/// everything there is, which reads as a rendering fault rather than as a
/// cap. Checked at compile time, since all four are constants.
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
/// count it was cut *from*; `None` when the whole of it fits.
///
/// The count is the whole text's, not the shown part's -- it is what the
/// "Show all N lines" offer says, and a reader deciding whether to ask for
/// the rest wants to know how much the rest is.
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
debug_assert!(
max_lines > 0 && max_bytes > 0,
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
);
let by_lines = text
.char_indices()
.filter(|(_, c)| *c == '\n')
.nth(max_lines - 1)
.map(|(i, _)| i);
let by_bytes = (text.len() > max_bytes).then(|| {
let mut end = max_bytes;
// Back up to a character boundary: a cut inside a multi-byte
// character panics on the slice below, and a transcript is full of
// them.
while !text.is_char_boundary(end) {
end -= 1;
}
end
});
let cut = match (by_lines, by_bytes) {
(Some(a), Some(b)) => a.min(b),
(a, b) => a.or(b)?,
};
Some((&text[..cut], text.lines().count()))
}
/// What a "Show all" offer says, so the wording is one string rather than
/// one per surface.
pub fn show_all_label(lines: usize) -> String {
format!("Show all {lines} lines")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_under_both_bounds_is_not_cut() {
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
}
#[test]
fn the_line_bound_cuts_at_a_line_boundary() {
let text = "a\nb\nc\nd\n";
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
assert_eq!(shown, "a\nb");
assert_eq!(
lines, 4,
"the count is the whole text's, not the shown part's"
);
}
/// The half the line bound cannot catch: one enormous line, which is
/// what a minified file or an embedded image arrives as.
#[test]
fn the_byte_bound_cuts_one_long_line() {
let text = "x".repeat(5000);
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
assert_eq!(shown.len(), 4096);
assert_eq!(lines, 1);
}
/// Whichever bites first, rather than whichever was checked first.
#[test]
fn the_tighter_of_the_two_bounds_wins() {
let text = "aaaa\n".repeat(100);
let (shown, _) = cut(&text, 80, 100).expect("over both");
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
}
/// A cut that lands inside a multi-byte character has to back up to
/// the boundary; slicing there would panic, and a transcript carries
/// em dashes and box drawing in every other line.
#[test]
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
let text = "é".repeat(100);
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
assert_eq!(
shown,
"é".repeat(5),
"11 bytes lands mid-character; 10 is the cut"
);
}
}
+244
View File
@@ -0,0 +1,244 @@
//! A tool call's input, read rather than dumped -- the port of
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
//! card's one-line summary and the expanded card's key/value list are
//! derived from.
//!
//! Every tool's input arrives as JSON, and showing it raw makes the reader
//! parse `{"command":"…","timeout":120000}` themselves to find the one
//! line they care about. So the fields that carry the meaning are pulled
//! out, and anything left over is still shown, because dropping a field
//! would be claiming the tool has no other input when it might.
//!
//! Pure, and here rather than in the widget crate, for the reason the rest
//! of this crate exists: the derivation is the same on a phone and on a
//! desktop, and it is testable without a renderer.
use crate::client::durations::format_millis_text;
use crate::client::highlight::Language;
use serde_json::{Map, Value};
/// A tool call's input, split into the parts a card draws separately.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInput {
/// The thing that will actually be run or read, if this tool has one.
pub subject: Option<String>,
/// The language [`ToolInput::subject`] is written in, for
/// highlighting.
pub language: Option<Language>,
/// The tool's own one-line summary, when it wrote one.
pub description: Option<String>,
/// How long the call may take, in the largest units it fits. Shown
/// apart because it is a limit on the call rather than part of what
/// the call does.
pub timeout: Option<String>,
/// Everything else, as `name: value` lines. Never dropped.
pub rest: Vec<String>,
}
impl ToolInput {
/// The one line to show when there is only room for one: what this
/// call is for.
pub fn title(&self) -> Option<&str> {
self.description
.as_deref()
.or(self.subject.as_deref())
// A subject that is only whitespace would draw as an empty
// summary line, which reads as a tool with nothing to say
// rather than as one whose subject was blank.
.filter(|t| !t.trim().is_empty())
}
}
/// Which field of which tool is the subject.
///
/// A table rather than a chain of `if`s: adding a tool is a row, and the
/// shape stops any of them from being the special case that gets its own
/// code path. Unknown tools fall through to "no subject, everything is
/// rest".
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("Bash", "command", Some(Language::Shell)),
("Read", "file_path", None),
("Write", "file_path", None),
("Edit", "file_path", None),
("Glob", "pattern", None),
("Grep", "pattern", None),
("WebFetch", "url", None),
];
/// Fields that are the tool's own prose about itself rather than input to
/// it.
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
/// string is its own characters, anything else is its JSON form.
///
/// One function rather than two, because the same coercion decides both
/// what a subject reads as and what a leftover field's value reads as, and
/// two copies would eventually disagree about a number.
fn as_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn non_blank(value: Option<&Value>) -> Option<String> {
let text = as_text(value?);
(!text.trim().is_empty()).then_some(text)
}
/// Split `input` (a tool call's JSON) into the parts a card draws.
///
/// Input that is not a JSON object -- older transcripts and some tools
/// send a bare string -- is still the input, so it is still shown, as the
/// whole of `rest`.
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
return ToolInput {
rest: match input.trim().is_empty() {
true => Vec::new(),
false => vec![input.to_string()],
},
..ToolInput::default()
};
};
parse_object(tool, &json)
}
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
let (subject_key, language) = SUBJECTS
.iter()
.find(|(name, ..)| *name == tool)
.map(|(_, key, language)| (Some(*key), *language))
.unwrap_or((None, None));
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
let description = DESCRIPTIONS
.iter()
.find_map(|key| non_blank(json.get(*key)));
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
// Sorted, so the leftovers are in the same order every time this call
// is drawn rather than in whatever order the JSON happened to arrive
// in. A field is left out only when it is already drawn somewhere
// else on the card.
let mut keys: Vec<&String> = json
.keys()
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
.collect();
keys.sort();
let rest = keys
.into_iter()
.map(|key| format!("{key}: {}", as_text(&json[key])))
.collect();
ToolInput {
subject,
language,
description,
timeout,
rest,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_tool_in_the_table_has_its_own_subject() {
// One assertion per row of `SUBJECTS`, because the table is the
// whole of the rule and a row lost in an edit would otherwise
// only show up as a card with no summary line.
let cases = [
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
];
for (tool, input, expected) in cases {
let parsed = parse_tool_input(tool, input);
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
assert_eq!(parsed.title(), Some(expected), "{tool}");
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
}
assert_eq!(
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
Some(Language::Shell),
"a Bash command is shell, and is the one row that names a language"
);
}
#[test]
fn a_tools_own_description_is_what_the_one_line_says() {
// The description wins over the subject: it is the tool's own
// prose about what this call is for, which is what a reader
// scanning a collapsed run is looking for.
let parsed = parse_tool_input(
"Bash",
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
);
assert_eq!(parsed.title(), Some("Run the iris tests"));
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn every_field_not_drawn_elsewhere_is_still_shown() {
// The half the "never dropped" promise is about: a tool this
// build has never heard of has no subject, so *everything* is
// rest -- and a known tool's extra fields are too.
let parsed = parse_tool_input(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
);
assert_eq!(
parsed.rest,
vec![
"new_string: y".to_string(),
"old_string: x".to_string(),
"replace_all: true".to_string(),
],
"sorted, and a non-string value written as JSON"
);
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
assert_eq!(unknown.subject, None);
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
}
#[test]
fn input_that_is_not_an_object_is_still_the_input() {
// Older transcripts and some tools send a bare string; a card
// that dropped it would claim the call had no input at all.
assert_eq!(
parse_tool_input("Bash", "just a string").rest,
vec!["just a string".to_string()]
);
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
assert_eq!(parse_tool_input("Bash", "").title(), None);
}
#[test]
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
assert_eq!(parsed.subject, None);
assert_eq!(parsed.title(), None);
// Not dropped just because it was blank -- it is still a field
// the call carried.
assert_eq!(
parsed.rest,
vec!["command: ".to_string(), "other: 1".to_string()]
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+588
View File
@@ -0,0 +1,588 @@
//! Where a session screen gets a transcript from: this phone's copy first,
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
//!
//! One seam rather than a cache the screen has to remember to consult.
//! Everything fetched before is asked of this, and everything the server
//! sends is written into the cache on the way past, so a caller never
//! learns which side answered. The one rule worth keeping in mind: the
//! cache is never load-bearing. Every read here has a network path beside
//! it producing the same result.
//!
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
//! ability to close a live stream from another thread. Both are wall-clock
//! and thread-lifetime concerns that belong to whatever runtime the caller
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
//! scope) rather than to this pure logic -- `follow` below is the same
//! decorator shape `iris/desktop-app/src/app.rs` and
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
//! `event_stream::follow_session_events`, just with the cache write built
//! in so a future caller does not have to repeat it a third time.
use event_model::SeqEvent;
use crate::client::api::{ApiClient, ApiError, Transport};
use crate::client::event_stream::{self, StreamItem};
use crate::client::transcript_cache::SessionCache;
/// How many events a session screen opens with, cached or fetched.
///
/// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
/// in the Kotlin original).
pub const OPENING_WINDOW: u32 = 80;
/// A transcript-line parse failure, told apart from [`ApiError`] so a
/// caller can tell "the server is unreachable" from "the server (or this
/// phone's own disk) sent something this build cannot read" -- the two
/// mean different things to a reader (retry, versus a build that is
/// behind).
#[derive(Debug, Clone)]
pub struct ParseError(pub String);
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
/// Either half of what can go wrong asking for a page: the network, or a
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
/// read.
#[derive(Debug, Clone)]
pub enum PageError {
Api(ApiError),
Parse(ParseError),
}
impl From<ApiError> for PageError {
fn from(e: ApiError) -> Self {
Self::Api(e)
}
}
impl From<ParseError> for PageError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
/// What [`TranscriptSource::page`] found, kept as two states rather than
/// one possibly-empty list.
///
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
/// empty [`Self::Events`] means "this conversation has no more history",
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
/// question could not be asked yet, which it must not. Collapsing the two
/// into an empty `Vec` puts the bug back, because the caller cannot tell
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
/// same silently.
#[derive(Debug, Clone, PartialEq)]
pub enum OlderPage {
/// The events before the cursor, oldest first. Empty means the start
/// of the conversation has been reached.
Events(Vec<SeqEvent>),
/// Nothing is loaded, so there was no cursor to page back from
/// (`before == 0`). Not an answer about the conversation at all.
NothingLoaded,
}
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
}
/// This phone's copy of one session's transcript, plus the server it
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>,
session_id: String,
pub cache: SessionCache,
}
impl<T: Transport> TranscriptSource<T> {
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
Self {
api,
session_id: session_id.into(),
cache,
}
}
/// The cached opening window, or `None` when there is nothing usable
/// to draw.
///
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
/// whole point of the feature: the rows are on screen while the check
/// that they are still the server's rows is in flight, and a failed
/// check replaces them exactly as a reset does.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?;
let lines = self.cache.newest(limit);
if lines.is_empty() {
return None;
}
match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events),
// A line this build cannot read at all, which the cache's own checks cannot
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
// open.
Err(ParseError(_)) => {
self.cache.purge();
None
}
}
}
/// Whether the server's event at the cached cursor is still the cached
/// one.
///
/// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a
/// sandbox re-seeded with the same ids, a backup restored, a session
/// re-imported), and the server's catch-up on such a file would hand
/// this phone a continuation of a *different* conversation, spliced
/// onto the cached one with no seam. Caught with one request of a few
/// hundred bytes.
///
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule.
///
/// What this cannot see is a line changed in the middle of the file
/// with the tail intact -- that is what a full reload is for.
pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else {
return Ok(false);
};
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
// event *at* the cursor when the server still has one there.
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(tail.seq + 1),
1,
false,
None,
)?;
let matches = page.len() == 1
&& parse_line(&tail.line)
.map(|cached| cached == page[0].1)
.unwrap_or(false);
if !matches {
self.cache.purge();
}
Ok(matches)
}
/// Today's opening fetch, kept as the start of the live run. Only
/// called when the cache has nothing to open with, or when
/// [`Self::probe`] said what it had was not the server's.
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page =
self.api
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
for (line, event) in &page {
self.cache.append(line, event.seq);
}
self.cache.flush();
Ok(page.into_iter().map(|(_, event)| event).collect())
}
/// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has.
///
/// The server bound (`after`) is what keeps the cache worth having. A
/// coalesced page reaches back as far as its row count takes it -- a
/// single reply is hundreds of lines -- so a page fetched after the
/// reader has been away could run straight past the cached run and
/// overlap it, and an overlapping page cannot be stored. Told where
/// this phone's copy starts, the server stops there instead.
///
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
/// the cache or the server anything -- see AGENTS.md's "things that
/// have bitten": there is no event before the first one, so the
/// request is not a harmless no-op, and its empty answer is
/// indistinguishable from having reached the start of history.
/// Guarded here rather than left to every caller, because it is a fact
/// about the question, not about who is asking it.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 {
return Ok(OlderPage::NothingLoaded);
}
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
let events: Vec<SeqEvent> = lines
.iter()
.map(|l| parse_line(l).map_err(PageError::from))
.collect::<Result<_, _>>()?;
return Ok(OlderPage::Events(events));
}
let after = self.cache.covered_up_to(before).map(|v| v - 1);
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(before),
limit,
coalesce,
after,
)?;
if let Some((_, first_event)) = page.first() {
// `before` rather than the newest line's seq: a coalesced page covers
// everything up to the cursor it was asked with, and nothing in its lines
// says so.
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache
.store_page(&lines, first_event.seq, before, coalesce);
}
Ok(OlderPage::Events(
page.into_iter().map(|(_, event)| event).collect(),
))
}
/// [`event_stream::follow_session_events`], with every frame written to
/// the cache before `on_item` sees it.
///
/// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each
/// status change, which is a turn's boundary and the granularity a
/// crash may as well lose, and once more when the stream ends.
pub fn follow(
&self,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let cache = &self.cache;
let result = event_stream::follow_session_events(
self.api.transport(),
&self.session_id,
after,
|item| {
if let StreamItem::Event { raw, event } = &item {
cache.append(raw, event.seq);
if matches!(event.event, event_model::Event::Status { .. }) {
cache.flush();
}
}
on_item(item)
},
);
cache.flush();
result
}
/// Leaves the cache with everything it was given -- called once a
/// caller is done with this source, mirroring the Kotlin `close`'s
/// final flush (that method's stream cancellation itself is the
/// runtime concern the module doc says is not ported here).
pub fn close(&self) {
self.cache.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::collections::VecDeque;
use std::io::Read;
use std::sync::Mutex;
/// A transport that answers fixed bodies in call order, and records
/// every path it was asked for -- so a test can assert *how many*
/// requests a method made, which is the point for the `before == 0`
/// guard (AGENTS.md's regression: the guard must stop the request
/// before it happens, not merely tolerate the empty answer).
#[derive(Default)]
struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>,
calls: Mutex<Vec<String>>,
}
impl ScriptedTransport {
fn respond(&self, status: u16, body: impl Into<String>) {
self.responses
.lock()
.unwrap()
.push_back((status, body.into()));
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl Transport for ScriptedTransport {
fn request(
&self,
_method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (status, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
Ok(RawResponse {
status,
body: body.into_bytes(),
})
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (_, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
panic!("ScriptedTransport got an unscripted stream request: {path}")
});
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
}
}
fn source(
transport: ScriptedTransport,
cache_root: &std::path::Path,
) -> TranscriptSource<ScriptedTransport> {
let api = ApiClient::new(transport);
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
TranscriptSource::new(api, "s1", cache)
}
fn status_line(seq: u64) -> String {
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
}
#[test]
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
assert_eq!(source.cached_opening(80), None);
let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1);
// The fetch wrote through: reopening the same cache now has something to show.
assert!(source.cache.tail().is_some());
}
#[test]
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(1)));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().unwrap());
assert!(source2.cache.tail().is_some());
}
#[test]
fn probe_mismatching_the_cached_tail_purges_the_cache() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
// The server now answers with a different event at the same seq -- the file
// behind this session was replaced.
let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]"));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(!source2.probe().unwrap());
assert!(source2.cache.tail().is_none());
}
#[test]
fn probe_finding_no_server_leaves_the_cache_untouched() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(500, "server on fire");
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().is_err());
assert!(
source2.cache.tail().is_some(),
"an unreachable server must not be treated as a mismatch"
);
}
/// The regression this module exists to close: `before == 0` must
/// never reach the network or the cache, because an empty answer there
/// is indistinguishable from "there is genuinely no more history" --
/// AGENTS.md's `loadOlderPage` incident.
#[test]
fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
let source = source(transport, dir.path());
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
assert_eq!(source.api.transport().call_count(), 0);
}
#[test]
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let calls_before = source.api.transport().call_count();
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
panic!("a cursor of 2 is a real question about the conversation");
};
assert_eq!(page.len(), 1);
assert_eq!(page[0].seq, 1);
assert_eq!(
source.api.transport().call_count(),
calls_before,
"a cache hit must not touch the network"
);
}
/// With nothing older cached there is no floor to give the server, so
/// the request carries no `after` at all.
#[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(5)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(3)));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
source2.page(5, 10, true).unwrap();
assert_eq!(
source2.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
);
}
/// The half the test above cannot show: when the cache *does* hold an
/// older run, the fetch is floored at its end, or the page would run
/// straight past it and overlap -- which `store_page` then refuses,
/// silently costing the phone the page it just paid for.
#[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
// A stored page covering [3, 6) and two live events above it, so the run this
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
// cache reads the directory as damaged and discards it.
let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6);
cache.append(&status_line(7), 7);
cache.flush();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(9)));
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
source.page(10, 10, true).unwrap();
assert_eq!(
source.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
"the fetch must stop one seq below where this phone's copy ends"
);
}
/// A page the server could not answer is an error, never an empty
/// page: the caller would read the second as "this conversation has no
/// more history" and stop paging for good.
#[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(500, "server on fire");
let source = source(transport, dir.path());
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
}
/// A cached line this build cannot read is told apart from the network
/// failing, for the same reason: neither is "no more history".
#[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.store_page(
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
3,
4,
true,
);
cache.append(&status_line(4), 4);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
assert_eq!(
source.api.transport().call_count(),
0,
"a cache hit that cannot be read must not fall through to the server unnoticed"
);
}
#[test]
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.append("not json at all", 1);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert_eq!(source.cached_opening(80), None);
assert!(
source.cache.tail().is_none(),
"a damaged line purges the cache"
);
}
#[test]
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
let source = source(transport, dir.path());
let mut seen = Vec::new();
source
.follow(0, |item| {
if let StreamItem::Event { event, .. } = item {
seen.push(event.seq);
}
true
})
.unwrap();
assert_eq!(seen, vec![1]);
assert_eq!(source.cache.tail().unwrap().seq, 1);
}
fn sse_frame(data: &str) -> String {
format!("data:{data}")
}
}
+425
View File
@@ -0,0 +1,425 @@
//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5)
//! filling the rest, both against a real `ai-server` reached through
//! `client-core`. The layout is the simplest thing that shows both at
//! once -- a fixed-width column and `rest(1)` for everything else, using
//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches
//! panes, rather than anything desktop-specific:
//!
//! ```text
//! +-----------+--------------------------------------+
//! | session | crate::ui::TranscriptScreen |
//! | list | (List of folded rows + composer) |
//! | (WidgetPtr| |
//! | swapped | (WidgetPtr swapped whole on session |
//! | on data) | switch or a new transcript event) |
//! +-----------+--------------------------------------+
//! ```
//!
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
//! full rebuild: `crate::client::transcript_fold::fold_event` folds the new
//! item list as before, then `apply` updates only the row(s) that actually
//! changed (almost always the one still-open assistant message a delta
//! landed in) instead of rebuilding the whole right-hand widget tree from
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
//! freshly loaded/selected session and for `apply`'s own rare
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
//! before the tail). The composer's in-progress text survives a rebuild
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
//! followup while a reply streams in is the one case a naive rebuild
//! would otherwise lose data on -- `apply`'s own path never touches the
//! composer at all, so this only matters on the fallback.
//!
//! Background network I/O (`crate::client::api`/`event_stream`, both
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
//! `std::thread`s that report back through `winit`'s `EventLoopProxy`
//! (`Proxy<AppEvent>`), rather than through iris's own `Tasks`/`task_on`:
//! `Tasks` only requests a redraw once, after its whole async closure
//! finishes, which fits a single request-then-update but not a live SSE
//! loop that needs to be seen redrawing after *each* event it relays.
//! `Proxy::send_event` wakes the window's event loop immediately, once per
//! event, which is what a stream wants.
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
};
use event_model::SeqEvent;
use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// The session list column's width -- a fixed size for the simplest
/// layout that shows both panels at once (UI_RULES's text-truncation and
/// no-shrink rules apply to what's drawn inside it, not to this choice of
/// column width itself).
const LIST_WIDTH: f32 = 260.0;
/// Everything a background thread hands back to the window's event loop.
/// `generation` on the session-scoped variants is the generation
/// `select_session` was on when the thread started (`Client::generation`)
/// -- compared back against the current one before being applied, so a
/// slow response from a session the reader has since clicked away from
/// can't overwrite what replaced it.
enum AppEvent {
Sessions(Result<Vec<SessionSummary>, String>),
TranscriptLoaded {
session_id: String,
generation: u64,
result: Result<Vec<TranscriptItem>, String>,
},
StreamEvent {
session_id: String,
generation: u64,
event: SeqEvent,
},
StreamEnded {
session_id: String,
generation: u64,
message: Option<String>,
},
SendFailed(String),
}
pub fn run() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
struct Client {
ui_state: DefaultUiState,
api: Arc<ApiClient<UreqTransport>>,
/// A second, independent `UreqTransport` to the same server, used only
/// by `select_session`'s live-follow loop. `ApiClient` keeps its
/// transport private (rightly -- nothing outside it should reach past
/// the typed calls), so a caller that also needs the raw
/// `Transport::stream` for SSE, as this one does, builds its own
/// rather than the crate growing a getter whose only purpose would be
/// letting one caller reach around its own abstraction.
stream_transport: Arc<UreqTransport>,
proxy: Proxy<AppEvent>,
sessions: Vec<SessionSummary>,
selected: Option<String>,
items: Vec<TranscriptItem>,
list_ptr: WeakWidget<WidgetPtr>,
transcript_ptr: WeakWidget<WidgetPtr>,
screen: Option<crate::ui::TranscriptScreen>,
/// Bumped every time the selected session changes; see `AppEvent`'s
/// doc for what it guards against.
generation: Arc<AtomicU64>,
}
impl DefaultAppState for Client {
type Event = AppEvent;
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
proxy: Proxy<AppEvent>,
) -> Self {
// Re-validated here rather than threaded through from `main` --
// `DefaultApp::run()` takes no payload, so there is no other way
// to get `main`'s parsed CLI/config into this constructor. `main`
// already called this once to fail fast before a window opens;
// this call only fails if the filesystem changed underneath the
// process in between, which is not a case worth a nicer message.
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
eprintln!("desktop-app: {e}");
std::process::exit(2);
});
let build_transport =
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
let (rest_transport, stream_transport) = build_transport()
.and_then(|rest| build_transport().map(|stream| (rest, stream)))
.unwrap_or_else(|e| {
eprintln!(
"desktop-app: couldn't set up TLS to {}: {e}",
server.base_url()
);
std::process::exit(1);
});
let api = Arc::new(ApiClient::new(rest_transport));
let stream_transport = Arc::new(stream_transport);
let list_ptr = WidgetPtr::new().add(rsc);
let transcript_ptr = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading sessions...");
transcript_ptr(rsc).set(loading);
(list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1)))
.span(Dir::RIGHT)
.set_root(rsc, &mut ui_state);
let client = Self {
ui_state,
api,
stream_transport,
proxy,
sessions: Vec::new(),
selected: None,
items: Vec::new(),
list_ptr,
transcript_ptr,
screen: None,
generation: Arc::new(AtomicU64::new(0)),
};
client.spawn_fetch_sessions();
client
}
fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc<Self>, _render: &mut UiRenderState) {
match event {
AppEvent::Sessions(Ok(sessions)) => {
self.sessions = sessions;
self.rebuild_list(rsc);
if self.selected.is_none() {
self.show_message(rsc, "Select a session.");
}
}
AppEvent::Sessions(Err(message)) => {
self.show_message(rsc, &format!("Couldn't list sessions: {message}"));
}
AppEvent::TranscriptLoaded {
session_id,
generation,
result,
} => {
if self.current(&session_id, generation) {
match result {
Ok(items) => {
self.items = items;
self.rebuild_transcript(rsc);
}
Err(message) => {
self.show_message(
rsc,
&format!("Couldn't load {session_id}: {message}"),
);
}
}
}
}
AppEvent::StreamEvent {
session_id,
generation,
event,
} => {
if self.current(&session_id, generation) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, &event);
match &self.screen {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
}
AppEvent::StreamEnded {
session_id,
generation,
message: Some(message),
} => {
if self.current(&session_id, generation) {
eprintln!("desktop-app: {session_id}'s live connection ended: {message}");
}
}
AppEvent::StreamEnded { .. } => {}
AppEvent::SendFailed(message) => {
eprintln!("desktop-app: couldn't send: {message}");
}
}
self.ui_state.window.request_redraw();
}
}
impl Client {
fn current(&self, session_id: &str, generation: u64) -> bool {
self.selected.as_deref() == Some(session_id)
&& self.generation.load(Ordering::SeqCst) == generation
}
/// Replaces the right-hand panel with a line of text -- built before
/// `transcript_ptr` is reached for, since building the message and
/// swapping it in both need `rsc` and can't overlap as one borrow.
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.transcript_ptr)(rsc).set(widget);
}
fn spawn_fetch_sessions(&self) {
let api = self.api.clone();
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let result = api.fetch_sessions().map_err(|e| e.to_string());
let _ = proxy.send_event(AppEvent::Sessions(result));
});
}
fn rebuild_list(&mut self, rsc: &mut DefaultRsc<Self>) {
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
for session in &self.sessions {
let selected = self.selected.as_deref() == Some(session.id.as_str());
let row = session_row(rsc, session, selected);
list(rsc).push(row);
}
let tree = list
.background(rect(Color::rgb(24, 24, 28)))
.add_strong(rsc)
.any();
(self.list_ptr)(rsc).set(tree);
}
/// Selecting a session starts a fresh generation: any thread still
/// working for the previous one checks `Client::current` before
/// touching state, so a slow response for a session the reader has
/// clicked away from is silently dropped rather than overwriting what
/// replaced it.
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.selected = Some(session_id.clone());
self.items.clear();
self.screen = None;
self.rebuild_list(rsc);
self.show_message(rsc, "Loading transcript...");
let api = self.api.clone();
let stream_transport = self.stream_transport.clone();
let proxy = self.proxy.clone();
let live_generation = self.generation.clone();
std::thread::spawn(move || {
// The most recent 200 events, coalesced -- plenty for a
// desktop proof; RUST.md's I3/history-paging work is what a
// real scrollback would reuse, out of scope here (E4 is only
// "the same screen runs in a window").
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
// The raw wire `seq` of the last line fetched -- not the seq of
// the last *folded item*. A `TranscriptItem::AssistantMsg` keeps
// the seq of the first delta it accumulated (`fold_event`'s own
// doc: "a row whose identity changed with every delta would be
// a new row every frame"), so resuming the live stream from
// that seq re-delivers every delta already folded into it,
// duplicating the tail of whatever reply was mid-stream when
// the page was fetched. Found by screenshotting a real reply
// through `run-headless.sh`: the assistant's line read "You
// said: ... testsaid: ... test", the back half being deltas 2
// through N replayed onto an already-complete message.
let after = page
.as_ref()
.ok()
.and_then(|values| raw_seq(values.last()?))
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
let _ = proxy.send_event(AppEvent::TranscriptLoaded {
session_id: session_id.clone(),
generation,
result,
});
// Follows live from here in the same thread -- sequential
// rather than a second thread, since there is nothing to do
// with the stream until the page above has been sent anyway.
let stop = || live_generation.load(Ordering::SeqCst) != generation;
if stop() {
return;
}
let outcome =
follow_session_events(&*stream_transport, &session_id, after, |item| match item {
StreamItem::Open | StreamItem::Reset => !stop(),
StreamItem::Event { event, .. } => {
if stop() {
return false;
}
let _ = proxy.send_event(AppEvent::StreamEvent {
session_id: session_id.clone(),
generation,
event,
});
true
}
});
let _ = proxy.send_event(AppEvent::StreamEnded {
session_id,
generation,
message: outcome.err().map(|e| e.to_string()),
});
});
}
fn send_message(&mut self, session_id: String, text: String) {
let api = self.api.clone();
let proxy = self.proxy.clone();
std::thread::spawn(move || {
if let Err(e) = api.send_message(&session_id, &text, &[]) {
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
}
});
}
fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc<Self>) {
let in_progress = self
.screen
.as_ref()
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
}
if let Some(session_id) = self.selected.clone() {
let field = screen.composer.field;
rsc.register_event(field, Submit, move |ctx, rsc| {
let text = field.edit(rsc).take();
let text = text.trim().to_string();
if !text.is_empty() {
ctx.state.send_message(session_id.clone(), text);
}
});
}
(self.transcript_ptr)(rsc).set(tree);
self.screen = Some(screen);
}
}
/// One row in the session list: title on top, status below, highlighted
/// when it's the one currently shown.
fn session_row(
rsc: &mut DefaultRsc<Client>,
session: &SessionSummary,
selected: bool,
) -> StrongWidget {
let bg = if selected {
Color::rgb(58, 90, 138)
} else {
Color::rgb(38, 38, 44)
};
let id = session.id.clone();
let label = format!("{}\n{}", session.title, session.status);
wtext(label)
.color(Color::WHITE)
.wrap(true)
.pad(10)
.width(rest(1))
.background(rect(bg))
.on(
CursorSense::click(),
move |ctx, rsc: &mut DefaultRsc<Client>| {
ctx.state.select_session(rsc, id.clone());
},
)
.add_strong(rsc)
.any()
}
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
+31
View File
@@ -0,0 +1,31 @@
//! Where the desktop app keeps its enrollment: `crate::client::config`'s
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
//!
//! Only the directory is this app's -- the file's name, its JSON, and its
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
//! are the store's, shared with the Android client so the two cannot come
//! to disagree about them.
use crate::client::config::EnrollmentStore;
use std::path::PathBuf;
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
/// the XDG basedir spec says to when the variable is unset -- the same
/// fallback `wg_app_link::xdg::config_home` uses, reimplemented here
/// rather than depended on: that helper lives in the `wg-app-link`
/// submodule, which `server/` needs but this desktop-only crate does not,
/// and pulling in a git submodule for one path join would cost more than
/// it saves.
pub fn config_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").expect("HOME must be set");
PathBuf::from(home).join(".config")
});
base.join("ai-app-desktop")
}
pub fn store() -> EnrollmentStore {
EnrollmentStore::new(config_dir())
}
+7
View File
@@ -0,0 +1,7 @@
//! The desktop entry point's state: the widget tree, the event flow and
//! the saved enrolment. `src/bin_desktop.rs` is the binary that drives it
//! -- see that file's doc comment for the command line.
pub mod app;
pub mod config;
pub mod startup;
+76
View File
@@ -0,0 +1,76 @@
//! The desktop binary's command line and the enrolment it resolves --
//! `--link`/`--ca`, parsed once at startup and again from `app.rs`'s
//! `Client::new`. Here rather than in `src/bin_desktop.rs` because both
//! callers are in the library; the binary is only `fn main`.
use crate::client::config::EnrolledServer;
use super::config;
struct Args {
ca_path: Option<std::path::PathBuf>,
link: Option<String>,
}
fn parse_args() -> Result<Args, String> {
let mut ca_path = None;
let mut link = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--ca" => {
ca_path = Some(std::path::PathBuf::from(
args.next().ok_or("--ca needs a path")?,
))
}
"--link" => link = Some(args.next().ok_or("--link needs a value")?),
other => return Err(format!("unrecognised argument '{other}'")),
}
}
Ok(Args { ca_path, link })
}
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
/// server (freshly parsed from `--link`, or read back from last time) and
/// the CA's PEM bytes. Loading is a pure function of the process's own
/// argv and config file, so it is safe to call again from `Client::new` --
/// see that call site's comment for why it is not threaded through some
/// other way (`DefaultApp::run()` takes no payload).
pub fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
let args = parse_args()?;
let store = config::store();
let server = match args.link {
Some(link) => {
let server = EnrolledServer::parse_link(&link)?;
store
.save(&server)
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
server
}
None => store
.load()
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
.ok_or_else(|| {
format!(
"no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \
once (app/ui-sandbox.sh's start banner prints one)",
config::config_dir().display()
)
})?,
};
// `--ca` wins where it was given, so a caller can point a link's
// server at a certificate it did not carry -- and so the flag still
// means what it did before the link could carry one.
let ca_pem = match (&args.ca_path, &server.ca_pem) {
(Some(path), _) => std::fs::read(path)
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
(None, Some(pem)) => pem.clone().into_bytes(),
(None, None) => {
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
minted by a server that includes one"
.to_string());
}
};
Ok((server, ca_pem))
}
+38
View File
@@ -0,0 +1,38 @@
//! The app -- a phone and desktop interface to AI coding sessions, drawn
//! with `iris` (the UI framework next door, which knows nothing about any
//! of this) and talking to `ai-server` over the model in `event-model`.
//!
//! Four modules, and the three entry points that combine them:
//!
//! - [`client`] -- no UI at all: the REST and SSE clients, the transcript
//! cache and fold, the highlighter, the ANSI parser, config and the
//! enrolment link. `docs/CLIENT_CORE.md` is its design.
//! - [`ui`] -- the screens, as iris widget trees. `docs/RUST.md`'s I5.
//! - [`desktop`] -- the winit entry point's app state (`src/bin_desktop.rs`
//! is the binary itself).
//! - [`android`] -- the `android-view` entry point: JNI registration, the
//! view peer, the enrolment deep link and the on-device log.
//! - [`shell`] -- a second, separate JNI surface: the bridge the *Kotlin*
//! app in `app/shellApp` calls for notifications, sharing and settings.
//! It draws nothing, which is why it is behind its own feature.
pub mod client;
#[cfg(feature = "screens")]
pub mod ui;
#[cfg(all(feature = "screens", not(target_os = "android")))]
pub mod desktop;
#[cfg(all(feature = "screens", target_os = "android"))]
pub mod android;
// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`,
// which this toolchain deprecates in favour of `try_update`. The call is
// inside the macro, so there is nothing here to migrate -- the fix is a
// `jni` release, and this allow comes out when one lands. Scoped to the
// module the macro is used in rather than the crate, so a deprecation in
// our own code is still a warning.
#[cfg(feature = "shell")]
#[allow(deprecated)]
pub mod shell;
+152
View File
@@ -0,0 +1,152 @@
//! Thin wrappers around the five `Env` calls this crate makes constantly
//! (a class name, a method name and a signature, all as plain `&str`).
//!
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
//! here uniformly rather than switching to the compile-time `jni_str!`
//! literal macro call by call -- these are a handful of short, one-off
//! lookups, not a hot loop, so the difference is not worth two code paths
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
//! `FieldSignature`, which is why those go through
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
//! parsed form is what lets these calls skip re-validating the signature
//! against the arguments on every call, which is the whole reason `jni`
//! moved to it.
//!
//! **The classloader gotcha, found by testing (2026-09-05).** A class
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
//! `get_static_field` -- anything that resolves a *class*, as opposed to
//! `call_method` on an object it already has, which needs no such lookup)
//! defaults to `FindClass`'s ordinary search when it cannot find the
//! calling thread a classloader through `Thread.getContextClassLoader()`.
//! That default is fine on a thread the JVM itself started -- an
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
//! from `android-shell`'s own background thread (the notification
//! follow-loop, the share upload) is running on a thread *Rust* spawned
//! and attached with `JavaVM::attach_current_thread`, which the platform
//! never gave an app classloader. Framework classes
//! (`android.app.Notification$Builder`, ...) still resolve, because they
//! are reachable from the bootstrap loader `FindClass` falls back to --
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
//! packaged inside this app's own APK. The failure was
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
//! as "failed to resolve Java class ... (class not found or linkage
//! error)" -- on a real device this reads as "the notification silently
//! never arrives," since the whole call is inside the follow loop and the
//! ongoing foreground notification (built on the main thread, in
//! `try_start`, before the background thread exists) posts fine either
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
//! first time any entry point has a `Context` to ask, and every class
//! lookup below goes through it explicitly via `LoaderContext::Loader`
//! rather than the thread-dependent default -- so it is correct on the
//! main thread and on this crate's own background threads alike.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
/// Caches `context`'s own `ClassLoader`, the first time this is called.
/// Cheap to call from every entry point that has a `Context` on hand
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
/// a `OnceLock::get` and nothing else.
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
// context.getClass().getClassLoader() -- resolved via `call_method` on
// real objects throughout, so this needs no class-name lookup of its
// own and has nothing to bootstrap.
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
/// A new Java string as a plain `JObject` -- what every call site here
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
/// `JString: Into<JObject>` is the documented way across).
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
+131
View File
@@ -0,0 +1,131 @@
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
//! package comment for what this crate is and RUST.md's E3 entry for the
//! design decisions.
//!
//! Each native method is declared with `jni`'s [`native_method!`] macro
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
//! the macro derives the mangled export name and the JNI signature from the
//! Rust function itself, so the two cannot drift apart the way a
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
//! routinely do. `error_policy = LogErrorAndDefault` matches
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
//! JNI call that threw) is reported to logcat, not thrown back into Java
//! as an exception that would crash the app over something recoverable.
//!
//! Each `const _: NativeMethod = native_method! { ... };` binding is
//! otherwise unused by name -- `_` is the idiomatic way to keep a
//! side-effecting const (here, generating the `#[export_name]`d function
//! the JVM resolves by the JNI naming convention) without a `dead_code`
//! warning for a binding nothing reads.
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("android-shell"),
);
});
}
// The parameters are spelled as their Java types, not as `JObject`: the
// macro encodes each argument into the exported symbol's JNI signature
// (and JNI resolves `Java_...` names *by* that signature), so a generic
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
// method actually declared `(Landroid/app/Activity;...)` -- two different
// symbols that never resolve to each other, silently, with no compiler
// error on either side. `android.app.Activity` etc. have no dedicated
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
// implementation functions below (the "Built-in Types" note in
// `native_method!`'s docs).
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.MainActivity",
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
error_policy = LogErrorAndDefault,
};
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
activity: JObject<'local>,
intent: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &activity)?;
share::handle_intent(env, &activity, &intent)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_sync(context: android.content.Context) -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &context)?;
notify::sync(env, &context)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_start_command(service: android.app.Service) -> jint,
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
service: JObject<'local>,
) -> Result<jint, jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &service)?;
Ok(notify::on_start_command(env, service))
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_destroy() -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}
+565
View File
@@ -0,0 +1,565 @@
//! Where a notification is said, and the foreground service that keeps
//! the connection open while the app is closed. Ported from
//! `Notifications.kt`'s `NotificationService`, minus the "session on
//! screen" / "hand to the app as a banner" branches: those read
//! process-wide state that only exists because a screen is drawn to
//! register against, and this experiment draws no screen yet (that is
//! E4's job, on iris). So every notification here takes the third branch
//! Kotlin's `show` already had -- the platform's own drawer -- which is
//! also exactly the case E3's pass condition asks for: **a notification
//! arrives with the app closed.**
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::client::api::UreqTransport;
use crate::client::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::shell::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
/// on *every* call, with nothing to notice a previous one is still going --
/// and `sync()` calling `startForegroundService` when the service is
/// already running is an ordinary Android start, not a restart, so
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
/// `sync` once itself, then again inside `handle_enrollment` after saving
/// the token) hits exactly this path and was observed opening **two**
/// concurrent connections to `/notifications` from one process -- caught
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
/// thread ai-app-notifications` lines for one enrollment. Guarded here
/// rather than left to match Kotlin's behaviour exactly, since duplicating
/// a live connection is a resource leak with no upside; worth carrying the
/// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false);
/// Set by `nativeOnDestroy`, checked by the follow loop between
/// reconnects. **Known gap, recorded rather than hidden**: unlike
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
/// can interrupt a `ureq` read already blocked inside one connection --
/// `Transport::stream` hands back a plain `Read` with no cancellation
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
/// practice this is a bounded wait rather than a hang; closing that gap
/// for real means adding a cancellation point to `crate::client::Transport`,
/// which is a decision affecting every caller of that trait, not just this
/// one -- left for whoever next depends on prompt shutdown.
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/NotificationManagerCompat",
"from",
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
&[JValue::Object(context)],
)?
.l()
}
fn create_channel(
env: &mut Env,
manager: &JObject,
id: &str,
name: &str,
importance: i32,
) -> Result<()> {
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
crate::shell::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroidx/core/app/NotificationChannelCompat;",
&[],
)?
.l()?;
crate::shell::jcall::call_method(
env,
manager,
"createNotificationChannel",
"(Landroidx/core/app/NotificationChannelCompat;)V",
&[JValue::Object(&channel)],
)?;
Ok(())
}
/// Two channels, because they are two different things to be told -- see
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
/// importances here are copied from it exactly, since a phone that has
/// seen both apps should not learn two different vocabularies for the
/// same fact.
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
let manager = notification_manager(env, context)?;
let default = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_DEFAULT",
)?;
let min = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_MIN",
)?;
create_channel(
env,
&manager,
ALERT_CHANNEL,
"Sessions needing attention",
default,
)?;
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
Ok(())
}
fn new_intent_for<'l>(
env: &mut Env<'l>,
context: &JObject,
class_name: &str,
) -> Result<JObject<'l>> {
let target_class = crate::shell::jcall::find_class(env, class_name)?;
crate::shell::jcall::new_object(
env,
"android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V",
&[JValue::Object(context), JValue::Object(&target_class)],
)
}
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
/// `sessionIntent`, including building the URI through `Uri.Builder`
/// rather than string concatenation, for the same reason: an id needing
/// escaping must survive the round trip.
fn session_intent<'l>(
env: &mut Env<'l>,
context: &JObject,
session_id: &str,
) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::shell::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
crate::shell::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
crate::shell::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
crate::shell::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
.l()?;
crate::shell::jcall::call_method(
env,
&intent,
"setData",
"(Landroid/net/Uri;)Landroid/content/Intent;",
&[JValue::Object(&uri)],
)?;
Ok(intent)
}
fn pending_activity<'l>(
env: &mut Env<'l>,
context: &JObject,
intent: &JObject,
) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::shell::jcall::call_static_method(
env,
"android/app/PendingIntent",
"getActivity",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
&[
JValue::Object(context),
JValue::Int(0),
JValue::Object(intent),
JValue::Int(update_current | immutable),
],
)?
.l()
}
fn builder_call<'l>(
env: &mut Env<'l>,
builder: &JObject<'l>,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<()> {
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
Ok(())
}
/// The type Android 14+ requires a foreground service to declare, and
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
fn foreground_type(env: &mut Env) -> Result<i32> {
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
if sdk >= upside_down_cake {
static_int(
env,
"android/content/pm/ServiceInfo",
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
)
} else {
Ok(0)
}
}
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setOngoing",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
builder_call(
env,
&builder,
"setPriority",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)],
)?;
crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()
}
/// Starts the service if there is a server to connect to, and stops it
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() {
crate::shell::jcall::call_method(
env,
context,
"stopService",
"(Landroid/content/Intent;)Z",
&[JValue::Object(&service_intent)],
)?;
return Ok(());
}
create_channels(env, context)?;
crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"startForegroundService",
"(Landroid/content/Context;Landroid/content/Intent;)V",
&[JValue::Object(context), JValue::Object(&service_intent)],
)?;
Ok(())
}
/// The `Service.onStartCommand` body -- loads settings, starts the
/// foreground notification, and spawns the follow-loop thread. Answers the
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
/// framework rather than hardcoded so a wrong guess at their values cannot
/// silently pick the other behaviour.
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
Err(e) => {
log_error(env, "onStartCommand", &e);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
}
}
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let Some(settings) = settings::load(env, service)? else {
return Ok(false);
};
let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?;
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/ServiceCompat",
"startForeground",
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
&[
JValue::Object(service),
JValue::Int(ONGOING_ID),
JValue::Object(&notification),
JValue::Int(fg_type),
],
)?;
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
// the first is still going -- the ordinary case for this service,
// since `sync()` is called from more than one place -- must not open
// a second connection.
if RUNNING.swap(true, Ordering::SeqCst) {
return Ok(true);
}
let vm = env.get_java_vm()?;
let context = env.new_global_ref(service)?;
STOPPING.store(false, Ordering::SeqCst);
std::thread::Builder::new()
.name("ai-app-notifications".to_string())
.spawn(move || {
// Requests a *permanent* attachment (detached only when this thread
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
// this is the long-lived follow loop, not a one-shot callback.
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
});
})
.ok();
Ok(true)
}
/// Follows the backend's notification stream, reconnecting until stopped
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
/// ordinary case, so it retries quietly and forever; nothing is shown when
/// it cannot connect, for the same reason as the Kotlin original: a
/// notification saying "I could not tell you whether anything happened" is
/// noise about a condition nobody can act on.
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
let _ = follow_notifications(&transport, |notification| {
if let Err(e) = show(env, context, &notification) {
log_error(env, "show", &e);
}
!STOPPING.load(Ordering::SeqCst)
});
}
if STOPPING.load(Ordering::SeqCst) {
return;
}
std::thread::sleep(RECONNECT_DELAY);
}
}
/// One notification per session, replacing that session's previous one --
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
/// branches this module's doc comment explains.
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
let allowed = if sdk < tiramisu {
true
} else {
let permission =
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"checkSelfPermission",
"(Landroid/content/Context;Ljava/lang/String;)I",
&[JValue::Object(context), JValue::Object(&permission)],
)?
.i()?;
result == granted
};
let enabled =
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::shell::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call(
env,
&builder,
"setContentText",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&text)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&pending)],
)?;
builder_call(
env,
&builder,
"setAutoCancel",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let when = (notification.at * 1000.0) as i64;
builder_call(
env,
&builder,
"setWhen",
"(J)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Long(when)],
)?;
builder_call(
env,
&builder,
"setShowWhen",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let built = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroid/app/Notification;",
&[],
)?
.l()?;
let tag = crate::shell::jcall::jstr_obj(env, &notification.session_id)?;
crate::shell::jcall::call_method(
env,
&manager,
"notify",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
&[
JValue::Object(&tag),
JValue::Int(ALERT_ID),
JValue::Object(&built),
],
)?;
Ok(())
}
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
/// the gap this module's `STOPPING` doc explains.
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
// comment): the old thread may still be inside a blocked read when a
// new `onStartCommand` follows immediately, which would spawn a
// second one before the first has actually stopped. Narrower than not
// resetting at all -- a service destroyed and never restarted would
// otherwise wedge `RUNNING` true forever -- and no worse than the
// known gap already accepted above.
RUNNING.store(false, Ordering::SeqCst);
}
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> {
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
crate::shell::jcall::call_static_method(
env,
"android/util/Log",
"e",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[JValue::Object(&tag), JValue::Object(&msg)],
)?;
Ok(())
})();
}
+142
View File
@@ -0,0 +1,142 @@
//! Enrollment: where the backend is, and the Keystore-sealed token to
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
//! class the production app already uses (see `ServerConfig.kt`), through
//! JNI, for two reasons: that code is shared with Dev Updater and already
//! tested, and the sealed value on a real phone is keyed to the exact
//! Keystore alias that class already uses -- reimplementing the crypto
//! here would either duplicate it or invalidate an existing enrollment.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
/// Where the backend is and how to authenticate to it -- the Rust twin of
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
/// rather than kept as a live JNI reference, so it can cross a thread
/// boundary (a `JObject` is tied to one `Env`/thread).
#[derive(Debug, Clone)]
pub struct ServerSettings {
pub host: String,
pub port: i32,
pub token: String,
}
impl ServerSettings {
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
/// This experiment's own scheme and Keystore alias -- distinct from the
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
/// installed side by side on the same development device without
/// colliding over which one a scanned QR or a deep link resolves to. See
/// RUST.md's E3 entry for why they are not the same value.
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::shell::jcall::new_object(
env,
STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&scheme), JValue::Object(&alias)],
)
}
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?;
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value =
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env)
}
/// The stored enrollment, or `None` when there is not one -- mirrors
/// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"load",
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(context)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::shell::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
&[
JValue::Object(&host),
JValue::Int(settings.port),
JValue::Object(&token),
],
)?;
crate::shell::jcall::call_method(
env,
&store,
"save",
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
&[JValue::Object(context), JValue::Object(&settings_obj)],
)?;
Ok(())
}
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
/// query-parameter rules rather than re-deriving them here.
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"parseEnrollmentUri",
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(uri)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// The CA this build pins, generated at build time the same way
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
/// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::shell::jcall::get_static_field(
env,
"com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM",
"Ljava/lang/String;",
)?
.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(jstr.try_to_string(env)?.into_bytes())
}
+185
View File
@@ -0,0 +1,185 @@
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
//!
//! **Scope cut, recorded rather than silent**: only shared *text*
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
//! ported here, because `client-core`'s `ApiClient` does not have the
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
//! `content://` stream and no text falls through to a toast saying so,
//! rather than silently doing nothing. Closing this gap is the same
//! `client-core` work whichever caller needs it next.
//!
//! **Which session a share lands in** is also a placeholder: with no
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
//! to whichever session has the latest `last_activity` -- the one most
//! likely to be what somebody meant. Worth revisiting once a real screen
//! exists to ask instead of guessing.
use crate::client::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::shell::notify;
use crate::shell::settings;
const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value =
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() {
return Ok(None);
}
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(Some(jstr.try_to_string(env)?))
}
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::shell::jcall::jstr_obj(env, message)?;
crate::shell::jcall::call_static_method(
env,
"com/example/aiapp/shell/MainActivity",
"toast",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&message)],
)?;
Ok(())
}
/// The one place an incoming intent is sorted into what it means -- mirrors
/// `MainActivity.kt`'s `handleIntent`.
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
action.as_deref(),
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
) {
return handle_share(env, activity, intent);
}
if action.as_deref() != Some(ACTION_VIEW) {
return Ok(());
}
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
.l()?;
if uri.is_null() {
return Ok(());
}
let scheme = get_string_method(env, &uri, "getScheme")?;
if scheme.as_deref() != Some(settings::SCHEME) {
return Ok(());
}
match get_string_method(env, &uri, "getHost")?.as_deref() {
Some("session") => handle_session_open(env, activity, &uri),
Some("enroll") => handle_enrollment(env, activity, &uri),
_ => Ok(()),
}
}
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(());
};
// There is no session screen yet (E4's job); the toast is this
// experiment's stand-in proof that the tap was routed to the right
// session id.
toast(env, activity, &format!("Opened session {session_id}"))
}
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
match settings::parse_enrollment_uri(env, uri)? {
Some(parsed) => {
settings::save(env, activity, &parsed)?;
notify::sync(env, activity)?;
toast(
env,
activity,
&format!("Enrolled with {}", parsed.base_url()),
)
}
None => toast(env, activity, "Not a valid enrollment code"),
}
}
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
/// what happens to it, minus attachments per this module's doc comment.
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::shell::jcall::call_method(
env,
intent,
"getStringExtra",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&extra_text)],
)?
.l()?;
let text = if text.is_null() {
None
} else {
let jstr: JString = env.cast_local::<JString>(text)?;
Some(jstr.try_to_string(env)?)
};
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
return toast(
env,
activity,
"Nothing to share -- only shared text is supported so far",
);
};
// Network I/O must not run on the calling thread: `handle_intent` is
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
// blocking socket read there is a `NetworkOnMainThreadException`. So
// the actual send happens on a JNI-attached background thread, the
// same shape `notify::try_start`'s follow loop uses; `toast` from that
// thread is safe because `MainActivity.toast` itself hops back to the
// main looper (see that method).
let vm = env.get_java_vm()?;
let activity_ref = env.new_global_ref(activity)?;
std::thread::spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
share_in_background(env, &activity_ref, text);
Ok(())
});
});
Ok(())
}
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
let outcome = attach_to_a_session(env, activity, &text);
let message = match outcome {
Ok(title) => format!("Shared into \"{title}\""),
Err(message) => message,
};
let _ = toast(env, activity, &message);
}
fn attach_to_a_session(
env: &mut Env,
activity: &JObject,
text: &str,
) -> std::result::Result<String, String> {
let settings = settings::load(env, activity)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Not enrolled yet".to_string())?;
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
.map_err(|e| e.to_string())?;
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
let target = sessions
.into_iter()
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
.ok_or_else(|| "No session to share into".to_string())?;
client
.send_message(&target.id, text, &[])
.map_err(|e| e.to_string())?;
Ok(target.title)
}
+126
View File
@@ -0,0 +1,126 @@
//! The message composer at the bottom of the transcript screen: a
//! multi-line editable field with a natural (not fixed) height, so it
//! grows as typed into -- IRIS_TODO.md's "input box" benchmark case
//! (`iris/benches/message_lazy_span.rs` exercises the mechanism in isolation;
//! this wires the same `TextEdit`-with-no-`Sized`-wrapper idiom into the
//! real screen). `lib.rs` gives the transcript `LazySpan` `.height(rest(1))`
//! beside this widget in a `Span::down`, so the list's own draw already
//! measures whatever vertical space is left each frame -- nothing here
//! computes a height by hand, and growing this field is exactly the
//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured.
//!
//! **Rebuilt 2026-09-06** (Iris's phone report on the dc01f88 build: the
//! grey bar drawn as a short, fixed strip with the typed text ~150px below
//! it on black, and empty black between the bar and the keyboard). One
//! widget now, top to bottom: an opaque background sized to its content
//! (`.background`, the same `Stack` idiom the header row's `HEADER_SURFACE`
//! already uses), the field inside `dp` padding and capped at
//! [`MAX_LINES`] before it scrolls instead of growing forever, and an
//! outer [`Pad`] whose `bottom` [`TranscriptScreen::set_bottom_inset`]
//! rewrites in place whenever the keyboard opens/closes -- never rebuilt,
//! since `field` is strongly owned inside this tree and this crate's
//! widgets cannot be re-parented once added (this module's own comment
//! below on why `build_composer` hands back a **weak** id).
use iris::prelude::*;
/// Caps the field's growth at roughly six lines of its own 18px text
/// before it scrolls instead of consuming the whole screen -- an
/// approximation (line-height and padding folded into one round `dp`
/// number) rather than a value derived from the font's real metrics,
/// which nothing in this crate exposes to a caller today.
const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0;
/// The bar's own surface -- both what is drawn behind the field and what
/// the field is clipped to, see `build_composer`.
const BAR_FILL: UiColor = UiColor::new(40, 40, 46, 255);
/// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`).
pub struct Composer {
pub field: WeakWidget<TextEdit>,
/// The bar's own outer padding -- only `bottom` is ever changed, by
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
/// a rebuilt tree, because `field` lives inside it and cannot be
/// re-added to a new wrapper once it is strongly owned here.
outer_pad: WeakWidget<Pad>,
}
impl Composer {
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
/// whenever the space below the bar changes: the IME's own inset while
/// it is open, the navigation-bar inset otherwise. Takes a plain
/// `f32` in the caller's own physical-pixel units rather than an
/// Android-specific insets type, so this crate stays usable from the
/// winit backend too, which has no navigation bar to report.
/// Rewrites the existing `Pad` in place (marking it dirty through the
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
/// so the field's focus, selection and in-progress text are untouched.
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
pad.padding.bottom = Len::abs(inset);
}
}
}
/// Returns the composer plus its own bar as a **weak** id -- the caller
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
/// `set_root` performs the one real strong registration. Calling
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
/// mistake this box's `row.rs` first made with its sender-label header, see
/// that file's comment for the fuller account.
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc) -> (Composer, WeakWidget)
where
Rsc::State: FocusHost,
{
let field = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.attr::<Selectable>(())
.label("Message")
.add(rsc);
// One widget: an opaque bar sized to its own content, wrapping the
// padded, height-capped field -- not a background rect and a field
// drawn as two independent siblings, which is what let the two
// disagree on where the bar actually was.
//
// `.masked_by(rect(BAR_FILL))` is that bar *and* the clip, in one:
// the rect is drawn behind the field and is itself what the field is
// cut to (`Masked::shape`), so the surface and the edge content
// disappears at cannot fall out of step. It replaces a
// `.masked().background(rect(...))` pair, which clipped one box
// inside the other: the mask sat *inside* the `dp(FIELD_PAD_DP)`
// padding, so a message longer than the six lines shown was sliced
// through the middle of a glyph 12dp in from the bar's edge, leaving
// a band of bare surface above the cut. Iris, 2026-09-08: "the
// message input box doesn't clip correctly ... the box should be
// clipped rather than the inset text." The padding still holds the
// text off the edge at the end the content is anchored to; what
// scrolls past the other end now passes under the bar's own edge,
// the way `row.rs` already cuts a code fence to its panel.
//
// Without any mask at all the overflow paints *above* the bar, over
// the transcript: measured at 58px of stray text for a 475px message
// in a 417px box.
let content = field
// `scrollable_to_end`: what is being typed is at the end, so a
// message longer than the six lines shown holds that end.
.scrollable(Axis::Y, Pin::End)
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1))
.masked_by(rect(BAR_FILL))
.add(rsc);
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
(Composer { field, outer_pad }, outer_pad)
}
+166
View File
@@ -0,0 +1,166 @@
//! The checked-in bench fixture, opened as a real transcript screen with
//! no server -- shared by every layer of docs/RUST.md's test rig.
//!
//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760
//! bytes, generated by `app/bench-fixture/generate.py`, never a real
//! transcript -- that file's own README), embedded with `include_str!`.
//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window,
//! folded once through `crate::client::transcript_fold::fold_page` exactly
//! as a real `/transcript` page would be; the rest are the streaming
//! tail, replayed one at a time through `fold_event` the way a live SSE
//! frame arrives.
//!
//! This half used to live in `iris-android-app`'s `bench_client.rs`, and
//! moved here on 2026-09-07 so the headless harness and a desktop window
//! open the same screen from the same bytes (AGENTS.md: nothing
//! UI-shaped in a platform crate). What stayed there is the JNI half --
//! the clipboard, the battery sampler, the IME calls and the report.
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::prelude::*;
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
pub const BACKLOG_COUNT: usize = 3200;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and
/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at
/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed
/// from memory -- every layer of the rig lays out at this size and
/// density so a screenshot and a headless assertion are about the same
/// screen.
pub const PHONE_WIDTH: f32 = 1080.0;
pub const PHONE_HEIGHT: f32 = 2424.0;
pub const PHONE_SCALE: f32 = 2.55;
/// 120Hz, the refresh rate that report ran at: 8.3ms a frame.
pub const PHONE_FRAME_MS: u64 = 8;
pub fn phone_size() -> Vec2 {
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
}
/// The fixture split the way the wire delivers it: raw JSON values for
/// the opening page (`fold_page` takes a page of wire JSON, same as a
/// real `/transcript` response) and parsed `SeqEvent`s for the tail
/// (`fold_event` takes one live event at a time, same as an SSE frame).
pub struct Fixture {
pub backlog: Vec<serde_json::Value>,
pub stream_tail: Vec<SeqEvent>,
}
impl Fixture {
/// Parses the whole fixture. Panics on malformed input: this is a
/// generated file compiled into the binary, so a parse failure is a
/// broken build rather than a condition a caller could recover from
/// (CODE_RULES: separate recoverable conditions from programmer
/// error).
pub fn parse() -> Self {
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
let mut stream_tail = Vec::new();
for (i, line) in FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.enumerate()
{
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
stream_tail.push(
serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent"),
);
}
}
Self {
backlog,
stream_tail,
}
}
/// The opening page folded into transcript items -- the same
/// `fold_page` a real first load runs. `Err` carries the fold's own
/// message, which a caller shows on screen rather than panicking, so
/// a fixture that stops folding is visible in the app instead of
/// being a crash on launch.
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
fold_page(&self.backlog)
}
}
/// The fixture's opening page as the rows a screen is built from.
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
group_tool_runs(items)
}
/// Everything a caller needs to run the fixture as an app screen would:
/// the screen, the folded items behind it, and the events not yet
/// streamed. The tree itself comes back separately from
/// [`build_screen`], since whoever takes it owns it.
pub struct Opened {
pub screen: crate::ui::TranscriptScreen,
pub items: Vec<TranscriptItem>,
/// The tail, for a caller that goes on replaying it one event at a
/// time through `fold_event`/`TranscriptScreen::apply` -- the
/// streaming phase of either app's benchmark.
pub stream_tail: Vec<SeqEvent>,
}
/// Build the transcript screen over the fixture's opening page, without
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
/// for a caller (the Android bench) that puts the screen inside a shell
/// of its own.
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
where
Rsc::State: FocusHost + OpenUrl,
{
let fixture = Fixture::parse();
let items = fixture.backlog_items()?;
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
Ok((
Opened {
screen,
items,
stream_tail: fixture.stream_tail,
},
tree,
))
}
/// [`build_screen`] with the screen as the window's root -- what the
/// headless harness and the desktop window open.
pub fn open<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result<Opened, String>
where
Rsc::State: FocusHost + OpenUrl,
{
let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(tree);
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
/// The split is what both bench clients assume; a fixture that
/// stopped having a streaming tail would make the Android bench's
/// stream phase silently measure nothing.
#[test]
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
let fixture = Fixture::parse();
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
assert!(
fixture.stream_tail.len() >= 400,
"the stream phase replays 400 events; the fixture has {}",
fixture.stream_tail.len()
);
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
}
}
+787
View File
@@ -0,0 +1,787 @@
//! One markdown **block** (`crate::client::markdown_blocks::Block`) rendered
//! for display: the plain text to draw, the [`SpanStyle`]s that style it,
//! the links inside it, and the [`BlockFrame`] the row builder puts around
//! it.
//!
//! This is the crate's answer to RUST.md's E2 finding against Masonry
//! ("rich inline text -- block-level yes, inline no, and both for the same
//! reason": `TextArea`'s `StyleSet` is one style for the whole editor,
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
//! per-range, so bold/italic/inline-code/links inside one wrapped
//! paragraph render in their own style *and* the paragraph still wraps and
//! selects as one buffer.
//!
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
//! A heading, a paragraph and a list are all *text with spans*; a fence
//! and a table are *verbatim text on a dark surface that pans sideways*;
//! a quote is *text behind a coloured bar*. Everything else markdown can
//! say is expressed in the spans, which cost no widgets and no layout
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
//! this deliberately differs.
//!
//! **What this deliberately does not attempt**, each for a reason recorded
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
//! the same list):
//! - **No background chip behind inline code.** Drawing one needs the
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
//! highlight uses `selection.geometry(layout)`,
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
//! `SpanStyle` gives the code range a monospace family and the
//! palette's code colour instead -- visually distinct, just not
//! chip-shaped.
//! - **A list's indent is written in spaces**, not measured. Compose lays
//! an item out as a marker column beside a text column, which keeps a
//! wrapped second line aligned under the first; here the marker is part
//! of the same buffer, so a wrapped line returns to the left margin.
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
//! wants yet.
//!
//! A heading's `SpanStyle::font_size` override does not also raise its
//! `line_height` (a buffer has one, set from the *base* font size in
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
//! paragraph's -- visible, not incorrect, and not fixed here since it
//! needs `SpanStyle` to carry line-height too.
use crate::client::highlight::{self, Kind, Language};
use crate::client::markdown_blocks::{Block, BlockKind};
use iris::prelude::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range;
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
// Material's roles, so a block drawn here and the same block drawn by the
// Compose app are the same colour rather than nearly.
const fn mocha(hex: u32) -> UiColor {
UiColor::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
255,
)
}
/// Body text: Mocha Text, the Compose app's `onSurface`.
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
/// Inline code, and a fence with no language to highlight it by.
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
/// A link. "Blue is what a link is on every Catppuccin surface, and the
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
/// A list's bullets and numbers: structure rather than words, so the
/// items of a list can be counted without reading them (`listMarkerColor`).
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
/// A quote's bar and its text: the bar carries the structure, and the
/// words step back one shade from body text so a quote reads as quoted
/// without being hard to read.
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
/// colours in both apps.
fn syntax_color(kind: Kind) -> UiColor {
match kind {
Kind::Keyword => mocha(0xCBA6F7),
Kind::String => mocha(0xA6E3A1),
Kind::Literal => mocha(0xFAB387),
Kind::Comment => mocha(0x6C7086),
Kind::Metadata => mocha(0xF9E2AF),
Kind::Punctuation => mocha(0xA6ADC8),
Kind::Mark => mocha(0x89DCEB),
}
}
/// What a row builder puts *around* a block's text widget. Three, not one
/// per markdown feature -- see the module doc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockFrame {
/// Text and nothing else: a paragraph, a heading, a list, a rule.
Plain,
/// A dark rounded panel whose text does not wrap -- long lines pan
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
/// its own fill, since a fence and a table are drawn on different
/// ones.
Verbatim { fill: UiColor },
/// A coloured bar down the left edge and an indent past it.
Quote,
}
/// The frame a block kind is drawn in. Pure, and the *only* place the
/// mapping is written: a new `BlockKind` shows up here as a compile error
/// rather than silently taking prose's appearance.
pub fn frame_of(kind: BlockKind) -> BlockFrame {
match kind {
BlockKind::Code => BlockFrame::Verbatim {
fill: VERBATIM_BACKGROUND,
},
BlockKind::Table => BlockFrame::Verbatim {
fill: TABLE_BACKGROUND,
},
BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
BlockFrame::Plain
}
}
}
/// A tappable range of a block's text and where it points.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
/// Byte range into [`Rendered::text`].
pub range: Range<usize>,
pub url: String,
}
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
/// it there for this would be a change to iris for a test's benefit.
#[derive(Clone, Default)]
pub struct Rendered {
pub text: String,
pub spans: Vec<SpanStyle>,
pub links: Vec<Link>,
}
impl Rendered {
/// The link `byte` falls inside, if any -- what a tap resolves
/// through. Half-open, so the offset one past a link's last character
/// (where a tap just after it lands) is *not* in it.
pub fn link_at(&self, byte: usize) -> Option<&Link> {
self.links.iter().find(|l| l.range.contains(&byte))
}
}
/// The heading ladder, in points at a 16pt body: it starts near the body
/// text and descends, because these are headings inside a chat message
/// rather than the top of a document. The numbers are Material's
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
/// -- kept as literals rather than derived from `base_size` so the two
/// apps agree exactly.
fn heading_size(level: HeadingLevel) -> f32 {
match level {
HeadingLevel::H1 => 24.0,
HeadingLevel::H2 => 22.0,
HeadingLevel::H3 => 16.0,
HeadingLevel::H4 => 14.0,
HeadingLevel::H5 => 12.0,
HeadingLevel::H6 => 11.0,
}
}
/// The bullet at each depth, cycling past the third: a disc, a ring, a
/// square -- the ladder a browser draws, so a nested list is told from its
/// parent by the glyph as well as by the indent. Same three
/// `MarkdownPieces.kt` uses.
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
/// A block-level separator inside one block's own text (a list item's
/// paragraphs, a quote's): two never run into each other with no gap, but
/// an empty `out` gets no leading blank.
fn ensure_blank_line(out: &mut String) {
if !out.is_empty() && !out.ends_with("\n\n") {
while out.ends_with('\n') {
out.pop();
}
out.push_str("\n\n");
}
}
fn ensure_line(out: &mut String) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
/// One top-level block, rendered. `base_size` is the row's ordinary
/// paragraph font size; a heading overrides it per span.
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
match block.kind {
// A table is the one block markdown states as a grid and iris has
// no grid widget for. Rendered as padded monospace instead --
// see [`table_text`].
BlockKind::Table => table_text(&block.source),
_ => render_markdown(&block.source, base_size),
}
}
/// One markdown source string rendered into plain text plus the spans that
/// style it. `base_size` is the row's ordinary paragraph font size, needed
/// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new();
let mut spans = Vec::new();
let mut links = Vec::new();
// Stack of start byte offsets for whatever inline/block styling is
// currently open -- pulldown-cmark's `Start`/`End` events are always
// balanced and each `End` already names its own kind (`TagEnd`), so a
// plain offset stack (rather than a tree, or repeating the kind here
// too) is enough. A link's destination rides along beside its offset,
// since `TagEnd::Link` does not carry it.
let mut open: Vec<(usize, Option<String>)> = Vec::new();
// One entry per open list: `Some(next number)` for an ordered list,
// `None` for a bulleted one. Depth is this vector's length, which is
// what picks the bullet glyph.
let mut lists: Vec<Option<u64>> = Vec::new();
// The language of the fence currently open, so `TagEnd::CodeBlock` can
// highlight what was collected between the two.
let mut fence_language: Option<Language> = None;
let parser = Parser::new_ext(src, options());
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Heading { .. }
| Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Image { .. } => open.push((out.len(), None)),
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
Tag::CodeBlock(kind) => {
fence_language = match &kind {
CodeBlockKind::Fenced(info) => {
// Only the first word: "rust,ignore" and
// "console session" are both written.
highlight::fence_language(info.split_whitespace().next())
}
CodeBlockKind::Indented => None,
};
ensure_blank_line(&mut out);
open.push((out.len(), None));
}
Tag::Item => {
ensure_line(&mut out);
let depth = lists.len().max(1);
out.push_str(&" ".repeat(depth - 1));
let start = out.len();
match lists.last_mut() {
Some(Some(n)) => {
out.push_str(&format!("{n}. "));
*n += 1;
}
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
}
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
}
Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
_ => {}
},
// Only the tag kinds that pushed onto `open` (Start, above) are
// popped here -- `List`/`Item`/`Paragraph`/`BlockQuote`/`Table`
// and friends push nothing, since they need no span, and must
// not touch this stack or they would pop an unrelated styled
// range still open around them.
Event::End(
tag_end @ (TagEnd::Heading(_)
| TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Image
| TagEnd::CodeBlock),
) => {
let Some((start, dest)) = open.pop() else {
continue;
};
if matches!(tag_end, TagEnd::CodeBlock) {
// A fence's trailing newline is the fence marker's, not
// the code's -- kept and it draws an empty last line.
while out.ends_with('\n') {
out.pop();
}
}
let range = start..out.len();
if range.is_empty() {
continue;
}
match tag_end {
TagEnd::Heading(level) => {
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
}
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
}
// An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least
// followable rather than silently inert.
TagEnd::Link | TagEnd::Image => {
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
if let Some(url) = dest {
links.push(Link { range, url });
}
}
TagEnd::CodeBlock => {
spans.push(
SpanStyle::new(range.clone())
.family(Family::Monospace)
.color(CODE_COLOR),
);
// After the monospace span, so the per-token
// colours win where they overlap it.
if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language);
}
}
_ => unreachable!("filtered by the outer match arm"),
}
}
Event::Text(text) => out.push_str(&text),
// Inline code (single backticks) is one atomic event with no
// `Start`/`End` pair of its own, unlike a fenced block -- so it
// is spanned directly here instead of through the `open` stack.
Event::Code(text) => {
let start = out.len();
out.push_str(&text);
spans.push(
SpanStyle::new(start..out.len())
.family(Family::Monospace)
.color(CODE_COLOR),
);
}
Event::SoftBreak => out.push(' '),
Event::HardBreak => out.push('\n'),
Event::Rule => {
ensure_line(&mut out);
out.push_str("\u{2500}\u{2500}\u{2500}\n");
}
Event::TaskListMarker(done) => {
let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
}
Event::End(TagEnd::List(_)) => {
lists.pop();
}
_ => {}
}
}
while out.ends_with('\n') {
out.pop();
}
// A span left pointing past the text a later trim shortened would draw
// against nothing; markdown that ends inside an open emphasis is
// ordinary mid-stream input, not a defect.
spans.retain(|s| s.range.end <= out.len());
links.retain(|l| l.range.end <= out.len());
Rendered {
text: out,
spans,
links,
}
}
/// The same option set `crate::client::markdown_blocks` splits with, so a
/// block boundary there and the styling here cannot disagree about what
/// the source means.
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
/// `crate::client::highlight`'s spans for the code at `range` inside `text`,
/// appended to `spans`.
///
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
/// (`highlight`'s module doc), so the offsets are walked once rather than
/// converted per span -- a fence is scanned on every delta that lands in
/// it, and it is the only block a delta re-renders.
pub(crate) fn highlight_into(
spans: &mut Vec<SpanStyle>,
text: &str,
range: Range<usize>,
language: Language,
) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
// `end` is always in range.
let bytes: Vec<usize> = code
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(code.len()))
.collect();
for span in highlight::spans_of(code, language) {
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
debug_assert!(
false,
"highlight span {}..{} outside {} chars of code",
span.start,
span.end,
bytes.len() - 1
);
continue;
};
spans.push(
SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace)
.color(syntax_color(span.kind)),
);
}
}
/// The widest a table column is allowed to get before its cells wrap
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
/// `tableCellWidth` was -- what fits three columns across a phone -- but
/// counted in monospace characters, which is the unit a padded table has:
/// three 28-character columns plus separators is about 90 characters,
/// which is what a 16pt mono face gives on a 1080px phone before the
/// sideways pan starts.
const TABLE_MAX_COL: usize = 28;
/// A GFM table as **padded monospace columns**, with the header bold and a
/// rule under it.
///
/// iris has no grid widget, and building one for the one block kind that
/// needs it would be a widget per markdown feature -- what this crate's
/// module doc says it will not do. A monospace face makes character counts
/// and pixel widths the same thing, so padding each cell to its column's
/// width *is* alignment, the column widths are measured from the cells,
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
pub fn table_text(src: &str) -> Rendered {
let rows = table_cells(src);
if rows.is_empty() {
return Rendered::default();
}
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
// Each cell wrapped to the cap first, so a column's width is the
// widest *line* it will actually draw rather than the longest cell.
let wrapped: Vec<Vec<Vec<String>>> = rows
.iter()
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
.collect();
let widths: Vec<usize> = (0..columns)
.map(|c| {
wrapped
.iter()
.filter_map(|row| row.get(c))
.flat_map(|lines| lines.iter())
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
let mut spans = Vec::new();
for (r, row) in wrapped.iter().enumerate() {
let height = row.iter().map(Vec::len).max().unwrap_or(1);
let start = out.len();
for line in 0..height {
if !out.is_empty() {
out.push('\n');
}
for (c, width) in widths.iter().enumerate() {
if c > 0 {
out.push_str(" ");
}
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
let text = text.unwrap_or("");
out.push_str(text);
// The last column is not padded: trailing spaces widen
// the block's measured width for nothing.
if c + 1 < widths.len() {
for _ in text.chars().count()..*width {
out.push(' ');
}
}
}
}
if r == 0 {
spans.push(SpanStyle::new(start..out.len()).bold());
out.push('\n');
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
}
}
Rendered {
text: out,
spans,
links: Vec::new(),
}
}
/// The cells of a GFM table, row by row, as their plain text.
fn table_cells(src: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut cell = String::new();
let mut in_cell = false;
for event in Parser::new_ext(src, options()) {
match event {
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
Event::Start(Tag::TableCell) => {
cell.clear();
in_cell = true;
}
Event::End(TagEnd::TableCell) => {
in_cell = false;
if let Some(row) = rows.last_mut() {
row.push(cell.trim().to_string());
}
}
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
_ => {}
}
}
rows.retain(|r| !r.is_empty());
rows
}
/// `text` broken onto lines of at most `width` characters, at spaces where
/// there are any. A word longer than the column is left over-long rather
/// than cut mid-word: the column then widens for it, which is visible and
/// correct, where cutting would silently lose characters.
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let extra = if line.is_empty() { 0 } else { 1 };
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
lines.push(line);
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::markdown_blocks::split_blocks;
fn block(src: &str) -> Rendered {
let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
render_block(&blocks[0], 16.0)
}
#[test]
fn plain_paragraph_has_no_spans() {
let r = render_markdown("just some words", 16.0);
assert_eq!(r.text, "just some words");
assert!(r.spans.is_empty());
}
#[test]
fn bold_and_italic_produce_spans_over_the_right_range() {
let r = render_markdown("a **bold** and *italic* word", 16.0);
assert_eq!(r.text, "a bold and italic word");
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
assert_eq!(&r.text[bold.range.clone()], "bold");
let italic = r.spans.iter().find(|s| s.italic).unwrap();
assert_eq!(&r.text[italic.range.clone()], "italic");
}
#[test]
fn heading_gets_a_bigger_font_size_span() {
let r = render_markdown("# A Title", 16.0);
assert!(r.text.starts_with("A Title"));
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
assert_eq!(&r.text[heading.range.clone()], "A Title");
assert_eq!(heading.font_size, Some(24.0));
}
/// Every level draws at its own size, so two levels of nesting are
/// never the same -- `Markdown.kt`'s reason for the ladder.
#[test]
fn every_heading_level_is_a_different_size() {
let mut sizes = Vec::new();
for level in 1..=6 {
let src = format!("{} h", "#".repeat(level));
let r = render_markdown(&src, 16.0);
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
}
let mut sorted = sizes.clone();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
sorted.dedup();
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
}
#[test]
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
assert!(r.text.contains("the docs"));
assert!(
!r.text.contains("example.com"),
"the URL should not leak into the visible text"
);
let link = r.spans.iter().find(|s| s.underline).unwrap();
assert_eq!(&r.text[link.range.clone()], "the docs");
let at = r.text.find("docs").unwrap();
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
let past = r.text.find("for").unwrap();
assert!(r.link_at(past).is_none());
}
#[test]
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
let r = block("```rust\nlet x = 1; // note\n```");
assert_eq!(r.text, "let x = 1; // note");
let keyword = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
.expect("a rust fence colours its keywords");
assert_eq!(&r.text[keyword.range.clone()], "let");
let comment = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
.unwrap();
assert_eq!(&r.text[comment.range.clone()], "// note");
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
}
/// The half the change had no reason to touch: a fence in a language
/// the highlighter has no rules for must be plain rather than
/// coloured by the nearest language's (`CodeFence.kt`'s
/// `fenceLanguage` doc).
#[test]
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
let r = block("```brainfuck\nlet x = 1;\n```");
assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1);
// `Family` is not `Debug`, so this is `assert!` rather than
// `assert_eq!`.
assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
}
/// Multi-byte characters are where a char-indexed highlighter and a
/// byte-indexed span list disagree if the conversion is missing.
#[test]
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
for span in &r.spans {
assert!(
r.text.is_char_boundary(span.range.start)
&& r.text.is_char_boundary(span.range.end),
"span {:?} is not on a char boundary of {:?}",
span.range,
r.text
);
}
let string = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::String)))
.unwrap();
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
}
#[test]
fn an_unterminated_fence_still_renders_what_arrived() {
let r = block("```rust\nlet x = 1;");
assert_eq!(r.text, "let x = 1;");
assert!(
r.spans
.iter()
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
);
}
#[test]
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
let r = block("- one\n- two\n - deep");
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
}
#[test]
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
let r = block("3. three\n4. four");
assert_eq!(r.text, "3. three\n4. four");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["3. ", "4. "]);
}
#[test]
fn a_quote_is_its_text_and_takes_the_quote_frame() {
let blocks = split_blocks("> quoted words\n> still quoted");
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
let r = render_block(&blocks[0], 16.0);
assert_eq!(r.text, "quoted words still quoted");
}
#[test]
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
use BlockKind::*;
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
assert_eq!(frame_of(Heading), BlockFrame::Plain);
assert_eq!(frame_of(List), BlockFrame::Plain);
assert_eq!(frame_of(Other), BlockFrame::Plain);
assert_eq!(frame_of(Quote), BlockFrame::Quote);
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
assert_ne!(
frame_of(Code),
frame_of(Table),
"a fence and a table sit on different fills"
);
}
#[test]
fn a_table_pads_its_columns_to_the_widest_cell() {
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
let lines: Vec<&str> = r.text.lines().collect();
assert_eq!(lines[0], "a bb");
assert_eq!(lines[1], "\u{2500}".repeat(8));
assert_eq!(lines[2], "cccc d");
let bold = r.spans.iter().find(|s| s.bold).unwrap();
assert_eq!(&r.text[bold.range.clone()], "a bb");
}
/// The fixture's own table shape: a long cell wraps inside its column
/// instead of making the row one enormous line.
#[test]
fn a_long_table_cell_wraps_inside_its_column() {
let long = "one two three four five six seven eight nine ten eleven twelve";
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
for line in r.text.lines() {
assert!(
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
"line too wide: {line:?}"
);
}
assert!(r.text.contains("twelve"));
}
#[test]
fn a_task_list_marks_its_boxes() {
let r = block("- [x] done\n- [ ] not");
assert!(r.text.contains("[x] done"));
assert!(r.text.contains("[ ] not"));
}
}
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
//! One `iris::widget::list::LazyItem` per folded transcript row
//! (`crate::client::transcript_fold::TranscriptRow`). A row is a **column of
//! one `TextEdit` per top-level markdown block** (paragraph, heading,
//! fence, list, table -- `crate::client::markdown_blocks`), each rendered
//! with `markdown`'s inline spans, so that RUST.md's "hard to get back"
//! behaviour 2 (rich inline text) still holds within a block and
//! behaviour 1 (selection) runs across blocks and rows alike through
//! `selection.rs`.
//!
//! It was one `TextEdit` for the whole message until 2026-09-06, which
//! meant a streamed delta re-shaped every paragraph of a long reply
//! through parley again -- the stream phase was the one place iris trailed
//! Compose on Iris's phone. [`RowBlocks::apply_delta`] is the other half
//! of the fix; docs/DECISIONS.md's entry has what the alternative shapes
//! were and why this one.
//!
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
//! `crate::client::transcript_fold::group_tool_runs`) is the row that proves
//! behaviour 3's "hold the edge nearest the tap" on expand: tapping its
//! header calls `LazySpan::note_tap` at the row's own on-screen position
//! (read back from `LazySpan::extent`, since the tap event only knows its
//! position *within* this row) before toggling a `WidgetPtr` between the
//! collapsed summary and the full detail -- the same two-step contract
//! `lazy_span.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::ui::selection::{SelKey, Selection};
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::tool::ToolRow;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
/// The gap drawn between two markdown blocks of one message. A block used
/// to be separated by the blank line `markdown::render_markdown` put in
/// the single buffer; now that each block is its own widget, that spacing
/// has to be the column's.
const BLOCK_GAP_DP: f32 = 8.0;
/// The paragraph size every row's `TextEdit` is built at; markdown headings
/// inside a row scale relative to a fixed set of sizes rather than this one
/// (`markdown::heading_size`), since a heading is meant to look the same
/// regardless of which row's base size surrounds it.
pub const BASE_SIZE: f32 = 16.0;
/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants.
/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into
/// one -- collisions are not a correctness risk worth guarding against here
/// (a `DefaultHasher` collision across the run ids one session produces is
/// astronomically unlikely, and the consequence of one would only be two
/// tool-call rows sharing a list slot, not data loss), and the high bit is
/// forced on so a hashed key can never collide with a real sequence number
/// (this build never produces 2^63 events).
pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey {
use crate::client::transcript_fold::ItemKey;
use std::hash::{Hash, Hasher};
match key {
ItemKey::Seq(seq) => *seq,
ItemKey::RunId(id) => {
let mut h = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut h);
h.finish() | (1 << 63)
}
}
}
/// The sender label shown above a row's text, and the markdown source to
/// render below it. `None` for a system-style note that has no sender.
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
match item {
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()),
TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")),
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
TranscriptItem::Note { text, .. } => (None, text.clone()),
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
// Epoch seconds as-is until the port has a relative-time formatter
// (P1); the Compose `LimitRow` draws it as a countdown.
TranscriptItem::LimitNote { resets_at, .. } => (
None,
match resets_at {
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
None => "_Usage limit reached._".to_string(),
},
),
TranscriptItem::CompactedNote {
pre_tokens,
post_tokens,
..
} => (
None,
match (pre_tokens, post_tokens) {
(Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"),
_ => "_Compacted._".to_string(),
},
),
TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")),
TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)),
TranscriptItem::ToolRun {
tool,
input,
output,
..
} => (Some(tool.as_str()), tool_call_markdown(tool, input, output)),
}
}
fn question_markdown(card: &QuestionCard) -> String {
let mut out = card.prompt.clone();
for opt in &card.options {
out.push_str(&format!("\n- {}", opt.label));
}
out
}
fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
let mut out = format!("**{tool}**\n\n```\n{input}\n```");
if !output.is_empty() {
out.push_str(&format!("\n\n```\n{output}\n```"));
}
out
}
/// The per-block text widgets of one row, kept by `TranscriptScreen` for
/// the row a reply is streaming into, so a delta can replace the block it
/// lands in instead of re-shaping the whole message
/// (docs/DECISIONS.md, 2026-09-06). Nothing else needs it: a row that is
/// not the tail never changes.
pub struct RowBlocks {
/// What each field was built from, in order -- compared against a
/// fresh split to decide what may be kept. See
/// `crate::client::markdown_blocks`' module doc for why this is a
/// comparison and not an assumption.
blocks: Vec<Block>,
fields: Vec<WeakWidget<TextEdit>>,
/// Each block's links, shared with its own tap handler so a delta
/// replaces what the handler reads instead of re-registering it.
/// One entry per field, which `apply_delta` asserts.
links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>,
/// The sender label the row was built with. A delta that changes it is
/// not a delta into the same message, so it falls back to a rebuild.
sender: Option<String>,
/// Whether this row draws less than the whole message
/// ([`cap_message`]). A delta cannot be appended to a capped row --
/// the new text would go on *below* the "Show all" that says it is
/// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller
/// rebuilds instead.
///
/// Never `true` for the row a reply is actually streaming into: the
/// live tail is built uncapped ([`build_row`]'s `cap`), which is what
/// keeps the refusal from costing anything in practice. This field is
/// the belt to that braces.
capped: bool,
}
/// Split for display: never empty, so a row with nothing in it yet is
/// still one (empty) text widget rather than no widget at all -- an empty
/// column reports a zero size and the row would vanish from the list.
fn display_blocks(markdown_src: &str) -> Vec<Block> {
let blocks = split_blocks(markdown_src);
if blocks.is_empty() {
vec![Block {
kind: BlockKind::Paragraph,
source: markdown_src.to_string(),
}]
} else {
blocks
}
}
/// `blocks` cut to what a row draws, with the line count of the **whole**
/// message; `None` when all of it fits.
///
/// A message is capped for the same reason a tool's output is: one row can
/// be a hundred kilobytes of text, all of it shaped and rasterised whether
/// or not it is on screen, and a reader scrolling past a wall of it wanted
/// the next message anyway. Iris asked for it on 2026-09-08 -- "also do it
/// for messages (both user and agent) please, they're desperately needed
/// for long messages".
///
/// The cut prefers a **block boundary**, because a message is markdown and
/// a whole paragraph is a smaller version of a message in a way that half
/// a paragraph is not. Where one block is over the bound by itself -- the
/// reply that is one enormous fence -- that block is truncated instead of
/// being dropped or drawn whole: dropping it would leave a row saying
/// nothing, and a truncated fence still renders as a fence, since the
/// renderer already knows the block's kind and pulldown-cmark closes an
/// unterminated one at the end of its input.
fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
let total = || blocks.iter().map(|b| b.source.lines().count()).sum();
if !cap {
return (blocks, None);
}
let mut kept = Vec::with_capacity(blocks.len());
let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES);
for block in &blocks {
if lines_left == 0 || bytes_left == 0 {
return (kept, Some(total()));
}
// `cut` is the test as well as the cutter: asking it whether this
// block fits in what is left is the same question, answered once,
// so the walk cannot disagree with the bound it is walking to.
match cut(&block.source, lines_left, bytes_left) {
// Over the bound by itself, with nothing kept yet: truncate,
// since dropping it would leave the row saying nothing.
Some((head, _)) if kept.is_empty() => {
kept.push(Block {
kind: block.kind,
source: head.to_string(),
});
return (kept, Some(total()));
}
// Over the bound with something already kept: stop on the
// boundary rather than half-drawing this one. A block
// truncated to its opening line is a *worse* answer than no
// block -- a fence cut to its own ``` renders as an empty
// panel, which reads as a rendering fault rather than as a
// cap (seen 2026-09-08 with the bound wound down to three
// lines to look at it).
Some(_) => return (kept, Some(total())),
None => {
lines_left -= block.source.lines().count().min(lines_left);
bytes_left -= block.source.len().min(bytes_left);
kept.push(block.clone());
}
}
}
(kept, None)
}
/// A message's own text, kept so that asking for the whole of a capped row
/// can rebuild it. `Rc` rather than a copy per closure: the source of a
/// long message is the largest string in the row, and the tap handler
/// would otherwise hold a second one for the lifetime of the row.
struct RowSource {
sender: Option<String>,
markdown: String,
}
/// The room a fence or a table's text gets inside its panel, and the
/// gap between a quote's bar and its words. `CodeFence.kt` charges the
/// renderer's `codeBlock` padding inside the tinted box and 8dp above and
/// below it; the vertical half is `BLOCK_GAP_DP`'s job here, since the
/// column already separates blocks.
const FRAME_PAD_DP: f32 = 10.0;
/// The bar down a quote's left edge.
const QUOTE_BAR_DP: f32 = 3.0;
/// A verbatim panel's corner, matching the renderer's own rounded fence.
const FRAME_RADIUS_DP: f32 = 8.0;
/// One block's own `TextEdit`, registered with `selection` under
/// `(row, block)` and wired to `Selection::drag` -- the block is the
/// selection unit (`selection::SelKey`) -- plus whatever
/// [`BlockFrame`] its kind is drawn in.
///
/// Returns the field (which `apply_delta` writes into), the widget the
/// column actually holds (the field, or the field inside its frame), and
/// the block's links, shared with the tap handler so a delta can replace
/// them without rebuilding the handler.
fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: SelKey,
block: &Block,
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
where
Rsc::State: FocusHost + OpenUrl,
{
let frame = frame_of(block.kind);
let rendered = render_block(block, BASE_SIZE);
let links = Rc::new(RefCell::new(rendered.links));
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text)
.spans(rendered.spans)
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
// A fence and a table say what they mean by where their
// characters sit, so they pan sideways rather than wrap
// (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded
// in *characters*, which only lines up in a monospace face.
.wrap(!verbatim)
.family(if verbatim {
Family::Monospace
} else {
Family::SansSerif
})
.size(BASE_SIZE)
.color(match frame {
BlockFrame::Quote => crate::ui::markdown::QUOTE_TEXT_COLOR,
_ => crate::ui::markdown::TEXT_COLOR,
})
.add(rsc);
selection.borrow_mut().register(key, field);
let tap_links = links.clone();
field
// The whole `drag_senses()` set, which is what every widget
// driving a `DragGesture` registers. This block normally only sees
// a gesture's *first* frames (`PressStart`, or a `Pressing` that
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
// it commits, `DragGesture` takes pointer capture on `list`'s own
// id and every further frame, including the terminal `Drop`,
// reaches `lib.rs`'s list-level registration instead -- see
// `iris::sense`'s pointer-capture doc for why that has to be a
// stable id rather than this row's, which `LazySpan` can retire mid-
// drag as content scrolls.
//
// `Cancel` is the one that is *not* optional, and leaving it out
// is what made Iris's 2026-09-08 "scroll a horizontal area, then
// tap in a vertical one, and it snaps": a cancel is delivered to
// the widget that was **pressed**, not to whoever holds the
// capture, so when a code fence inside this block panned sideways
// and took the pointer, nothing ever told the shared gesture its
// press was over. It stayed open with the fence's touch-down as
// its origin, and the next press anywhere was measured from there.
.on(CursorSense::drag_senses(), move |ctx, rsc| {
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
let outcome = selection.borrow_mut().drag(
rsc,
list,
Some((key, pos, size)),
cursor,
ctx.data.sense,
ctx.data.cursor.time,
ctx.data.pointer,
);
// A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc).
if outcome == GestureOutcome::Tapped {
let byte = field.edit(rsc).byte_at(cursor, size);
let url = tap_links
.borrow()
.iter()
.find(|l| l.range.contains(&byte))
.map(|l| l.url.clone());
if let Some(url) = url {
log::info!("iris link: opening {url}");
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
}
}
})
.add(rsc);
// The column holds the *framed* widget; the field is what
// `apply_delta` writes into and what `Selection` resolves. Keeping
// the two apart is what lets a fence gain a background without the
// delta path knowing anything about frames.
let framed = match frame {
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
// Masked *by the panel*, not inside it: the fence's own rounded
// rect is the clip, so content scrolled sideways is cut on the
// curve instead of leaving square pixels in the corners
// (Iris, 2026-09-07).
BlockFrame::Verbatim { fill } => field
.scrollable(Axis::X, Pin::Start)
.pad(dp(FRAME_PAD_DP))
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
// A `Stack` (through `background`) rather than a two-child
// `Span(Dir::RIGHT)`: the bar is drawn behind text padded past
// it, which is the same picture with one widget fewer and
// without `Span`'s provisional full-region pass. That pass is
// also what first surfaced the `mov`-then-`reposition` assert
// docs/RUST.md's P1a box records as still open, so the shape
// with fewer passes is the one to prefer here.
BlockFrame::Quote => field
.width(rest(1))
.pad(Padding {
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
..Padding::ZERO
})
.background(rect(crate::ui::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
};
(field, framed, links)
}
/// Build a row from a sender label plus markdown source: a column of one
/// `TextEdit` per top-level markdown block, under the sender's own label.
///
/// One widget per block rather than one per message is what makes a
/// streamed delta cost the last block instead of the whole reply -- see
/// [`RowBlocks::apply_delta`] for the other half, and
/// `crate::client::markdown_blocks` for the split. Selection still runs
/// across the whole transcript; the unit it steps in is a block now rather
/// than a row (`selection::SelKey`).
///
/// `cap` draws at most [`cap_message`]'s worth of it with a "Show all"
/// under the rest. The row's content sits inside a `WidgetPtr` so that
/// answering that offer replaces it in place, which is the same shape a
/// tool card uses to open (`tool.rs`'s `build_card_ptr`).
fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
cap: bool,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let source = Rc::new(RowSource {
sender: sender.map(str::to_string),
markdown: markdown_src.to_string(),
});
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let (content, blocks) = row_content(rsc, list, selection, key, source, ptr, cap);
ptr(rsc).set(content);
(strong.any(), blocks)
}
/// One row's header, blocks, and -- when [`cap_message`] left something
/// out -- the "Show all" that replaces the lot with the whole message.
///
/// Separate from [`build_text_row`] because the tap calls it a second
/// time, with `cap` false, and writes the result back into the same
/// `WidgetPtr`.
fn row_content<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
cap: bool,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap);
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len());
for (i, block) in blocks.iter().enumerate() {
let (field, framed, block_links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
fields.push(field);
links.push(block_links);
column.push(framed);
}
if let Some(lines) = hidden {
column.push(show_all(
rsc,
list,
selection.clone(),
key,
source.clone(),
ptr,
lines,
));
}
let column = column.add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
// what performs the *one* real strong registration each child gets.
// Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()`
// copy into that composition, tried to strong-register the same id
// twice and panicked with "was already added"
// (`core/src/widget/like.rs:12`) -- found running this crate's own
// `run-headless.sh` example, the first real render of a row.
let header: WeakWidget = match &source.sender {
Some(name) => wtext(name.clone())
.size(13.0)
.color(UiColor::new(150, 150, 160, 255))
.add(rsc),
None => Span::empty(Dir::DOWN).add(rsc),
};
let widget = (header, column.width(rest(1)))
.span(Dir::DOWN)
.gap(dp(4))
.pad(dp(10))
.add_strong(rsc)
.any();
(
widget,
RowBlocks {
blocks,
fields,
links,
column,
sender: source.sender.clone(),
capped: hidden.is_some(),
},
)
}
/// The "Show all N lines" under a capped message, and the tap that
/// replaces the row with the whole of it.
///
/// The `RowBlocks` the rebuild produces is **discarded**, because a capped
/// row is never the row a reply is streaming into (`build_row`'s `cap`) --
/// so nothing is holding one for it, and there is nothing to keep in step.
#[allow(clippy::too_many_arguments)]
fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
lines: usize,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let label = show_all_label(lines);
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = wtext(label.clone())
.size(13.0)
.color(UiColor::new(150, 150, 160, 255))
.text_align(Align::LEFT)
.label(label)
.add_strong(rsc);
more(rsc).set(words);
on_tap(rsc, more, list, selection.clone(), move |rsc| {
hold_edge(rsc, list, key);
let (content, _blocks) = row_content(
rsc,
list,
selection.clone(),
key,
source.clone(),
ptr,
false,
);
let _old = ptr(rsc).replace(content);
});
more_strong.any()
}
impl RowBlocks {
/// Bring this row up to date with `markdown_src` **without** re-laying
/// out the blocks that did not change, and say whether that was
/// possible. `false` means the caller must rebuild the row the
/// ordinary way: an earlier block was rewritten (markdown allows it --
/// a trailing `---` turns the paragraph above into a heading), the
/// sender changed, or the message got shorter.
///
/// This is the whole point of the per-block column: a delta arriving
/// in a 3,000-character reply touches one `set_with_spans` on the last
/// block, so parley re-shapes that block and nothing else.
pub fn apply_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
if self.sender.as_deref() != sender {
return false;
}
// A capped row draws less than the message it was built from, so
// appending to it would put the new text *below* the "Show all"
// saying the rest is hidden. The caller rebuilds instead, and
// rebuilds uncapped (`TranscriptScreen::apply`), so this refusal
// costs one rebuild per message rather than one per delta.
if self.capped {
return false;
}
let new_blocks = display_blocks(markdown_src);
let common = common_prefix(&self.blocks, &new_blocks);
// Everything already drawn must either be kept whole (`common ==
// len`, a pure append) or be kept except for the last block, which
// is the one a delta lands in. Anything else means an already
// laid-out block is no longer what it was.
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
return false;
}
// A block's *frame* is built around its widget once and never
// rewritten, so a block whose kind changed under the delta (the
// paragraph that a `|---|` line turns into a table) cannot take
// this path -- it would keep prose's appearance with a table's
// text in it. Only the last block can differ at all, by the check
// above.
if new_blocks.len() == self.blocks.len()
&& common < self.blocks.len()
&& new_blocks[common].kind != self.blocks[common].kind
{
return false;
}
debug_assert!(
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
"one field and one link list per block: {} fields, {} links, {} blocks",
self.fields.len(),
self.links.len(),
self.blocks.len()
);
for (i, block) in new_blocks.iter().enumerate().skip(common) {
match (self.fields.get(i), self.links.get(i)) {
(Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE);
field
.edit(rsc)
.set_with_spans(&rendered.text, rendered.spans);
// Replaced together with the text: a link range left
// over from the previous delta points into a string
// that no longer exists.
*links.borrow_mut() = rendered.links;
}
_ => {
let (field, framed, links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
self.fields.push(field);
self.links.push(links);
// `get_mut` marks the column dirty, which is what gets
// the new block drawn; its removal half is the row's
// own, since the column owns the child strongly.
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
column.push(framed);
}
}
}
}
self.blocks = new_blocks;
true
}
}
fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
item: &TranscriptItem,
cap: bool,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let (sender, markdown_src) = item_content(item);
build_text_row(rsc, list, selection, key, sender, &markdown_src, cap)
}
/// What a row keeps so the next event can change part of it instead of
/// all of it -- one variant per kind of row that has such a path.
///
/// Two mechanisms would have been two answers to the same question ("what
/// can this row do cheaply?"), so the caller holds one of these for its
/// tail row and asks it, rather than holding a `RowBlocks` and a
/// `ToolRow` and choosing between them at each call site.
pub enum TailRow {
/// A message: a column of one text widget per markdown block, so a
/// streamed delta costs the last block.
Blocks(RowBlocks),
/// A tool call or a run of them: a column of cards, so an arriving
/// result costs one card.
Tools(ToolRow),
}
/// `cap` draws a long message as [`cap_message`]'s worth of it behind a
/// "Show all"; the caller passes `false` for the **live tail**, the row a
/// reply is streaming into, because a row that grows while it is capped
/// would appear to stop growing (`RowBlocks::capped`). Every other row is
/// capped.
pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
row: &FoldedRow,
working: bool,
cap: bool,
) -> (RowKey, StrongWidget, Option<TailRow>)
where
Rsc::State: FocusHost + OpenUrl,
{
// A lone tool call is a card too, not a message with markdown in it:
// `group_tool_runs` leaves one call as a `Single` because "Called 1
// tool" hides a card to say the same thing in more words, and the
// *card* is what both cases draw (`ToolRows.kt`).
let calls = match row {
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
Some(std::slice::from_ref(item))
}
FoldedRow::Tools(calls) => Some(calls.as_slice()),
FoldedRow::Single(_) => None,
};
if let Some(calls) = calls {
let key = row_key(&calls[0].key());
let (widget, tools) =
crate::ui::tool::build_tool_row(rsc, list, selection, key, calls.to_vec(), working);
return (key, widget, Some(TailRow::Tools(tools)));
}
let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above");
};
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, selection, key, item, cap);
(key, widget, Some(TailRow::Blocks(blocks)))
}
#[cfg(test)]
mod tests {
use super::*;
fn blocks(src: &str) -> Vec<Block> {
display_blocks(src)
}
#[test]
fn a_message_inside_the_bounds_is_not_capped() {
let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true);
assert_eq!(kept.len(), 2);
assert_eq!(hidden, None);
}
/// Off by default at the call site that matters: the row a reply is
/// streaming into is built with `cap` false, and must come back whole
/// however long it has got.
#[test]
fn cap_false_keeps_everything() {
let src = "a\n\n".repeat(MESSAGE_LINES * 2);
let (kept, hidden) = cap_message(blocks(&src), false);
assert_eq!(kept.len(), MESSAGE_LINES * 2);
assert_eq!(hidden, None);
}
/// The ordinary case: the cut lands between two blocks, so every
/// block drawn is a whole one.
#[test]
fn a_long_message_is_cut_on_a_block_boundary() {
let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2);
let all = blocks(&src);
let (kept, hidden) = cap_message(all.clone(), true);
assert!(kept.len() < all.len(), "nothing was left out");
assert!(
kept.iter().zip(&all).all(|(k, a)| k == a),
"a block was truncated where a boundary was available",
);
assert_eq!(
hidden,
Some(all.iter().map(|b| b.source.lines().count()).sum()),
"the offer says the whole message's line count, not the shown part's",
);
}
/// The half a block boundary cannot answer: one enormous fence, which
/// is what a reply pasting a file arrives as. Truncated rather than
/// dropped -- a row that drew nothing would say less than the line it
/// replaced -- and still a fence, since the kind is decided before the
/// truncation and an unterminated one closes at the end of its input.
#[test]
fn one_block_over_the_bound_by_itself_is_truncated() {
let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2));
let all = blocks(&src);
assert_eq!(all.len(), 1, "the fixture must be a single block");
let (kept, hidden) = cap_message(all.clone(), true);
assert_eq!(kept.len(), 1);
assert_eq!(
kept[0].kind, all[0].kind,
"truncation changed the block's kind"
);
assert!(
kept[0].source.len() < all[0].source.len(),
"the one over-long block was drawn whole",
);
assert!(hidden.is_some());
}
}
+531
View File
@@ -0,0 +1,531 @@
//! Selection spanning multiple transcript rows -- RUST.md's "hard to get
//! back" behaviour 1, and the one E2 found flatly impossible on Masonry:
//! `TextArea` wraps exactly one `parley::PlainEditor`, and there is no
//! `SelectionContainer`-shaped type anywhere in `masonry`/`masonry_core`/
//! `xilem` (RUST.md's E2 box, citing
//! `masonry/src/widgets/text_area.rs:414-459`). Each transcript row here is
//! still its own `TextEdit` (one per row, not one per transcript, since a
//! row is what `LazySpan` virtualises), so this is not literally "one
//! `PlainEditor`" either -- iris's answer is a coordinator that drives each
//! visible row's *own* selection primitives (`TextEditCtx::select`/
//! `select_all`/`deselect`, already built for a single field) from one
//! pointer drag that crosses row boundaries, giving the same reader-facing
//! result (a selection that runs from a reply into the tool output beneath
//! it, one copy) without needing a single shared text buffer underneath.
//!
//! Rows are keyed by `RowKey` (`iris::widget::list`), which every real row
//! source (a transcript's sequence number) already assigns in the order the
//! reader reads them in -- so "between the anchor and the current row" is
//! answered by ordinary integer comparison via a `BTreeMap`, not a second
//! copy of the list's own ordering.
//!
//! **Scoped shortcut, recorded rather than hidden**: the anchor row (the
//! one the drag started in) is selected in full (`select_all`) the moment
//! the drag leaves it, rather than "from the click point to whichever edge
//! points away from the drag" -- the exact partial selection would need
//! that row's own laid-out size, which `TextEditCtx` does not expose to a
//! caller outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
//! private). Only the row currently *under the pointer* gets a true partial
//! selection (from its own start or end, per direction, to the pointer's
//! exact point) -- see `extend`. Re-entering the anchor row is still exact,
//! since that branch never goes through the approximation.
use iris::prelude::*;
use std::{collections::BTreeMap, time::Instant};
/// What this selects between: a row's `RowKey` and the index of one
/// markdown **block** inside it. A row is a column of one text widget per
/// block since 2026-09-06 (`crate::client::markdown_blocks`, and
/// docs/DECISIONS.md for why), so the block, not the row, is the unit --
/// `(row, block)` compares lexicographically, which is reading order for
/// both levels, so every range query below is unchanged.
pub type SelKey = (RowKey, u32);
pub struct Selection {
rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
anchor: Option<(SelKey, Vec2)>,
/// One gesture shared by every row's drag handler -- RUST.md's I5
/// gesture conflict (a row's own `click_or_drag()` and a list-level
/// pan wanting the same touch gesture). See `drag` below, and
/// `iris::sense::DragGesture`'s own doc for the arbitration, velocity
/// tracking and pointer-capture mechanics this no longer owns itself
/// -- Iris's 2026-09-06 ask (`IRIS.md`) that a drag's *mechanics* live
/// in iris's default input layer, with only the pan-vs-select
/// *decision* staying here.
gesture: DragGesture,
/// The transcript's `LazySpan`, whose `ScrollController` owns the
/// position, the fling and `amt` -- what a committed pan and a release
/// are handed to. Set by `build_tree` the moment it exists, which is
/// after this (rows need a `Selection` to be built, and the span
/// needs the rows), hence the `Option` rather than a constructor
/// argument. A `Selection` without one still selects text and still
/// reports taps; it simply cannot pan, which is what the `debug_assert`
/// in `drag` is there to catch in the tests that build one by hand.
scroll: Option<WeakWidget<LazySpan>>,
}
impl Default for Selection {
fn default() -> Self {
Self::new()
}
}
impl Selection {
pub fn new() -> Self {
Self {
rows: BTreeMap::new(),
anchor: None,
gesture: DragGesture::new(),
scroll: None,
}
}
/// Hand this the transcript's `LazySpan` once it exists -- see the
/// `scroll` field. Called by `build_tree`; every pan and fling this
/// arbitrates goes to that span's own `ScrollController`
/// (`Scrollable::scroll`/`fling`), which is where the position lives.
pub fn set_scroll_area(&mut self, scroll: WeakWidget<LazySpan>) {
self.scroll = Some(scroll);
}
/// A row's selectable text became visible/known. Every addition here
/// needs its removal (`unregister`, or `clear` for all of them at
/// once) -- called when `LazySpan` evicts the row (`pop_front`/
/// `pop_back`/`clear`), so this map never outgrows however many rows
/// are actually loaded. `LazySpan::place` guards the twin of this same
/// class of bug on the list's own side (`lazy_span.rs`'s `slot_exists`
/// assertion) -- a derived handle that silently outlives what it
/// points to; the next caller adding a third row-keyed side table
/// should read both.
pub fn register(&mut self, key: SelKey, text: WeakWidget<TextEdit>) {
self.rows.insert(key, text);
}
/// Drops every registration at once -- the same shape `LazySpan::clear()`
/// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm
/// calls right before it, since a full rebuild drops every row's old
/// widget and `push_row` re-`register`s each surviving key's new one
/// as it goes (review docs/REVIEW-2026-09-06.md finding 1: the
/// `Rebuild` arm used to call only `LazySpan::clear()`, leaving any key
/// dropped by the regroup -- present in the old rows, absent from the
/// new ones -- pointing at a widget the list had just freed, so the
/// next long-press anywhere panicked in `begin`'s deselect loop).
pub fn clear(&mut self) {
self.rows.clear();
self.anchor = None;
}
/// Forgets every block of one row -- a row is registered block by
/// block, so its removal has to take all of them, and taking only the
/// first is how a freed widget would be left behind in this map.
pub fn unregister(&mut self, row: RowKey) {
self.rows.retain(|&(k, _), _| k != row);
if self.anchor.map(|((k, _), _)| k) == Some(row) {
self.anchor = None;
}
}
/// A fresh press: clears whatever was selected elsewhere (an ordinary
/// click starts a new selection, it does not extend the old one) and
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
/// never turns into a drag leaves exactly this and nothing else
/// selected.
pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
let rows: Vec<SelKey> = self.rows.keys().copied().collect();
for k in rows {
if k != key
&& let Some(w) = self.rows.get(&k)
{
w.edit(ui).deselect();
}
}
if let Some(w) = self.rows.get(&key) {
w.edit(ui).select(pos, size, false, false);
}
self.anchor = Some((key, pos));
}
/// The drag continues, now over `key`'s row at `pos`. See the module
/// doc for the anchor-row shortcut.
pub fn extend(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
let Some((anchor_key, _anchor_pos)) = self.anchor else {
return;
};
if key == anchor_key {
if let Some(w) = self.rows.get(&key) {
w.edit(ui).select(pos, size, true, false);
}
return;
}
let (lo, hi) = if anchor_key < key {
(anchor_key, key)
} else {
(key, anchor_key)
};
let in_range: Vec<SelKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
for k in &in_range {
let Some(w) = self.rows.get(k).copied() else {
continue;
};
if *k == key {
// The row under the pointer: partial selection from
// whichever of its own edges faces the anchor, extended to
// the exact pointer point.
let start = if key > anchor_key { Vec2::ZERO } else { size };
w.edit(ui).select(start, size, false, false);
w.edit(ui).select(pos, size, true, false);
} else {
w.edit(ui).select_all();
}
}
let outside: Vec<SelKey> = self
.rows
.keys()
.copied()
.filter(|k| *k < lo || *k > hi)
.collect();
for k in outside {
if let Some(w) = self.rows.get(&k) {
w.edit(ui).deselect();
}
}
}
/// The block indices currently registered for `row`, in order. For a
/// test asserting that a row's removal or rebuild took every one of
/// its blocks with it -- the contract `unregister` states and the one
/// a caller can get wrong silently, since a stale handle only shows
/// up as a panic on some later, unrelated press.
#[cfg(test)]
pub fn registered_blocks(&self, row: RowKey) -> impl Iterator<Item = u32> + '_ {
self.rows
.keys()
.filter(move |(k, _)| *k == row)
.map(|&(_, b)| b)
}
/// Which registered block is under `pos_window`, with the position
/// and size that block's own `TextEdit` wants (block-local, the way
/// `begin`/`extend` are given them by a block's own pointer handler).
///
/// For the pointer-captured half of a drag, where the event no longer
/// reaches the widget under the finger and the list-level handler has
/// to say where the finger is. It asks the render state for each
/// block's drawn box rather than doing the arithmetic from the row's
/// extent -- the box is what a hit test resolves against anyway, and
/// it means this and a block's own handler cannot disagree about
/// where a block is. O(blocks loaded), on one frame of a drag.
pub fn locate(
&self,
ui: &impl UiRsc,
render: &UiRenderState,
pos_window: Vec2,
) -> Option<(SelKey, Vec2, Vec2)> {
for (&key, w) in &self.rows {
let Some(px) = render.window_region(w, ui) else {
continue;
};
if px.contains(pos_window) {
return Some((key, pos_window - px.top_left, px.size()));
}
}
None
}
/// Whether any row currently has a non-empty selection -- what a fresh
/// press consults so `drag` knows whether an early horizontal move is
/// "start dragging the selection handle" rather than an ordinary tap.
fn has_selection(&self, ui: &mut impl UiRsc) -> bool {
self.rows
.values()
.any(|w| w.edit(ui).text.selected_text().is_some())
}
/// One row's `CursorSense::click_or_drag()` handler, for every row,
/// routes its raw pointer data through here rather than calling
/// `begin`/`extend` directly -- this is the single place that decides
/// whether the gesture pans `list` or extends a selection, so the
/// decision is made once per gesture rather than independently by
/// whichever row happens to be under the finger this frame (see
/// `DragArbiter`'s own doc for why one shared instance, not one per
/// row, is what makes that consistent as a drag crosses row
/// boundaries).
///
/// `row`, if given, is `(key, pos_row, size)` for whichever row the
/// pointer is currently over -- row-local, as `begin`/`extend` want.
/// `None` once the gesture is pointer-captured (`iris::sense`'s
/// pointer-capture doc) and the current position falls outside every
/// row `LazySpan` has loaded (a gap, or off the end of the content); a
/// `Pan` outcome never needs it, so this only actually matters mid-
/// selection, where it is rare and the frame is simply dropped.
/// `pos_window` is in window space, since a pan's delta has to stay
/// meaningful even when this frame's event landed on a different row
/// than the last one. `pointer` is `CursorData`'s own field -- what
/// `DragGesture` needs to take pointer capture.
#[allow(clippy::too_many_arguments)]
/// Returns what the gesture decided this frame, so a caller with its
/// own meaning for a *tap* -- a row's link handler -- reads it from
/// the one arbiter that already knows, rather than timing a second
/// one beside it (which would disagree the moment either changed).
pub fn drag(
&mut self,
ui: &mut impl UiRsc,
list: WeakWidget<LazySpan>,
row: Option<(SelKey, Vec2, Vec2)>,
pos_window: Vec2,
sense: CursorSense,
now: Instant,
pointer: &PointerRequests,
) -> GestureOutcome {
// A fresh touch-down cancels any fling still coasting from the
// previous gesture -- `ScrollController::fling`'s own doc, and Android's
// `Scroller::abortAnimation` for the same reason -- and, since
// 2026-09-07, *tells the gesture there was one*. A press that
// caught moving content is a catch: it pans from this very sample
// rather than waiting out `DRAG_SLOP`, which is what pins the
// content to the finger instead of leaving it coasting for the
// first few frames (Iris's "it fails to stop & snap to where
// finger is"). See `DragArbiter::press_start` for Compose's own
// mechanism. `starts_press` rather than a `PressStart` test of our
// own, so this fires on the recovered-press frames too.
debug_assert!(
self.scroll.is_some(),
"Selection::drag with no scroll area: a committed pan would be silently dropped -- \
call `set_scroll_area` after building the transcript's `LazySpan`"
);
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.scroll.is_some_and(|s| s(ui).is_scrolling());
if let Some(scroll) = self.scroll {
scroll(ui).cancel_fling();
}
}
press.already_selected = self.has_selection(ui);
let outcome = self
.gesture
.handle(pointer, list.id(), sense, pos_window, now, press);
match outcome {
// Somebody else took the gesture (a code fence panning
// sideways under the finger). Nothing here acted on it, and
// `DragGesture` has already forgotten it, so there is nothing
// to undo either -- the point is that no tap, fling or
// selection follows from a gesture that was never ours.
GestureOutcome::Cancelled | GestureOutcome::Undecided => {}
// `scroll(dy)`, not `scroll(-dy)`: since the position moved
// into the controller there is one convention for a scroll delta in
// the crate -- the finger's -- rather than a `LazySpan` whose
// anchor offset ran the other way while claiming to mirror it.
GestureOutcome::Pan(dy) => {
if let Some(scroll) = self.scroll {
scroll(ui).scroll(dy);
}
}
GestureOutcome::SelectStart => {
if let Some((key, pos_row, size)) = row {
// Grep-able on "iris selection" the way the frame
// report is on "iris frame report" -- selection has no
// accessibility label of its own yet, so this is the
// smallest way to confirm a real on-device long-
// press-then-drag actually reached here (RUST.md's I5
// box, "Measurements taken" (c)).
log::info!("iris selection: begin at row {key:?}");
self.begin(ui, key, pos_row, size);
}
}
GestureOutcome::SelectExtend => {
if let Some((key, pos_row, size)) = row {
log::info!("iris selection: extend to row {key:?}");
self.extend(ui, key, pos_row, size);
}
}
// A fling only ever follows a pan -- never a selection that
// happened to end with the finger still moving, and never a
// tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => {
// The half that actually makes it move -- see
// `ScrollController::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything.
//
// Only when `fling` actually took it: below Compose's
// `|v| <= 1.0` there is nothing to tick, and registering
// an animation for a widget that is not animating asks the
// next frame to find that out (docs/REVIEW-2026-09-07.md's
// second nit).
if let Some(scroll) = self.scroll
&& scroll(ui).fling(v)
{
let id = scroll.id();
ui.ui_mut().animate(id);
}
}
// A tap is nobody's business here -- `row.rs` reads it from
// the returned outcome and follows a link if one was under
// the finger.
GestureOutcome::Released(None) | GestureOutcome::Tapped => {}
}
outcome
}
/// The concatenated selected text, in row order, `None` if nothing is
/// selected -- what a copy command reads. Joins with a blank line
/// between rows, matching how the transcript itself separates them.
pub fn selected_text(&self, ui: &mut impl UiRsc) -> Option<String> {
let mut parts = Vec::new();
for w in self.rows.values() {
if let Some(text) = w.edit(ui).text.selected_text() {
parts.push(text);
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Pure range-membership logic, independent of any widget/render
// machinery (the same reasoning `begin`/`extend` apply per-row) --
// exercised directly so the "which rows fall between anchor and
// current" arithmetic has a test that needs no `UiRenderState`.
fn in_range(anchor: RowKey, current: RowKey, keys: &[RowKey]) -> Vec<RowKey> {
let (lo, hi) = if anchor < current {
(anchor, current)
} else {
(current, anchor)
};
keys.iter()
.copied()
.filter(|k| *k >= lo && *k <= hi)
.collect()
}
#[test]
fn selection_spans_forward_across_rows() {
let keys = [1, 2, 3, 4, 5];
assert_eq!(in_range(2, 4, &keys), vec![2, 3, 4]);
}
#[test]
fn selection_spans_backward_across_rows() {
let keys = [1, 2, 3, 4, 5];
assert_eq!(in_range(4, 2, &keys), vec![2, 3, 4]);
}
#[test]
fn selection_within_one_row_is_just_that_row() {
let keys = [1, 2, 3];
assert_eq!(in_range(2, 2, &keys), vec![2]);
}
struct TestRsc {
ui: UiData,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
/// The RUST.md I5 intermittent-touch-scroll-dropout regression: a
/// gesture whose `ACTION_DOWN` landed where no row's sensor covers
/// (padding, a gap, a header with no handler) delivers this row only
/// `Pressing` frames, never `PressStart`. Before the fix, the shared
/// `DragArbiter` stayed `Idle` for the whole gesture (`DragArbiter::
/// update`'s own doc), which is exactly what a real device trace
/// showed for four of twenty-four otherwise-identical swipes in one
/// run -- `ui-trace`-driven touch coordinates land on different row
/// content each time the list actually scrolls, so whether `DOWN`
/// happens to hit a sensor is intermittent by construction. This test
/// fails on the code before `Selection::drag`'s `_ if self.arbiter.
/// is_idle()` branch existed, because the arbiter would still report
/// `is_idle()` after both calls below.
#[test]
fn a_missed_press_start_recovers_on_the_next_pressing_frame() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let field = rsc
.ui
.widgets
.add_strong(TextEdit::new(
TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None),
EditMode::MultiLine,
))
.weak();
let list = rsc
.ui
.widgets
.add_strong(LazySpan::new(Dir::DOWN, Pin::End));
let list_weak = list.weak();
// The scroll area the real screen puts around it: a committed pan
// goes there, and `Selection` asserts it was told about one.
let scroll = rsc
.ui
.widgets
.add_strong(LazySpan::new(Dir::DOWN, Pin::End))
.weak();
let list = list_weak;
let mut sel = Selection::new();
sel.set_scroll_area(scroll);
sel.register((1, 0), field);
assert!(sel.gesture.is_idle());
let pointer = PointerRequests::default();
let now = Instant::now();
let size = Vec2::new(100.0, 20.0);
// No `PressStart` is ever sent -- only the `Pressing` frames a
// widget whose sensor missed the `ACTION_DOWN` would actually see.
sel.drag(
&mut rsc,
list,
Some(((1, 0), Vec2::ZERO, size)),
Vec2::new(540.0, 700.0),
CursorSense::Pressing(CursorButton::Left),
now,
&pointer,
);
assert!(
!sel.gesture.is_idle(),
"a Pressing frame with the arbiter still Idle must recover \
the press rather than leaving it stuck"
);
}
#[test]
fn unregister_forgets_every_block_of_the_row_and_clears_a_matching_anchor() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let field = rsc
.ui
.widgets
.add_strong(TextEdit::new(
TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None),
EditMode::MultiLine,
))
.weak();
let mut sel = Selection::new();
// Two blocks of the same row, which is what `unregister` has to
// take together -- removing only the first is how a freed widget
// gets left in this map.
sel.register((5, 0), field);
sel.register((5, 1), field);
sel.anchor = Some(((5, 1), Vec2::ZERO));
assert_eq!(sel.rows.len(), 2);
sel.unregister(5);
assert!(sel.rows.is_empty());
assert!(sel.anchor.is_none());
}
}
+80
View File
@@ -0,0 +1,80 @@
//! A tap on something in the transcript, and holding the reader's edge
//! while what they tapped changes height.
//!
//! Both halves are shared by everything in the transcript that opens: a
//! tool card and its group ([`crate::ui::tool`]), and a message's "Show all"
//! ([`crate::ui::row`]). They are here rather than in either of those because
//! there is one right answer to "was that a tap?" on this screen, and two
//! copies of it would eventually disagree -- which on a scrolling screen
//! means one of them firing at the end of a pan.
use crate::ui::selection::Selection;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
/// Register `f` as `ptr`'s **tap**, panning the list instead when the
/// finger moves.
///
/// `Selection::drag` with no row is the same call `row.rs` makes with one:
/// it drives the shared `DragArbiter`, so a drag starting on a card
/// scrolls (and flings) the transcript exactly as one starting on a
/// paragraph does, and only a press that committed to nothing comes back
/// as `Tapped`. A bare `CursorSense::click()` would be a second,
/// disagreeing detector -- it fires at the end of a pan too, so every
/// scroll that began on a card would also toggle it.
pub(crate) fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
f: impl Fn(&mut Rsc) + 'static,
) where
Rsc::State: FocusHost + OpenUrl,
{
// The whole `drag_senses()` set -- what any widget driving a
// `DragGesture` registers, `Cancel` included. See `row.rs`'s twin
// registration for what leaving `Cancel` out did.
ptr.on(CursorSense::drag_senses(), move |ctx, rsc| {
let outcome = selection.borrow_mut().drag(
rsc,
list,
None,
ctx.data.cursor.pos,
ctx.data.sense,
ctx.data.cursor.time,
ctx.data.pointer,
);
if outcome == GestureOutcome::Tapped {
f(rsc);
}
})
.add(rsc);
}
/// Hold the edge the reader is looking at while row `key` changes height.
///
/// `LazySpan::note_tap` wants a viewport-relative position and a row only
/// knows its own box, so `LazySpan::extent` (last frame's on-screen box
/// for this key) turns the two into the position `lazy_span.rs`'s
/// hold-the-edge pass resolves against -- the two-step contract that
/// module's doc describes for `AGENTS.md`'s `holdTopEdge`.
///
/// Call it **before** the change, from every handler that makes a row
/// taller or shorter: opening a card, opening a group, asking for the
/// whole of a capped block or message. A handler that skips it is one
/// where the transcript jumps under the reader's finger.
pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget<LazySpan>, key: RowKey) {
// Only when the list actually has an extent for this row. `None`
// means the row has not been drawn yet -- which happens the moment
// something opens a group before the first frame
// (`TranscriptScreen::expand_tail_tools`, the headless screenshot) --
// and standing in `0.0` for it tells the layout pass to hold an edge
// at the top of the viewport that nothing was ever at. The whole list
// then places itself against that invented anchor: rows drawn at each
// other's cached heights, tool cards as empty bars with their text a
// group's height below them (`docs/bench/p1b-2026-09-06/`'s first
// attempt). Nothing to hold is not the same as an edge at zero.
if let Some((top, _bottom)) = list(rsc).extent(key) {
list(rsc).note_tap(top);
}
}
+886
View File
@@ -0,0 +1,886 @@
//! Tool-call cards and the runs they are grouped into -- the port of
//! `ToolRows.kt`/`ToolInput.kt` (RUST.md's P1b).
//!
//! One card per call. Closed, it is a single line: the tool's name and
//! what the call is for ([`crate::client::tool_summary::parse_tool_input`]'s
//! `title`). The command itself is not on it, because a wrapped command
//! turns one row into four and a run of them into a wall. Open, it shows
//! the description, the input and the output.
//!
//! **A collapsed card lays out its summary line and nothing else.** Not an
//! optimisation -- the discipline this crate is built to. The bench
//! fixture carries tool outputs of tens of kilobytes, and a collapsed card
//! that built a text widget for one would pay parley for text nobody can
//! see. `collapsed_cards_shape_only_their_summary_lines` in `lib.rs` holds
//! it, counting `UiRenderState`'s text-shape counter the same way
//! `a_delta_into_a_long_reply_...` counts it for a streamed delta.
//!
//! **Two or more adjacent calls are one group** -- decided in
//! `crate::client::transcript_fold::group_tool_runs`/`adopt_run` and never
//! re-derived here. A group is a header, a column of cards on its own
//! surface, and a bar at its foot: it closes from either end, because a
//! long group's header scrolls off while its last call is still on screen,
//! and the reader who wants it shut is looking at the bottom.
//!
//! **A result arriving replaces one card.** [`ToolRow::apply_calls`] is
//! the group's half of `RowBlocks::apply_delta`'s discipline: a group is a
//! column of cards, and a `ToolEnd` changes exactly one of them.
//!
//! **Every tap here is a tap** -- `GestureOutcome::Tapped` out of the one
//! `DragArbiter` `Selection` already owns, never a second detector. A
//! finger that panned the list past a card must not also open it; that
//! rule is written once, in the gesture machine, and this file only reads
//! its answer.
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
use crate::client::tool_summary::{ToolInput, parse_tool_input};
use crate::client::transcript_fold::{ToolState, TranscriptItem};
use crate::ui::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into};
use crate::ui::selection::Selection;
use crate::ui::tap::{hold_edge, on_tap};
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
/// as an object on it.
const CARD_FILL: UiColor = UiColor::new(0x31, 0x32, 0x44, 255);
/// The surface a group's cards sit on: Mantle, one step *below* the page.
/// That surface is the single cue saying these calls belong together, and
/// it goes below rather than above because the cards are already above --
/// two steps in the same direction render as one flat block.
const GROUP_FILL: UiColor = UiColor::new(0x18, 0x18, 0x25, 255);
/// A tool's name, and any of the call's own words.
const NAME_COLOR: UiColor = TEXT_COLOR;
/// The summary line and the leftover input fields: Subtext 0, the Compose
/// app's `onSurfaceVariant` -- structure about the call rather than the
/// call's own words.
const MUTED_COLOR: UiColor = UiColor::new(0xA6, 0xAD, 0xC8, 255);
/// Waiting on a person -- Peach, `Theme.kt`'s `awaitingColor`. The same
/// colour a question card takes, because it is the same fact.
const AWAITING_COLOR: UiColor = UiColor::new(0xFA, 0xB3, 0x87, 255);
/// The call itself failed -- Red, the scheme's `error`/`failedColor`.
const FAILED_COLOR: UiColor = UiColor::new(0xF3, 0x8B, 0xA8, 255);
/// **Nobody found out** -- Yellow, `Theme.kt`'s `warningColor`. Its own
/// colour *and* its own word: the expensive confusion is between this and
/// a call that finished having printed nothing, those two share an empty
/// output, and a difference in kind cannot be carried by colour alone.
const UNKNOWN_COLOR: UiColor = UiColor::new(0xF9, 0xE2, 0xAF, 255);
/// A tool's name (Material `titleSmall`).
const NAME_SIZE: f32 = 14.0;
/// The summary line, and the input and output text (`bodySmall`).
const BODY_SIZE: f32 = 12.0;
/// The state word, the "Output" heading and the group's own count
/// (`labelSmall`).
const LABEL_SIZE: f32 = 11.0;
/// The room inside a card, and so the height a bar of one line of text
/// comes to (`ToolRows.kt`'s `GROUP_INSET_LARGE`).
const CARD_PAD_DP: f32 = 12.0;
/// A card's corner: `shapes.medium`, the same as every other card in the
/// app.
const CARD_RADIUS_DP: f32 = 12.0;
/// The gap between the parts of a card's header line, and between the
/// stacked parts of an open card.
const GAP_DP: f32 = 8.0;
/// Smaller than a card's radius, and deliberately: a verbatim block sits
/// *inside* one, and a rounded rectangle drawn at the same radius as the
/// one behind it reads as a misprint (`RawBlock.kt`).
const RAW_RADIUS_DP: f32 = 4.0;
/// The room inside a verbatim block.
const RAW_PAD_DP: f32 = 8.0;
/// How far a group holds its cards off its own edge (`ToolRows.kt`).
const GROUP_INSET_DP: f32 = 4.0;
/// The size of the disclosure mark, as a font size in dp.
///
/// The mark is a glyph in the icon font iris ships (`iris::icon`, built by
/// `iris/core/build-icon-font.sh`) -- not a codepoint out of whatever the
/// platform resolved, which is what U+25B8/25BE/25B4 were until
/// 2026-09-08: Iris's phone drew an empty box for them and this VM drew a
/// dot once iris stopped bundling faces. The font's Mono face draws each
/// glyph inside a full em, so this reads a little larger than the same
/// number would as text.
const MARK_DP: f32 = 9.0;
/// Which cards the reader has opened, and which have had their whole
/// output asked for.
///
/// Outside the widget tree on purpose: a card is rebuilt when its result
/// arrives, and being open is the reader's state rather than the event's
/// -- held in the widget, it would silently close the moment the tool
/// answered. Keyed by the call's own id, which survives a regroup. Its
/// path out is [`ToolRow::apply_calls`], which drops the entry for any
/// call no longer in the row.
#[derive(Default)]
struct ToolRowState {
group_expanded: bool,
open: HashMap<String, bool>,
whole: HashMap<(String, Part), bool>,
}
/// Which half of an open card a cap and its "Show all" belong to.
///
/// The two are capped and revealed **independently**: a reader who wants
/// the whole of a 900-line `new_string` rarely also wants the whole of the
/// build log underneath it, and one control revealing both would make the
/// card jump by the sum of two things when it was asked about one.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Part {
Input,
Output,
}
/// Everything a handler needs to redraw part of this row, in one `Rc` so
/// that a handler registered once keeps working against calls that arrive
/// later. The rebuild functions read `calls` fresh rather than capturing a
/// call, which is what lets [`ToolRow::apply_calls`] replace a card's
/// content without re-registering its gesture.
struct Shared {
calls: RefCell<Vec<TranscriptItem>>,
state: RefCell<ToolRowState>,
/// One `WidgetPtr` per call, in order -- what makes a result cost one
/// card. Empty while the group is collapsed, because a collapsed group
/// draws no cards at all. Its path out is [`build_content`], which
/// clears it before building whatever replaces them.
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
/// The whole row's content, swapped when the group opens or closes.
/// Filled in immediately after construction -- the `WidgetPtr` cannot
/// exist before the `Rc` every handler inside it captures.
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
/// Whether a call in this row could still be running -- the caller's
/// `session_working`, and `false` for every row behind the newest,
/// whose turn has already ended. The one input to [`ToolState`] that
/// is not a property of the call itself, and what separates "still
/// going" from "nobody found out".
working: Cell<bool>,
}
/// One transcript row's worth of tool calls, kept by the caller for the
/// row a result can still land in -- the tool-call counterpart of
/// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card
/// rather than a row.
pub struct ToolRow {
shared: Rc<Shared>,
}
fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
wtext(content)
.size(size)
.color(color)
.text_align(Align::LEFT)
}
/// One of `iris::icon`'s disclosure marks, at [`MARK_DP`] in the muted
/// colour -- the one place this crate names the icon family, so a second
/// icon is a second constant rather than a second way of asking.
fn disclosure<Rsc>(glyph: &'static str) -> TextBuilder<Rsc> {
text(glyph, MARK_DP, MUTED_COLOR).family(Family::Icons)
}
/// A verbatim block: monospace on the surface everything verbatim in this
/// app sits on, not wrapped, panning sideways on a finger.
///
/// Not wrapped for `ToolInput.kt`'s reason -- a wrapped command hides
/// where its arguments end, and the long one is the one being read
/// closely. A long line is **clipped** here rather than pannable, which a
/// markdown fence (`row.rs`'s `BlockFrame::Verbatim`) is not: adding
/// `.scrollable(Axis::X, Pin::Start)` to this non-editable `Text` made it draw
/// nothing at all -- an empty panel where the command should be, seen on
/// 2026-09-06 in `docs/bench/p1b-2026-09-06/` and bisected to that one
/// call (the fence, which does the same thing to a `TextEdit`, is fine).
/// Recorded in docs/IRIS_TODO.md; when it is fixed, the pan belongs here
/// too, because the long command is the one being read closely.
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>) -> StrongWidget
where
Rsc::State: FocusHost,
{
let field = body
.family(Family::Monospace)
.size(BODY_SIZE)
.wrap(false)
.add(rsc);
field
.scrollable(Axis::X, Pin::Start)
.pad(dp(RAW_PAD_DP))
.masked_by(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
/// The word a card shows for what became of the call, and the colour it is
/// in. `None` for a call that simply worked -- the ordinary outcome says
/// nothing, the way it says nothing in Compose.
///
/// Colour by consequence: the same red wherever something failed, the same
/// peach wherever the turn is stopped on a person, yellow where the answer
/// is that nobody knows.
fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> {
match state {
// A spinner would say the machine is working; while this call
// waits on an answer the machine is doing nothing at all, so the
// card says whose move it is instead (`ToolRows.kt`).
ToolState::Deciding => Some(("your turn", AWAITING_COLOR)),
ToolState::Running => Some(("running", MUTED_COLOR)),
ToolState::Failed => Some(("failed", FAILED_COLOR)),
ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)),
ToolState::Succeeded => None,
}
}
/// What a screen reader is given for one card, and what a `ui-trace`
/// script taps by: the tool, what the call is for, and how it went when
/// that is anything but "fine" -- the same three things the Compose card's
/// own text says, in the order it says them.
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
let mut name = tool.to_string();
if let Some(title) = parsed.title() {
name.push_str(": ");
name.push_str(title);
}
if let Some((word, _)) = state_mark(state) {
name.push_str(" (");
name.push_str(word);
name.push(')');
}
name
}
/// The heading a group carries, closed or open. Compose's exact wording,
/// because it is also the name every `ui-trace` script taps it by.
fn group_label(count: usize) -> String {
format!("Called {count} tools")
}
/// One verbatim block as it will be drawn: the text, the line count of
/// the *whole* of it, and whether anything was left out.
///
/// `whole` is the reader having already asked for all of it, folded in
/// here so that every caller reads the same three values whichever answer
/// it was.
fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
Some((head, lines)) if !whole => (head, lines, true),
Some((_, lines)) => (body, lines, false),
None => (body, body.lines().count(), false),
}
}
/// Whether the reader has asked for the whole of `part` on this call.
fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
shared
.state
.borrow()
.whole
.get(&(id.to_string(), part))
.copied()
.unwrap_or(false)
}
/// The "Show all N lines" a capped block is followed by.
///
/// A control rather than a note, and it says the count rather than "more",
/// because the reader is deciding whether to ask for it: "Show all 4,000
/// lines" and "Show all 12 lines" are different decisions and the word
/// "more" tells them apart not at all.
fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
id: &str,
part: Part,
lines: usize,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let label = show_all_label(lines);
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR)
.label(label)
.add_strong(rsc);
more(rsc).set(words);
let shared_for_tap = shared.clone();
let key = (id.to_string(), part);
on_tap(
rsc,
more,
shared.list,
shared.selection.clone(),
move |rsc| {
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
shared_for_tap
.state
.borrow_mut()
.whole
.insert(key.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
},
);
more_strong.any()
}
/// The tool's output, or the reason there is none to show.
///
/// The empty cases are drawn rather than left blank: "it printed nothing"
/// and "nothing ever came back" are the pair [`ToolState`] exists to keep
/// apart, and a card that drew neither would show the same thing for both.
fn output_block<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
id: &str,
output: &str,
call_state: ToolState,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
if output.is_empty() {
let (words, colour) = match call_state {
ToolState::Succeeded => ("No output", MUTED_COLOR),
ToolState::Failed => ("Failed, with no output", FAILED_COLOR),
ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR),
ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR),
};
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
}
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any());
// What the tool printed, in the face it was written for: this is
// column-aligned far more often than it is prose, and a proportional
// font destroys the alignment that carried the meaning.
let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR);
column.push(raw_block(rsc, body));
if was_cut {
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
}
column.width(rest(1)).add_strong(rsc).any()
}
/// One tool call's card content.
///
/// Collapsed, this is one `Span` of at most four short strings -- no
/// input, no output, nothing whose size is the call's size.
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let call = shared.calls.borrow()[index].clone();
let TranscriptItem::ToolRun {
id,
tool,
input,
output,
..
} = &call
else {
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
return Span::empty(Dir::DOWN).add_strong(rsc).any();
};
let parsed = parse_tool_input(tool, input);
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
// A call waiting on permission is shown open whatever the reader last
// chose: the command is the thing being decided, and a row saying only
// "Bash" cannot be decided on (`ToolRows.kt`).
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|| call_state == ToolState::Deciding;
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
header.push(
disclosure(if open { icon::OPEN } else { icon::CLOSED })
.add_strong(rsc)
.any(),
);
header.push(
text(tool.clone(), NAME_SIZE, NAME_COLOR)
.add_strong(rsc)
.any(),
);
match (open, parsed.title()) {
// Open, the summary is redundant -- the input below is the same
// thing in full -- and the space goes to the timeout instead, at
// the far end, since it is a limit on the call rather than part of
// what the call does.
(true, _) | (false, None) => {
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
}
// One line, clipped rather than shrunk or wrapped: a wrapped
// command turns one row into four and a run of them into a wall.
(false, Some(title)) => header.push(
text(title.to_string(), BODY_SIZE, MUTED_COLOR)
.wrap(false)
.masked()
.width(rest(1))
.add_strong(rsc)
.any(),
),
}
if open && let Some(timeout) = &parsed.timeout {
header.push(
text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR)
.add_strong(rsc)
.any(),
);
}
if let Some((word, colour)) = state_mark(call_state) {
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
}
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
column.push(header.width(rest(1)).add_strong(rsc).any());
if open {
if let Some(description) = &parsed.description {
// The tool's own prose about what it is doing, so it belongs
// with the reader's text rather than inside the machine's --
// above the input block rather than in it (`ToolInput.kt`).
column.push(
text(description.clone(), BODY_SIZE, MUTED_COLOR)
.width(rest(1))
.add_strong(rsc)
.any(),
);
}
// The input's blocks are capped as **one** thing, with one "Show
// all" under the last of them: the subject and the leftover fields
// are two halves of the same answer to "what was this call given",
// and two controls would make the reader ask twice. An `Edit` is
// why the input needs a cap at all -- its `old_string` and
// `new_string` arrive here whole, and are routinely the largest
// text on the screen.
let whole = wants_whole(shared, id, Part::Input);
let mut input_lines = 0usize;
let mut input_cut = false;
if let Some(subject) = &parsed.subject {
let (shown, lines, was_cut) = capped(subject, whole);
input_lines += lines;
input_cut |= was_cut;
let spans = match parsed.language {
// Highlighted over what is *drawn*, not over the whole
// subject: a span past the end of the text it styles is a
// range into nothing.
Some(language) => {
let mut spans = Vec::new();
highlight_into(&mut spans, shown, 0..shown.len(), language);
spans
}
// An unknown language is drawn plain rather than coloured
// by the nearest one -- P1a's rule for a fence, and the
// same reason: a wrong highlight is read as a fact.
None => Vec::new(),
};
let body = text(shown.to_string(), BODY_SIZE, NAME_COLOR).spans(spans);
column.push(raw_block(rsc, body));
}
if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool
// had no other input when it might (`ToolInput.kt`). Capped is
// not dropped -- the field is still there, with its size said
// out loud.
let joined = parsed.rest.join("\n");
let (shown, lines, was_cut) = capped(&joined, whole);
input_lines += lines;
input_cut |= was_cut;
let body = text(shown.to_string(), BODY_SIZE, MUTED_COLOR);
column.push(raw_block(rsc, body));
}
if input_cut {
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
}
column.push(output_block(rsc, shared, index, id, output, call_state));
}
column
.width(rest(1))
.pad(dp(CARD_PAD_DP))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(card_label(tool, &parsed, call_state))
.add_strong(rsc)
.any()
}
/// Rebuild card `index` in place. The removal half is the returned
/// `StrongWidget` being dropped, which frees the content this replaced.
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
where
Rsc::State: FocusHost + OpenUrl,
{
let Some(ptr) = shared.card_ptr(index) else {
// Reached only if a handler outlives the card it was registered
// on, which `apply_calls` is written to prevent.
debug_assert!(false, "card {index} has no widget to redraw");
return;
};
let content = build_card(rsc, shared, index);
let _old = ptr(rsc).replace(content);
}
/// A card and the tap that opens it. The gesture is registered **once**,
/// on a `WidgetPtr` whose content is replaced as often as needed -- which
/// is why every rebuild reads the call out of [`Shared`] rather than
/// capturing one.
fn build_card_ptr<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
) -> (StrongWidget, WeakWidget<WidgetPtr>)
where
Rsc::State: FocusHost + OpenUrl,
{
// The strong handle is the card's one real registration and goes to
// whatever container holds it; the weak one is what the gesture and
// every later redraw address it by.
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
shared.cards.borrow_mut().push(ptr);
debug_assert_eq!(
shared.cards.borrow().len(),
index + 1,
"a card's index is its position, and both are the call's"
);
let content = build_card(rsc, shared, index);
ptr(rsc).set(content);
let for_tap = shared.clone();
on_tap(
rsc,
ptr,
shared.list,
shared.selection.clone(),
move |rsc| {
hold_edge(rsc, for_tap.list, for_tap.key);
let Some(id) = for_tap.call_id(index) else {
debug_assert!(false, "tapped card {index} is no longer in the row");
return;
};
let was = for_tap
.state
.borrow()
.open
.get(&id)
.copied()
.unwrap_or(false);
for_tap.state.borrow_mut().open.insert(id, !was);
redraw_card(rsc, &for_tap, index);
},
);
(strong.any(), ptr)
}
/// A bar the height of one line of `LABEL_SIZE` text, carrying `mark`
/// centred -- the group's collapse control at its foot.
///
/// Given the same content as the heading above rather than a height that
/// looks close, so the surface the calls sit on is the same thickness at
/// both ends (`ToolRows.kt`'s `groupBarHeight`, which derives the number
/// from the type for the same reason).
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let mark = disclosure(icon::COLLAPSE)
.center_text()
.width(rest(1))
.pad(dp(CARD_PAD_DP))
// Anything shown only as a mark still needs a name: this is what
// a screen reader reads and what a `ui-trace` script taps.
.label("Collapse these tool calls")
.add_strong(rsc);
ptr(rsc).set(mark);
let for_tap = shared.clone();
on_tap(
rsc,
ptr,
shared.list,
shared.selection.clone(),
move |rsc| toggle_group(rsc, &for_tap),
);
strong.any()
}
/// The row's whole content: a lone card, a closed group's one line, or an
/// open group's header, cards and foot.
///
/// Rebuilt whole when the group opens or closes, because that is a change
/// of what the row *is* rather than of one card in it. Everything a single
/// card's tap does goes through [`redraw_card`] instead.
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
shared.cards.borrow_mut().clear();
let count = shared.calls.borrow().len();
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
// One call is left alone: "Called 1 tool" hides a card to say the same
// thing in more words, and the run this grouping exists for is the
// burst of five greps nobody wants to scroll past (`ToolRows.kt`).
if count == 1 {
return build_card_ptr(rsc, shared, 0).0;
}
if !shared.state.borrow().group_expanded {
let heading = group_label(count);
return text(heading.clone(), NAME_SIZE, NAME_COLOR)
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any();
}
let heading = group_label(count);
let mut group = Span::empty(Dir::DOWN);
group.push(
text(heading.clone(), NAME_SIZE, NAME_COLOR)
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any(),
);
// The cards sit in their own `Span` inside the group's, inset from
// its edge the way `ToolRows.kt` insets them.
//
// This shape was flattened into one `Span` between 2026-09-06 and
// 2026-09-08 to work around "a `Span` of `Pad`ded children inside
// another `Span` places those children a slot out of step", which
// cost the group that inset. **Not reproducible on 2026-09-08**:
// `IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot` puts
// every card's content in its own box with the two spans nested, and
// `iris`'s `a_span_of_padded_children_inside_a_span_draws_each_where_
// its_box_is` pins that at layer 1. Something between those dates
// fixed it -- most likely f5b8893's `mov`-vs-`reposition` work or the
// nested-mask pass -- so the workaround is gone rather than kept
// against a defect that no longer exists.
{
let mut cards = Span::empty(Dir::DOWN);
for index in 0..count {
cards.push(build_card_ptr(rsc, shared, index).0);
}
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
}
// Shutting it from here anchors the other end: the reader is at the
// bottom of a long group, and what they are looking at is what follows
// it (`ToolRows.kt`'s `CollapseBar`).
group.push(collapse_bar(rsc, shared));
group
.width(rest(1))
.background(rect(GROUP_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
where
Rsc::State: FocusHost + OpenUrl,
{
hold_edge(rsc, shared.list, shared.key);
let was = shared.state.borrow().group_expanded;
shared.state.borrow_mut().group_expanded = !was;
let content = build_content(rsc, shared);
shared.set_content(rsc, content);
}
impl Shared {
/// Swap the row's whole content. The old `StrongWidget` is freed as it
/// drops here, which is the removal half of what replaced it.
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
let Some(ptr) = *self.content.borrow() else {
debug_assert!(
false,
"the row's content pointer is set before anything can tap it"
);
return;
};
let _old = ptr(rsc).replace(content);
}
fn call_id(&self, index: usize) -> Option<String> {
match self.calls.borrow().get(index) {
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
_ => None,
}
}
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
self.cards.borrow().get(index).copied()
}
}
/// Build a tool row: one card, or a run of them under one heading.
///
/// `working` is the caller's `session_working` **for this row** -- true
/// only for the newest row of a session that is still doing something.
/// Every row behind it belongs to a turn that has ended, so a call in one
/// with no result never came back rather than still running.
pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
working: bool,
) -> (StrongWidget, ToolRow)
where
Rsc::State: FocusHost + OpenUrl,
{
let shared = Rc::new(Shared {
calls: RefCell::new(calls),
state: RefCell::new(ToolRowState::default()),
cards: RefCell::new(Vec::new()),
content: RefCell::new(None),
list,
selection,
key,
working: Cell::new(working),
});
// `.add_strong`, not `.add`: this row *is* the top of its own subtree,
// so nothing else holds it and it has to own itself (`row.rs`).
let content_strong = WidgetPtr::new().add_strong(rsc);
let content = content_strong.weak();
*shared.content.borrow_mut() = Some(content);
let inner = build_content(rsc, &shared);
content(rsc).set(inner);
(content_strong.any(), ToolRow { shared })
}
impl ToolRow {
/// The calls this row is currently drawing -- what a caller passes
/// back to [`Self::apply_calls`] when something other than the calls
/// themselves changed (the session's status).
pub fn calls(&self) -> Vec<TranscriptItem> {
self.shared.calls.borrow().clone()
}
/// How many cards this row currently draws -- zero for a closed
/// group, which is the whole reason its calls' outputs cost nothing.
/// Only the tests ask; nothing on screen is decided by it.
#[cfg(test)]
pub(crate) fn card_count(&self) -> usize {
self.shared.cards.borrow().len()
}
/// Open or close this row's group without a tap.
///
/// Exists because the expanded appearance is otherwise unreachable
/// from anything that cannot press the screen -- a headless
/// screenshot on this displayless machine, and a test. Same path a tap
/// takes, including `LazySpan::note_tap`, so what it produces is what a
/// reader would have got.
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.shared.state.borrow().group_expanded != expanded {
toggle_group(rsc, &self.shared);
}
}
/// Bring this row up to date with `calls` **without** rebuilding the
/// cards that did not change, and say whether that was possible.
/// `false` means the caller must rebuild the row the ordinary way.
///
/// This is what the per-card `WidgetPtr` exists for: a `ToolEnd`
/// changes one call, so it costs one card, whatever else is in the
/// run. The same rule `RowBlocks::apply_delta` follows for the blocks
/// of a message.
///
/// Refused when a call *left* the row or the calls were reordered: a
/// card's index is its call's position, and every registered handler
/// closed over that index. A run only ever grows at its end while it
/// is the live row, so the refused cases are the ones a page join
/// produces -- and those go through `Rebuild` already.
pub fn apply_calls<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
calls: &[TranscriptItem],
working: bool,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
// A row that was tool calls and now holds something else is a
// different row, not a changed one -- and nothing here could draw
// a message anyway.
if calls.is_empty()
|| !calls
.iter()
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
{
return false;
}
let old = self.shared.calls.borrow().clone();
if calls.len() < old.len() {
return false;
}
// Whether the group is drawn as one card or as a stack changes at
// exactly one call, and that is a different row, not a changed
// one.
if (old.len() == 1) != (calls.len() == 1) {
return false;
}
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
self.shared.working.set(working);
*self.shared.calls.borrow_mut() = calls.to_vec();
// The path out for the reader's own state: a call that is no
// longer in this row keeps no entry in `open` or `whole`.
let ids: std::collections::HashSet<String> = calls
.iter()
.filter_map(|c| match c {
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
{
let mut state = self.shared.state.borrow_mut();
state.open.retain(|id, _| ids.contains(id));
state.whole.retain(|(id, _), _| ids.contains(id));
}
// A collapsed group draws no cards, so a changed call is worth
// nothing on screen -- unless the *count* changed, which is the
// whole of what its one line says.
if self.shared.cards.borrow().is_empty() {
if calls.len() != old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
return true;
}
debug_assert_eq!(
self.shared.cards.borrow().len(),
old.len(),
"an open row draws exactly one card per call"
);
for index in changed {
redraw_card(rsc, &self.shared, index);
}
// A call *joining* the run rebuilds the row's content rather than
// appending one card: the group's `Span` holds its collapse bar
// after the cards, and `Span::push` would put the new card behind
// it. That is still O(this row) -- every other row is untouched --
// and it is much rarer than a result arriving, which is the case
// the per-card `WidgetPtr` above exists for.
if calls.len() > old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
true
}
}
+211
View File
@@ -0,0 +1,211 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for catching a fling:
//! the real transcript screen over the real bench fixture, at the phone's
//! size and density, with no window, no compositor and no GPU.
//!
//! docs/IRIS_TODO.md's 2026-09-07 night report -- "sometimes when I try to
//! catch it while it's still moving (particularly if I drag) then it fails
//! to stop & snap to where finger is". The finger goes down on content
//! that is still travelling and the content does not follow it until
//! `DRAG_SLOP` has been crossed, which at a fling's speed is several
//! frames of the content sliding *away* from a finger that is already
//! down. Compose does not do that: a down while `isScrollInProgress`
//! starts the drag immediately (`scrollable`'s `startDragImmediately`).
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use iris::sense::DRAG_SLOP;
/// The screen open on the fixture, framed twice -- once to draw, once for
/// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
/// Where the content is, in window pixels: the top of whichever row is
/// under the middle of the viewport. `LazySpan` has no travel accessor and
/// this needs none -- a row's own extent moves exactly as far as the
/// content does, and the row is picked once so the two readings compare.
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
/// Three finger samples 8ms apart, each moving `STEP` further down the
/// screen. `STEP * 3` is deliberately **under** `DRAG_SLOP`: a gesture
/// this small moves nothing at all on a settled list (the control below),
/// so anything it moves here is the catch and not the slop being crossed.
const STEP: f32 = 2.0;
const SAMPLES: usize = 3;
const CATCH_X: f32 = 540.0;
/// Feeds the down and its `SAMPLES` moves from `y0` at `t0`, asserting
/// after each one that the content moved by exactly the finger's own
/// delta. Returns the release time.
fn drag_from(
h: &mut Harness,
screen: &ai_app::ui::TranscriptScreen,
key: RowKey,
y0: f32,
t0: u64,
expect_tracking: bool,
) -> u64 {
let before = row_top(h, screen, key);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
assert_eq!(
row_top(h, screen, key),
before,
"the down itself must not move the content, only stop it"
);
let mut t = t0;
for i in 1..=SAMPLES {
let moved = STEP * i as f32;
t = t0 + 8 * i as u64;
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
let travelled = row_top(h, screen, key) - before;
if expect_tracking {
assert!(
(travelled - moved).abs() < 0.5,
"sample {i}: the finger has moved {moved}px since the down and the content \
{travelled:.1}px -- it is not pinned to the finger"
);
} else {
assert!(
travelled.abs() < 0.5,
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
nothing, but the content moved {travelled:.1}px"
);
}
}
t += 8;
h.touch(
TouchAction::Up,
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
t,
);
t
}
/// The report itself: flick, let the fling run for 150ms, then put a
/// finger down and drag it a little. From the down onwards the content is
/// pinned to the finger, sample for sample -- no slop, and no coasting
/// past the place the finger stopped it.
#[test]
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
// Frames, not a file: the second half of this gesture has to arrive
// *while* the fling is ticking, and a `.touch` replay inserts no
// frames between its samples, so a fling recorded that way would be
// running on paper and stationary in fact.
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
}
/// The other half of the same rule: a catch that never moved at all is a
/// `Released(None)`, not a tap. Compose's scrollable consumes that DOWN,
/// so no click detector under it ever sees the gesture -- stopping a
/// fling with a finger must not also follow the link it landed on, and
/// must not hand the list a velocity to start again with.
#[test]
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
let stopped_at = row_top(&mut h, &screen, key);
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a press that stopped a fling and moved nothing must not start another"
);
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
assert!(
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
"the content moved after a catch was released without moving"
);
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"a catch is not a tap: nothing under it may be followed"
);
}
/// The half this change had no reason to touch: on a list that is *not*
/// moving, the same tiny drag is still inside `DRAG_SLOP` and still moves
/// nothing. Without this, making every press pin the content would pass
/// the test above and take the slop away from every ordinary press.
#[test]
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
// Long past the spline's own 2071ms for this recording.
let settled = h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
flick.end_ms() + 4000,
PHONE_FRAME_MS,
);
assert!(
!(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(
&mut h,
&screen,
key,
1200.0,
settled + PHONE_FRAME_MS,
false,
);
}
+230
View File
@@ -0,0 +1,230 @@
//! Layer 1 for Iris's 2026-09-08 "flinging doesn't work in horizontal
//! scroll areas": a real markdown fence in the real transcript screen,
//! flicked sideways, has to keep moving after the finger leaves.
//!
//! The fence is pushed here rather than hunted for in the bench fixture,
//! so the test knows which row it is pressing and where. The `ScrollArea` it
//! asserts on is found by walking what is actually drawn -- there is no
//! handle to it from the outside, and a coordinate would only prove that
//! *something* moved.
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchAction};
use iris::prelude::*;
/// The horizontal scroll area drawn inside `top..bottom`, with the box
/// it was drawn at -- a fence is the only thing in a transcript that pans
/// sideways. Found by walking what is actually drawn, because there is no
/// handle to a fence's own `ScrollArea` from the outside and a bare
/// coordinate would only prove that *something* moved.
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
h.render
.active
.keys()
.copied()
.filter(|&id| {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.is_some_and(|s| s.axis() == Axis::X)
})
.find_map(|id| {
let r = h.render.window_region(&id, &h.rsc)?;
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
})
}
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.expect("the fence's scroll area is still drawn")
.is_scrolling()
}
fn amt(h: &Harness, id: WidgetId) -> f32 {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.expect("the fence's scroll area is still drawn")
.amt()
}
#[test]
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve \
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = ai_app::ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
// Down the middle of the fence's own box, so the press is on the
// text inside the scroll area rather than on the row's sender label.
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
// A flick sideways: four samples 8ms apart, accelerating, then the
// finger leaves.
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
let at_release = amt(&h, fence_scroll);
assert!(
at_release > 0.0,
"the flick itself must have panned the fence, got {at_release}"
);
// Frames for the next half second, with nothing touching the screen.
let mut t = 240;
while t <= 740 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let coasted = amt(&h, fence_scroll);
assert!(
coasted > at_release + 1.0,
"the fence stopped dead at the release: {at_release} -> {coasted}"
);
// ...and it settles rather than running forever.
let settled = coasted;
while t <= 4_000 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let after = amt(&h, fence_scroll);
assert!(
after >= settled,
"a fling must not run backwards: {settled} -> {after}"
);
let last = after;
h.frame(t);
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
}
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
/// horizontal scroll animation is still active, it stays locked to the
/// horizontal scroll. It should let it keep going and instead only affect
/// vertical scrolling."
///
/// Her own diagnosis was the right one -- "tapping outside of something
/// that a fling is currently active for should have no code in common
/// with the fling that could influence it" -- and
/// `sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
/// is the mechanism in isolation. This is the same thing over the real
/// screen, which is where it was found: the finger goes down on an
/// ordinary row 500px above a coasting fence, and what must move is the
/// list, while the fence carries on coasting untouched.
#[test]
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: format!(
"```\n{}\n```",
(1..=200)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ")
),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = ai_app::ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
// Flick the fence sideways and let go, exactly as above.
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
h.frame(248);
assert!(
is_scrolling(&h, fence_scroll),
"the fence has to still be coasting for this to be the reported case",
);
// A row well clear of the fence, taken by its own extent rather than
// by a coordinate: the gaps between rows are pointer-transparent, so a
// y picked by hand lands on nothing often enough to make a green run
// meaningless.
let probe = box_.top_left.y - 500.0;
let row = (screen.list)(&mut h.rsc)
.key_at(probe)
.expect("a row that far up the screen");
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
let from = (row_top + row_bottom) / 2.0;
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
let fence_before = amt(&h, fence_scroll);
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
let mut t = 264;
for i in 1..=8 {
h.touch(
TouchAction::Move,
Vec2::new(540.0, from + 20.0 * i as f32),
t,
);
t += 8;
}
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
assert_ne!(
list_before,
(screen.list)(&mut h.rsc).anchor_position_display(),
"the drag was nowhere near the fence, so it belongs to the list",
);
assert!(
amt(&h, fence_scroll) > fence_before,
"the fence's fling must carry on through a gesture that was never \
its own: {fence_before} -> {}",
amt(&h, fence_scroll),
);
}
+206
View File
@@ -0,0 +1,206 @@
//! Layer 1 of docs/RUST.md's "Three test layers" for a gesture the
//! *platform* takes away, over the real transcript screen and the real
//! bench fixture.
//!
//! Both halves of Iris's 2026-09-08 report about the transcript moving on
//! its own live here. A cancel is not a release, so nothing may follow it
//! -- and every widget that was tracking the press has to hear about it,
//! or the next press anywhere on screen is measured from the origin the
//! abandoned one left behind.
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// Where the content actually is, in window pixels: the top of whichever
/// row is under the middle of the viewport, tracked by key. The anchor's
/// own `idx/off` display is not that -- the list rehomes its anchor to a
/// different row without the content moving at all -- so a test asserting
/// "nothing moved" reads a row's own extent, the way `catch_a_fling.rs`
/// does.
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
/// The system's own swipe up from the bottom edge to leave the app is
/// delivered to the app as moves and then `ACTION_CANCEL`. Read as a
/// release it hands the list that swipe's velocity, and the transcript
/// flings while nobody is looking -- "leaving and reopening the app also
/// randomly moved the vertical scroll".
#[test]
fn a_cancelled_flick_does_not_fling() {
let (mut h, screen) = opened();
let flick = script(
"flick-cancelled",
include_str!("../touch/flick-cancelled.touch"),
);
h.replay(&flick);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a gesture the platform took away must not fling"
);
// ...and it must not be moving on its own over the following second
// either, which is what a fling started some other way would look
// like.
let (key, settled) = tracked_row(&mut h, &screen);
let end = flick.end_ms() + 1_000;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
t += PHONE_FRAME_MS;
}
let now = row_top(&mut h, &screen, key);
assert!(
(now - settled).abs() < 0.5,
"the list kept moving after a cancelled gesture: {settled} -> {now}"
);
}
/// The other half, and the one that made a *later* touch snap: a cancel
/// has to reach every widget that was handed a frame of the press, so the
/// gesture it was driving forgets its origin. Without it the arbiter is
/// still open with the abandoned press's touch-down as its origin, and
/// the next press is measured from there -- a jump the size of the
/// distance between two unrelated touches.
#[test]
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
let (mut h, screen) = opened();
// Press near the top of the transcript and let the platform take it.
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
let (key, before) = tracked_row(&mut h, &screen);
// A plain tap, a long way down the screen from where that press
// started. It must move nothing at all.
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
let after = row_top(&mut h, &screen, key);
assert!(
(after - before).abs() < 0.5,
"a tap after a cancelled press panned the list by {}px, the distance between them",
after - before
);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
}
/// The report itself: "if I scroll in a horizontal area and then tap in a
/// vertical area, it seems to snap."
///
/// A markdown fence pans sideways through its own `ScrollArea`, which takes
/// pointer capture the moment it commits. Everything else that was handed
/// a frame of that press is told so with `CursorSense::Cancel` -- and the
/// widget the press actually landed on is the fence's own text block,
/// which drives `ai_app::ui::Selection`'s shared `DragGesture`. A
/// block that does not register `Cancel` never hears it, so the gesture
/// stays open with the fence's touch-down as its origin and the next
/// press anywhere is measured from there.
///
/// The fence is pushed here rather than hunted for in the fixture, so the
/// test knows exactly which row it is pressing and where.
#[test]
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let (mut h, screen) = opened();
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
.to_string(),
settled: true,
});
// A plain paragraph under it, because the tap has to land on
// ordinary text: a tap that happens to hit a tool group's header
// toggles it, and a row changing height moves the list for a reason
// that has nothing to do with this.
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_001,
text: "A plain paragraph with nothing to tap in it, only words, so that a \
press here is a press on ordinary text and nothing else."
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
screen.push_row(&mut h.rsc, &para);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
// Press in the middle of the fence's own row, so the gesture starts on
// the text block inside the scroll area rather than in a gap.
let key = ai_app::ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let y = (top + bottom) / 2.0;
assert!(
y > 0.0 && y < phone_size().y,
"the fence row has to be on screen to be pressed: {top}..{bottom}"
);
// Sideways, well past `DRAG_SLOP`, so the fence commits and captures.
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
let (tracked, before) = tracked_row(&mut h, &screen);
// A tap on the paragraph, a long way down the screen from where that
// pan started.
let para_key = ai_app::ui::row::row_key(&para.key());
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
.extent(para_key)
.expect("the paragraph row is on screen");
h.touch(
TouchAction::Down,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
400,
);
h.touch(
TouchAction::Up,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
450,
);
let after = row_top(&mut h, &screen, tracked);
assert!(
(after - before).abs() < 0.5,
"a tap after panning a code fence moved the transcript by {}px",
after - before
);
}
+192
View File
@@ -0,0 +1,192 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for the diagnostics
//! themselves rather than a widget: `iris::diagnostics::set_trace` gates
//! `iris::input`/`iris::frame` (Iris's 2026-09-07 request, "add another
//! button to copy input event info ... instrument a lot of the code with
//! timings"), and `docs/REVIEW-2026-09-07.md`'s D1 found that the switch
//! existed but four older per-frame `debug!` lines were not wired to it,
//! filling the app's 2000-line log ring with frame spam before `Copy
//! report` had a chance to include anything else. This is what a fix to
//! that has to prove, both directions:
//!
//! 1. **Off** (the default): replaying a real gesture through a real
//! screen leaves the ring holding nothing below `info` -- so the
//! lines D1 named, and everything this pass gated the same way, really
//! are silent by default rather than merely "usually quiet."
//! 2. **On**: the same replay produces `iris::input` lines that
//! `report_to_touch.py` turns back into the exact `TouchScript` that
//! was replayed, and `iris::frame` lines with real, non-zero
//! durations dated on the harness's own clock.
//!
//! **Single capturing logger, single test function** (this file's only
//! `#[test]`): `log::set_logger` can succeed exactly once per process, and
//! AGENTS.md's "tracing caches callsite interest process-wide" lesson is
//! the general form of why every exercise of a logging path has to share
//! one subscriber -- so if a second test here ever needs the ring's
//! contents, it must extend this one rather than install its own.
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchScript};
/// Records every line's level and formatted message -- enough to answer
/// both "is the ring quiet" (no line at `Debug` or below) and "what did
/// tracing actually write" (the `iris::input` lines, read back by
/// `report_to_touch.py`).
struct CaptureLogger {
lines: Mutex<Vec<(log::Level, String)>>,
}
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
impl log::Log for CaptureLogger {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
self.lines
.lock()
.unwrap()
.push((record.level(), record.args().to_string()));
}
fn flush(&self) {}
}
/// Installs the capture logger at `Debug` -- the same level
/// `iris/android-app/src/lib.rs`'s `JNI_OnLoad` installs at, which is
/// exactly why `iris::diagnostics::trace_enabled` has to be the gate
/// (its own module doc) rather than the level.
fn logger() -> &'static CaptureLogger {
let logger = LOGGER.get_or_init(|| CaptureLogger {
lines: Mutex::new(Vec::new()),
});
// Ignore "already set": a previous call in this same test binary
// already won, and it is the same logger either way.
let _ = log::set_logger(logger);
log::set_max_level(log::LevelFilter::Debug);
logger
}
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
std::mem::take(&mut *logger.lines.lock().unwrap())
}
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
#[test]
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
let logger = logger();
// --- (1) off: a real flick through a real screen leaves the ring
// with nothing at `Debug` or below.
iris::diagnostics::set_trace(false);
drain(logger); // whatever `opened()` itself logged while building
let (mut h, screen) = opened();
drain(logger); // and whatever opening logged
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
let quiet = drain(logger);
let debug_lines: Vec<_> = quiet
.iter()
.filter(|(level, _)| *level == log::Level::Debug)
.collect();
assert!(
debug_lines.is_empty(),
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
);
// --- (2) on: the same replay, from a fresh screen so the anchor and
// sequence numbers match `flick-120hz.touch` exactly again.
iris::diagnostics::set_trace(true);
let (mut h, screen) = opened();
drain(logger);
h.replay(&flick);
let _ = (screen.list)(&mut h.rsc);
let traced = drain(logger);
iris::diagnostics::set_trace(false); // leave it off for any test after this one
let input_lines: Vec<&str> = traced
.iter()
.filter(|(_, msg)| msg.contains("iris input: action="))
.map(|(_, msg)| msg.as_str())
.collect();
assert_eq!(
input_lines.len(),
flick.samples.len(),
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
);
let frame_lines: Vec<&str> = traced
.iter()
.filter(|(_, msg)| msg.starts_with("iris frame:"))
.map(|(_, msg)| msg.as_str())
.collect();
assert!(
!frame_lines.is_empty(),
"expected at least one `iris::frame` line once tracing was on"
);
for line in &frame_lines {
// `layout=` and `draw=` are `{:?}`-formatted `Duration`s, so a real
// one reads like `12.34µs`/`1.2ms`, never the bare `0ns` a
// no-op frame would print.
assert!(
!line.contains("layout=0ns"),
"a frame that redrew should not report zero layout time: {line}"
);
}
// --- the round trip: pipe every `iris::input` line through
// `report_to_touch.py` and parse the result back into a `TouchScript`,
// which must equal the one that was replayed. `report_to_touch.py`
// is prefix-agnostic (it `search`es for the marker), so handing it
// the bare message is the same as handing it a real ring line.
let report = input_lines.join("\n");
let script_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../iris/benches/report_to_touch.py"
);
let mut child = Command::new("python3")
.arg(script_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("python3 must be on PATH to run report_to_touch.py");
{
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(report.as_bytes())
.unwrap();
}
let output = child.wait_with_output().expect("report_to_touch.py exited");
assert!(
output.status.success(),
"report_to_touch.py failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
let round_tripped =
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
assert_eq!(
round_tripped.samples.len(),
flick.samples.len(),
"round trip produced a different number of samples:\n{touch_text}"
);
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
assert_eq!(original.t_ms, back.t_ms);
assert_eq!(original.action, back.action);
assert_eq!(original.pos, back.pos);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Layer 1 of docs/RUST.md's "Three test layers": the real transcript
//! screen, over the real bench fixture, at the phone's size and density,
//! driven by `iris::harness` with no window, no compositor and no GPU.
//!
//! Every gesture here is a file under `touch/` -- see
//! `flick-120hz.touch` for why the *shape* of the delivery is the whole
//! point, and why the emulator cannot produce it (a `ui-trace` swipe is
//! many evenly-spaced events; a finger at 120Hz is five samples in
//! 20ms).
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchScript};
use iris::prelude::*;
/// The screen open on the fixture, framed twice: once to draw, once for
/// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String {
(screen.list)(&mut h.rsc).anchor_position_display()
}
/// (a) and (b) together, because the second is only meaningful if the
/// first happened: the recorded flick must release with a real velocity
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that
/// puts a value in `Scroll::fling_velocity`), and the list must then
/// actually travel and stop on the spline's own schedule.
#[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc)
.fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap");
// Compose's own answer for this recording's five samples, printed by
// `iris/benches/velocity_reference.py` -- not a number read off this
// code. **Positive** because the flick runs *down* the screen and a
// delta now carries the finger's own direction the whole way, from the
// gesture through `Selection::drag` (which passes it straight to
// `Scroll::fling`) to the anchor. It read -15250 while the transcript
// negated the velocity on its way into a `LazySpan` whose anchor
// offset ran the other way; the magnitude is the number that came from
// `velocity_reference.py` and it has not changed.
// The 2026-09-07 before/after: the old average estimator read
// 12250px/s here, which is the fling Iris reported as too slow.
assert!(
(velocity - 15_250.0).abs() < 20.0,
"expected ~15250px/s from velocity_reference.py, got {velocity}"
);
// `iris/benches/fling_spline_reference.py`'s own line for this exact
// case -- `density=2.55 v=15250.0: distance=11057.424px
// duration=2.0716s`. **Not** `FlingCalculator::new(PHONE_SCALE)`,
// which is the calculator under test: bounding a fling with the thing
// being measured is the "compared the code with itself" shape 73f956f
// found in the spline's own tests, and it left this one able to fail
// in the "ran too long" direction only -- never in the "stopped dead"
// direction, which is what Iris actually reported
// (docs/REVIEW-2026-09-07.md's T1).
const REFERENCE_MS: u64 = 2071;
const REFERENCE_PX: f32 = 11057.0;
let end = flick.end_ms() + REFERENCE_MS * 2;
let mut settled_at = None;
let mut t = flick.end_ms();
// Travel in pixels, measured from a row's own on-screen extent, since
// `LazySpan` has no travel accessor and this needs none: follow whatever
// row is under the viewport's middle until it leaves, then pick
// another. Deliberately an *under*-count -- the frame a row leaves on
// contributes nothing -- which is why it is only ever a lower bound.
let middle = phone_size().y / 2.0;
let mut travelled = 0.0f32;
let mut tracked: Option<(RowKey, f32)> = None;
while t <= end {
h.frame(t);
let list = (screen.list)(&mut h.rsc);
tracked =
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
Some((key, was, now)) => {
travelled += (now - was).abs();
Some((key, now))
}
None => list
.key_at(middle)
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
};
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
}
let after = offset(&mut h, &screen);
assert_ne!(
before, after,
"the fling ticks must have moved the list off where the flick left it"
);
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
let ran_for = settled_at - flick.end_ms();
// Both directions. The lower bound is the one that fails when a fling
// settles on its first tick; the upper is the one that was here.
assert!(
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
);
assert!(
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
);
// 80% of the reference, against 10527px measured today -- the 5%
// shortfall is the frames a tracked row leaves the screen on. A fling
// that moves one row's worth fails this; scaling `tick_fling`'s delta
// by 0.01 reports 111px, which is how it was confirmed to fail in the
// direction the bug goes.
assert!(
travelled >= REFERENCE_PX * 0.8,
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
);
}
/// The half the flick fix had no reason to touch: a tap must decide
/// `Tapped`, which means no velocity anywhere and nothing moved.
#[test]
fn a_tap_on_a_row_moves_nothing() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
// Frames it would have moved in, had anything been moving.
h.frames_until(100, 400, PHONE_FRAME_MS);
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"no link was under this tap"
);
}
/// A press held past `LONG_PRESS` and then dragged selects text rather
/// than panning -- the other branch of the same arbiter the flick goes
/// through.
#[test]
fn a_long_press_and_drag_selects_text() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script(
"long-press",
include_str!("../touch/long-press.touch"),
));
let selected = screen
.selected_text(&mut h.rsc)
.expect("a long-press then drag must leave text selected");
assert!(
!selected.trim().is_empty(),
"the selection covered no characters: {selected:?}"
);
assert_eq!(
before,
offset(&mut h, &screen),
"a selection must not also pan the list"
);
}
/// The composer sits on whatever the platform says the bottom of usable
/// space is -- the keyboard's inset while it is open
/// (`Composer::set_bottom_inset`, the path Android's
/// `on_insets_changed` feeds). Checked here rather than on the emulator
/// because it is a layout fact, and the emulator costs minutes.
#[test]
fn the_composer_sits_above_a_simulated_ime_inset() {
let (mut h, screen) = opened();
let height = h.size().y;
let field_bottom = |h: &mut Harness| {
h.render
.window_region(&screen.composer.field, &h.rsc)
.expect("the composer field is on screen")
.bot_right
.y
};
let closed = field_bottom(&mut h);
assert!(
closed <= height,
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
);
// A Gboard-sized keyboard on this surface. Any real number would do;
// what matters is that the bar clears it.
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
let open = field_bottom(&mut h);
assert!(
open <= height - ime,
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
height - ime
);
assert!(
(closed - open - ime).abs() < 1.0,
"the composer moved {} for a {ime}px inset",
closed - open
);
}
/// A newline typed into the composer must leave the caret inside the
/// bar's own padding, not flush against its bottom edge.
///
/// Iris's phone, 2026-09-08: "when typing with the keyboard up and
/// entering enough newlines ... the text drops down close to the bottom
/// and seems to ignore the padding. If I close (and optionally reopen)
/// the keyboard it seems to fix itself." The cause was `Scroll::draw`
/// placing its child against *last* frame's content length and stopping
/// there: each newline drew the field in a box one line short of its
/// text, and since the text is centred in its box it hung half a line
/// past each end, putting the caret's line box a full padding below the
/// bar's inside edge. Nothing dirtied that subtree again, so the stale
/// placement was simply the last one drawn -- until the keyboard closed
/// and the inset rewrite forced a redraw, which is the "fixes itself"
/// half of the report. No settling frame here on purpose: the placement
/// is corrected within the frame that typed, so the first frame drawn
/// after a keystroke is already right.
#[test]
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
let (mut h, screen) = opened();
let height = h.size().y;
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
h.state.set_focus(Some(screen.composer.field));
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
// Past `composer::MAX_LINES`, so the bar is capped and scrolling
// rather than still growing -- the state the report is about.
for _ in 0..12 {
screen.composer.field.edit(&mut h.rsc).insert("a\n");
h.frame(PHONE_FRAME_MS);
}
// The caret is the last primitive `TextEdit::draw` emits.
let caret = {
let slot = *h
.render
.debug(h.rsc.widgets(), "Message")
.flat_map(|a| a.primitives.iter().map(|p| p.slot))
.collect::<Vec<_>>()
.last()
.expect("the focused field draws a caret");
h.render.primitive_corners(slot, &h.rsc)
};
// The bar sits directly on the IME, so its inside edge is one
// `FIELD_PAD_DP` above `height - ime`. Stated in pixels rather than
// read back from the composer, which is the thing under test.
let bar_bottom = height - ime;
let padding = 12.0 * PHONE_SCALE;
assert!(
caret.bot_right.y < bar_bottom - padding / 2.0,
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
and its padding is {padding}px",
caret.bot_right.y,
);
}
+340
View File
@@ -0,0 +1,340 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for the transcript's
//! own edges: the real screen over the real fixture, under a header bar
//! like the bench app's, driven by `iris::harness`.
//!
//! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report --
//! rows scrolled above the viewport still drawn, over the header, and a
//! blank band where the row straddling the top edge should be. Both are
//! one rule (`LazySpan::intersects_viewport`): a row is drawn if any part of
//! it is inside the list's own box, and nothing outside that box reaches
//! the screen.
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::Harness;
use iris::prelude::*;
/// A header band above the transcript, as `bench_client.rs` puts one --
/// the surface the rows were drawing over on the phone. Its exact height
/// does not matter; what matters is that the list's own box does not
/// start at the top of the window, so "above the viewport" and "off the
/// screen" are different places.
const HEADER_H: f32 = 300.0;
const HEADER: UiColor = UiColor::new(28, 28, 34, 255);
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let (opened, tree) = ai_app::ui::fixture::build_screen(&mut h.rsc).expect("the fixture folds");
let content = WidgetPtr::new().add(&mut h.rsc);
content(&mut h.rsc).set(tree);
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(&mut h.rsc)
.any();
h.state.set_root(root);
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
/// The list's own on-screen box, in window pixels.
fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion {
h.render
.window_region(&screen.list.id(), &h.rsc)
.expect("the list is on screen")
}
/// Every row the list drew this frame, as `(top, bottom)` window pixels,
/// topmost first. A `LazySpan`'s direct children are exactly its rows, and
/// `draw_inner`'s old-children diffing means a row it did not place this
/// frame is not among them.
fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> {
let mut rows: Vec<(f32, f32)> = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.iter()
.filter_map(|id| h.render.window_region(id, &h.rsc))
.map(|px| (px.top_left.y, px.bot_right.y))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows
}
/// Scrolls `amount` and runs the frame it asks for, returning the time of
/// the next one. **Positive walks back through older rows** -- the
/// finger's own direction, and `Scroll::scroll`'s, which is the one
/// convention a delta has anywhere in iris since the transcript's scroll
/// position lives in the `LazySpan`'s own `ScrollController`.
/// It used to be the opposite here, because a `LazySpan`'s anchor offset
/// ran the other way.
fn scrolled(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
(screen.list)(&mut h.rsc).scroll(amount);
h.frame(t);
t + PHONE_FRAME_MS
}
/// (a) of docs/IRIS_TODO.md's reproduction: with a row across the top
/// edge, that row is placed -- the viewport's first pixel belongs to
/// something. A rule that culled a row once its *top* left the viewport
/// would leave a blank band here, which is the second of Iris's two
/// screenshots.
#[test]
fn the_row_across_the_top_edge_is_drawn() {
let (mut h, screen) = opened();
let top = list_box(&h, &screen).top_left.y;
let mut t = PHONE_FRAME_MS * 2;
// 40px a frame, the shape a finger pan arrives in, through a straddle
// and out the other side of it many times over.
for _ in 0..60 {
t = scrolled(&mut h, &screen, 40.0, t);
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("something is on screen");
assert!(
first.0 <= top + 0.5,
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
at {top:.1}",
first.0 - top,
first.0,
);
assert!(
first.1 > top,
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
{top:.1}",
first.1,
);
}
}
/// (b): what falls outside the list's box is clipped rather than drawn
/// over whatever is there. The straddling row above is drawn *in full*,
/// so the only thing between its earlier lines and the header bar is this
/// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run
/// benchmark" button.
///
/// The clip is one the screen **opted into** (`build_tree`'s `.masked()`),
/// so this reads the mask the list *inherited*. A `LazySpan` sets none of
/// its own -- masking is opt-in, like scrolling (Iris, 2026-09-08) -- so
/// this is also the test that the transcript is still asking for one.
#[test]
fn the_list_is_clipped_to_its_own_box() {
let (h, screen) = opened();
let active = h.render.active.get(&screen.list.id()).expect("drawn");
assert!(
active.mask != MaskIdx::NONE,
"the transcript's list is drawn with nothing clipping it",
);
let clip = h.render.mask_region(active.mask, &h.rsc);
let list = list_box(&h, &screen);
assert!(
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
edge still draws past it",
);
// And the mask has to *reach* what the rows draw. The two above say a
// mask exists and sits in the right place; neither says any primitive
// references it, so a broken `Mask::parent` chain -- what d507ae4
// introduced -- would leave them green while a code fence inside a row
// drew unclipped again (docs/REVIEW-2026-09-07.md's T3).
let rows = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.clone();
let mut checked = 0;
for row in rows {
for prim in primitives_under(&h, row) {
assert!(
mask_chain(&h, prim).contains(&active.mask),
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
own mask {:?}",
mask_chain(&h, prim),
active.mask,
);
checked += 1;
}
}
assert!(
checked > 0,
"no row primitive was checked, so this test asserted nothing",
);
}
/// Every primitive `id` and its descendants drew, as `MaskIdx`es -- images
/// excluded, since they live in a separate instance array with their own
/// indices (`Primitives::free`).
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
let Some(active) = h.render.active.get(&id) else {
return Vec::new();
};
let mut out: Vec<MaskIdx> = active
.primitives
.iter()
.filter(|p| p.binding != IMAGE_BINDING)
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
.collect();
for child in &active.children {
out.extend(primitives_under(h, *child));
}
out
}
/// The chain the fragment stage walks from `mask`, outermost last.
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
let mut chain = Vec::new();
let mut at = mask;
while at != MaskIdx::NONE {
assert!(
!chain.contains(&at),
"the mask chain from {mask:?} loops back to {at:?}",
);
chain.push(at);
at = h.rsc.ui.masks[at.idx()].parent;
}
chain
}
/// A row that has left the viewport entirely is not drawn at all. Before
/// the fix the walk ran from the anchor -- which `scroll` leaves wherever
/// it was, however far outside the viewport that ends up -- and drew
/// every row on the way: 8 scrolls of 3000px left **64 rows** placed for
/// a 2012px viewport, ~59 of them off screen and painting over the
/// header.
///
/// The box is asserted on every leg *except the first*, because a row
/// whose height has never been measured has to be drawn to be measured
/// (`LazySpan::place`'s doc), which on the first walk back is every row
/// entering from the top. Every later leg crosses the same rows with
/// every height already known -- including the second walk *back*, which
/// is there because a regression that draws rows in the wrong place while
/// travelling backwards would otherwise be checked only by the row count
/// (docs/REVIEW-2026-09-07.md's T2). That is also the ordinary state of a
/// transcript being panned around in. The bound on how many rows are
/// placed at once holds on all three.
#[test]
fn rows_that_have_left_the_viewport_are_not_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
// A handful of rows whatever distance has been travelled -- the
// module doc's own claim about this widget.
assert!(
rows.len() <= 24,
"{leg} {step}: {} rows drawn for one 2012px viewport",
rows.len(),
);
};
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
for &(top, bottom) in rows {
assert!(
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
{list:?} and was drawn anyway",
);
}
};
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
bounded(&drawn_rows(&h, &screen), "measuring", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step);
inside(&rows, "forward", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "back", step);
inside(&rows, "back", step);
}
}
/// The end the fix had no reason to touch: the row across the *bottom*
/// edge, where the composer starts. Same rule, other direction -- and the
/// list opens pinned there, so this is the ordinary state of the screen
/// rather than a scrolled-to one.
#[test]
fn the_row_across_the_bottom_edge_is_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 {
t = scrolled(&mut h, &screen, 37.0, t);
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen");
assert!(
last.1 >= list.bot_right.y - 0.5,
"a band of {:.1}px above the composer belongs to no row",
list.bot_right.y - last.1,
);
assert!(
last.0 < list.bot_right.y,
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
{:.1}",
last.0,
list.bot_right.y,
);
}
}
/// Panning past the first row settles *on* it rather than beyond it. The
/// list is scrolled far further back than the fixture is long, which is
/// what a hard fling toward the top does; before the clamp existed it
/// stayed wherever that left it -- the phone's "black from the header
/// down", and a whole blank screen in `iris`'s own
/// `fling_toward_the_start_stops_at_the_first_row`.
#[test]
fn scrolling_past_the_first_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, 100_000.0, t);
}
// No settling frame on purpose: the draw that discovers the gap gives
// it back inside that same frame (`LazySpan::overscroll_gap`), so the last
// frame `scrolled` drew is already flush with the first row. Adding
// one here would hide a regression to the old next-frame correction.
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("the first row is on screen");
assert!(
(first.0 - list.top_left.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
first.0 - list.top_left.y,
);
}
/// The same clamp at the other end, which is where Iris met it second
/// ("you shouldn't be able to scroll below the bottom (or above top)").
/// The list opens flush with its newest row, so this drags *forward* off
/// the end of the content and back.
#[test]
fn scrolling_past_the_last_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..20 {
t = scrolled(&mut h, &screen, -100_000.0, t);
}
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("the last row is on screen");
assert!(
(last.1 - list.bot_right.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
blank",
list.bot_right.y - last.1,
);
}
+21
View File
@@ -0,0 +1,21 @@
# A finger flick the shape Iris's phone delivers one, from
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
# as DOWN, one or two MOVEs and UP inside a few frames, with the
# intermediate positions batched inside those MOVEs as historical
# samples (~4ms apart, the touch digitiser's own rate) rather than
# arriving as separate events. Each line here is one such sample, which
# is exactly what `IrisViewPeer::on_touch_event` replays through the
# sensors one at a time -- so the whole gesture is 20ms and five
# samples, and the velocity has to come out of *those*.
#
# Downward (increasing y) on purpose: the screen opens pinned to the
# newest end, so a flick the other way has nothing left to scroll to and
# the fling clamps on its first tick -- a pass that would prove nothing.
# Coordinates are physical pixels on a 1080x2424 surface.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 up 540 1196
+11
View File
@@ -0,0 +1,11 @@
# `flick-120hz.touch` to the sample, with the platform taking the gesture
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
# is what the swipe up from the bottom edge to leave the app delivers
# after its moves. Nothing may follow from it: no tap, no selection and,
# the one that showed on Iris's phone, no fling.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 cancel 540 1196
+11
View File
@@ -0,0 +1,11 @@
# A long-press then a drag across the text: held past LONG_PRESS
# (500ms) without moving, which is what starts a selection rather than a
# pan, then dragged sideways so the selection actually covers
# something. A press alone leaves a collapsed caret and no selected
# text (`Selection::begin`), which is why this file does not stop at the
# hold.
0 down 300 1000
520 move 300 1000
560 move 700 1000
600 move 900 1000
640 up 900 1000
+5
View File
@@ -0,0 +1,5 @@
# The case the flick had no reason to touch: a press and release in one
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
# tap -- no pan, no velocity, nothing moved.
0 down 540 1000
80 up 540 1000