Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
+10
-66
@@ -1,26 +1,10 @@
|
||||
# The app: everything that is about *this product* rather than about the UI
|
||||
# framework it draws with. One package, because splitting it was buying
|
||||
# nothing -- see `docs/RUST.md`'s "One app crate" for the account. In short:
|
||||
# `client` (the REST/SSE clients, the transcript cache and fold, the
|
||||
# highlighter) and `ui` (the screens, in iris widgets) only ever ship
|
||||
# together, and the three entry points below are three faces of one binary
|
||||
# rather than three programs.
|
||||
#
|
||||
# `iris/` is the framework and knows nothing about any of this; the
|
||||
# dependency runs one way, and a widget or a colour appearing here that is
|
||||
# not about a session, a transcript or a setup belongs there instead
|
||||
# (AGENTS.md).
|
||||
# Product code lives here; reusable UI belongs in `iris/`.
|
||||
[package]
|
||||
name = "ai-app"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# `cdylib` is the Android face -- both the iris app (`android-project/`,
|
||||
# `System.loadLibrary("ai_app")`) and the JNI bridge the Kotlin shell in
|
||||
# `app/shellApp` calls (the `shell` feature). `rlib` is what the desktop
|
||||
# binary, the examples and `tests/` link against. One package produces one
|
||||
# library artifact, so the two Android apps share a `.so` name and pick
|
||||
# what goes in it with features rather than with a second crate.
|
||||
# Android loads the cdylib; desktop, examples, and tests link the rlib.
|
||||
[lib]
|
||||
name = "ai_app"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -39,106 +23,67 @@ name = "phone"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[dependencies]
|
||||
# The event model, shared with `server/` so the two agree by construction.
|
||||
# It stays a crate of its own at the repo root for exactly that reason:
|
||||
# it is the contract between this app and the backend, not app code.
|
||||
event-model = { path = "../event-model" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# `float_roundtrip` for the same reason `server/` sets it -- AGENTS.md's
|
||||
# "Things that have bitten". `raw_value` for the transcript cache.
|
||||
# Transcript lines must retain exact float values and raw JSON bytes.
|
||||
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
pulldown-cmark = "0.13.4"
|
||||
base64 = "0.23"
|
||||
log = { version = "0.4.34", features = ["std"] }
|
||||
|
||||
# The UI framework. Optional so `--no-default-features --features shell`
|
||||
# builds the Kotlin shell's JNI bridge without linking wgpu, parley and
|
||||
# the rest of a renderer into an APK that draws with Compose.
|
||||
# Optional so the Compose shell does not link the renderer.
|
||||
iris = { path = "../iris", optional = true }
|
||||
# iris's own tabs demo, kept runnable on Android through this project's
|
||||
# Gradle app (the `tabs-screen` feature). The dependency direction is the
|
||||
# right way round: the app may reach into the framework's example widget
|
||||
# tree, never the reverse.
|
||||
tabs-ui = { path = "../iris/tabs-ui", optional = true }
|
||||
jni = { version = "0.22", optional = true }
|
||||
# `bench` only: `libc` for the process CPU-time and RSS samples, `tokio`
|
||||
# for the run's own timer.
|
||||
libc = { version = "0.2.189", optional = true }
|
||||
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
winit = "0.30.13"
|
||||
|
||||
# Pinned to the exact commit RUST.md's E1 measured on this emulator; see
|
||||
# `iris/Cargo.toml`'s copy of this pin for what advancing it costs.
|
||||
# Keep this pin synchronized with `iris/Cargo.toml`.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||
android_logger = "0.15.1"
|
||||
|
||||
[features]
|
||||
default = ["screens", "fixture"]
|
||||
# The iris half: `src/ui` and everything that draws. Off for the Kotlin
|
||||
# shell's bridge, which is JNI and `src/client` only.
|
||||
screens = ["dep:iris"]
|
||||
# `src/ui/fixture.rs` and the harness tests that drive it. Default-on so
|
||||
# `cargo test` covers them; `build-apk.sh` passes `--no-default-features`
|
||||
# so an APK carries the 1.9 MB fixture only when it asked for `bench`.
|
||||
# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture.
|
||||
fixture = ["screens"]
|
||||
# The two Android widget trees, on the same axis: a build picks one.
|
||||
transcript-screen = ["screens"]
|
||||
tabs-screen = ["screens", "dep:tabs-ui"]
|
||||
# P0's iris half (docs/RUST.md): the fixture screen with a "Run benchmark"
|
||||
# control, driving the same scroll loop and streaming phase the Compose
|
||||
# bench build type does.
|
||||
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
|
||||
# The JNI bridge `app/shellApp` calls -- notifications, the share target
|
||||
# and the Keystore-sealed settings.
|
||||
shell = ["dep:jni"]
|
||||
# See `iris/Cargo.toml`'s feature of the same name. Never for a phone.
|
||||
force-gles = ["screens", "iris/force-gles"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.53.1", features = ["rt", "time"] }
|
||||
|
||||
# The Android builds, kept off `release`/`dev` so a desktop build is not
|
||||
# also optimised for size and unwinding is not also turned off for the
|
||||
# tests. `build-apk.sh` passes `--profile android-release`.
|
||||
# APK builds select these profiles explicitly.
|
||||
[profile.android-release]
|
||||
inherits = "release"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
# **Speed, not size** (2026-09-09). This was `"s"`, chosen when the
|
||||
# question was why the APK was double the Compose one -- but that was
|
||||
# measured in bytes only, and `"s"` costs the loop vectorisation and
|
||||
# inlining a renderer runs on. Measured with
|
||||
# `scripts/rigs/ui-profile`'s `frame_profile.rs`, the same warm fling eight
|
||||
# times over:
|
||||
# iris's own per-frame work is p90 0.15ms / p99 0.42ms at `"s"` and
|
||||
# p90 0.09ms / p99 0.26ms at `3`, so about a third of the CPU half of a
|
||||
# scrolling frame was being paid for 1.9 MB of download. The same
|
||||
# argument the table in docs/RUST.md gives for refusing `"z"`, applied one
|
||||
# level further up.
|
||||
# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus
|
||||
# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost.
|
||||
opt-level = 3
|
||||
|
||||
[profile.android-dev]
|
||||
inherits = "dev"
|
||||
panic = "abort"
|
||||
|
||||
# Same reasoning as `iris/Cargo.toml`'s copy: full DWARF in every test
|
||||
# binary is what made `cargo test` here write tens of gigabytes.
|
||||
# Full DWARF in each renderer-linked test binary writes tens of gigabytes.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# The headless harness tests (`iris::harness`, no window and no GPU) all
|
||||
# open the bench fixture, so they say so rather than failing to compile
|
||||
# when it is off.
|
||||
[[test]]
|
||||
name = "catch_a_fling"
|
||||
required-features = ["fixture"]
|
||||
@@ -162,4 +107,3 @@ required-features = ["fixture"]
|
||||
[[test]]
|
||||
name = "top_edge"
|
||||
required-features = ["fixture"]
|
||||
|
||||
+4
-72
@@ -7,52 +7,13 @@ import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
|
||||
/**
|
||||
* This app's own recent log, exposed on the device.
|
||||
*
|
||||
* Iris runs these builds on a phone with no {@code adb}, and Android
|
||||
* forbids one app reading another's {@code logcat} -- so nothing outside
|
||||
* this process can recover what it wrote. The process already keeps a
|
||||
* bounded copy of its log (Rust: {@code client_core::log_ring}); this
|
||||
* hands it to Dev Updater, which is on the same phone, so it needs no
|
||||
* tunnel, no token and no second enrolment.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
|
||||
public final class DevLogProvider extends ContentProvider {
|
||||
static {
|
||||
// The provider is created before any activity, so it cannot rely
|
||||
// on MainActivity's own load. Loading twice is a no-op.
|
||||
// A provider can start the process without creating MainActivity.
|
||||
System.loadLibrary("ai_app");
|
||||
}
|
||||
|
||||
/** Matches {@link #nativeLinesSince}'s flat answer. Both sides say it once. */
|
||||
private static final int FIELDS_PER_LINE = 5;
|
||||
|
||||
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||
@@ -63,33 +24,16 @@ public final class DevLogProvider extends ContentProvider {
|
||||
|
||||
private UriMatcher matcher;
|
||||
|
||||
/** Every held line, {@link #FIELDS_PER_LINE} strings each, oldest first. */
|
||||
private static native String[] nativeLinesSince(long since);
|
||||
|
||||
/** Three strings: held, dropped, newest sequence. */
|
||||
private static native String[] nativeStatus();
|
||||
|
||||
/**
|
||||
* Tells the Rust side which authority this build registered under, so
|
||||
* the diagnostics pane can name somewhere a reader can actually query
|
||||
* -- and so "declared but never created" is a state it can say. Only
|
||||
* the provider knows it was instantiated; Android creates one lazily.
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
// The provider may be the process's only component, so it must supply
|
||||
// the files directory normally initialized by MainActivity.
|
||||
private static native void nativeReady(String authority, String filesDir);
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
// The authority is not a constant here: it is derived from this
|
||||
// build's applicationId, so the bench package and the ordinary one
|
||||
// do not share one. Read back from the manifest rather than
|
||||
// recomposed, so there is one answer to what it is.
|
||||
String authority = getContext().getPackageName() + ".devlog";
|
||||
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||
matcher.addURI(authority, "lines", LINES);
|
||||
@@ -111,19 +55,10 @@ public final class DevLogProvider extends ContentProvider {
|
||||
case STATUS:
|
||||
return status();
|
||||
default:
|
||||
// Null rather than an exception: an unknown path is a
|
||||
// reader asking for something this app does not have, and
|
||||
// the contract's own answer for that is no cursor.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code ?since=} as a number, or 0 for a reader starting from the
|
||||
* beginning. A value that is not a number is treated as 0 rather than
|
||||
* refused -- what a caller wants from a malformed cursor is the log,
|
||||
* not a stack trace about the query string.
|
||||
*/
|
||||
private static long sinceOf(Uri uri) {
|
||||
String since = uri.getQueryParameter("since");
|
||||
if (since == null) {
|
||||
@@ -170,9 +105,6 @@ public final class DevLogProvider extends ContentProvider {
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
// A MIME type is for something meant to be handed to another app
|
||||
// as data; these rows are read by one reader that knows the
|
||||
// columns. Saying nothing is the honest answer, not a gap.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,20 +39,7 @@ public final class IrisView extends RustView {
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the Rust side (iris/src/android/view.rs's
|
||||
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
|
||||
* drawing -- an ordinary instance method rather than a `native` one,
|
||||
* since this call is Rust reaching into Java rather than the other
|
||||
* direction. Replaces the whole activity content with plain,
|
||||
* selectable, scrollable text rather than leaving the last frame (or a
|
||||
* blank surface) on screen with no way to report what happened:
|
||||
* UI_RULES.md's "a failure is reported where it happened, and says
|
||||
* what to do next." No dialog and no styling beyond what is needed to
|
||||
* read and copy the text -- this path exists for exactly the crash it
|
||||
* replaces, so it must not depend on anything that could itself fail
|
||||
* to render.
|
||||
*/
|
||||
// This path must not depend on the renderer that failed to initialize.
|
||||
void showRendererError(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
|
||||
@@ -10,38 +10,18 @@ import android.view.WindowInsetsAnimation;
|
||||
import android.widget.FrameLayout;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||
* filling the window, running iris's tabs example through
|
||||
* iris-android-app's Rust side. Mirrors android-view's own
|
||||
* DemoActivity, plus the window-insets wiring that has no android-view
|
||||
* counterpart.
|
||||
*/
|
||||
public final class MainActivity extends Activity {
|
||||
static {
|
||||
System.loadLibrary("ai_app");
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's private directory, where the Rust side keeps its enrollment
|
||||
* (`src/enrollment.rs`). Handed over before the view is built, because
|
||||
* the client the view creates reads the enrollment as it starts.
|
||||
*/
|
||||
private static native void nativeSetFilesDir(String path);
|
||||
|
||||
/**
|
||||
* One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's
|
||||
* Enroll button opens it. Parsed and stored on the Rust side, which is
|
||||
* where the enrollment lives for the desktop app too -- nothing about
|
||||
* the link's format is known here.
|
||||
*/
|
||||
private static native void nativeEnroll(String uri);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
// Before the view: creating it starts the Rust client, which asks
|
||||
// straight away which server it is enrolled with.
|
||||
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||
handleEnrollmentIntent(getIntent());
|
||||
IrisView view = new IrisView(this);
|
||||
@@ -54,46 +34,14 @@ public final class MainActivity extends Activity {
|
||||
setContentView(layout);
|
||||
view.requestFocus();
|
||||
|
||||
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
|
||||
// `logcat` showed the platform's own IME open/resize happening
|
||||
// while `setOnApplyWindowInsetsListener` fired only once, at
|
||||
// attach, and never again for a pure keyboard toggle -- a plain
|
||||
// (non-edge-to-edge) window is only guaranteed that one initial
|
||||
// dispatch; `adjustResize` handling the IME entirely by resizing
|
||||
// the window is not itself a trigger for a fresh one. Opting into
|
||||
// edge-to-edge (a platform call, API 30+, no new dependency) is
|
||||
// what makes the system redeliver insets on every change,
|
||||
// including the ones this activity actually cares about --
|
||||
// `getSystemWindowInset*` below is unaffected by this (it has
|
||||
// always reported the raw system-bar/IME overlap regardless of
|
||||
// who consumes it), so the on-screen bars and the padding Rust
|
||||
// already derives from those four numbers are unchanged; only the
|
||||
// callback's firing became reliable.
|
||||
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
}
|
||||
|
||||
// **The keyboard's height arrives twice, over two different
|
||||
// paths, and the phone needs the second one** (Iris, 2026-09-07:
|
||||
// the emulator pushed the composer up and her Pixel did not).
|
||||
// `setOnApplyWindowInsetsListener` is the platform's *settled*
|
||||
// answer; `WindowInsetsAnimation.Callback` is the running one, and
|
||||
// an IME that animates in delivers every intermediate height
|
||||
// through the callback with the static dispatch arriving only at
|
||||
// the ends -- on some devices only at `onEnd`. Registering both
|
||||
// means neither device depends on the other's timing, and it is
|
||||
// also what makes the push-up *animate* with the keyboard rather
|
||||
// than jump when it lands.
|
||||
//
|
||||
// The two do not disagree, because they are the same call with the
|
||||
// same numbers read out of whichever `WindowInsets` is current.
|
||||
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
|
||||
// nothing keeps the ordinary dispatch running underneath.
|
||||
// `onEnd` re-reads the root's insets rather than trusting the last
|
||||
// `onProgress`: an animation interrupted mid-flight never delivers
|
||||
// its final frame, which is exactly the fault the Compose app hit
|
||||
// (AGENTS.md, "the composer can get stuck floating above the
|
||||
// bottom of the screen").
|
||||
// Static dispatch supplies settled insets; the animation callback
|
||||
// supplies intermediate IME heights. An interrupted animation may
|
||||
// omit its final progress frame, so onEnd re-reads the root insets.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
@@ -120,25 +68,14 @@ public final class MainActivity extends Activity {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A link that arrives while the activity is already up. `singleTop` is
|
||||
* not set, so this is the resumed case only -- the fresh-launch case
|
||||
* goes through `onCreate`'s `getIntent`. `setIntent` so a later
|
||||
* `getIntent` reports the one actually being acted on rather than the
|
||||
* one this activity started with.
|
||||
*/
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
// Keep getIntent() consistent with the enrollment being handled.
|
||||
setIntent(intent);
|
||||
handleEnrollmentIntent(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a VIEW intent's URI to the Rust side, which decides whether it
|
||||
* is an enrollment link -- the scheme is checked here only so a launch
|
||||
* intent (which carries no data) costs nothing.
|
||||
*/
|
||||
private static void handleEnrollmentIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
@@ -149,35 +86,13 @@ public final class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||
* place that reads these fields, so the static dispatch and the
|
||||
* animation callback above cannot come to report different things. */
|
||||
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// **Two separate answers, because they are separate questions**
|
||||
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||
// pass sent the boolean *as* the height (0 or 1) because under
|
||||
// plain `adjustResize` the window shrinks to make room and the ime
|
||||
// inset therefore measures a zero overlap by construction -- true
|
||||
// then, and no longer true now that this is an edge-to-edge window
|
||||
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
|
||||
// above for the devices below that), which is exactly the case
|
||||
// where the system stops resizing and hands the app the real
|
||||
// overlap instead. Sending 1 for it left the Rust side padding the
|
||||
// composer by one physical pixel, so the keyboard covered the bar
|
||||
// and the transcript alike.
|
||||
//
|
||||
// The visibility is still sent in its own right rather than
|
||||
// inferred from `height > 0`: the two disagree during the
|
||||
// keyboard's slide-in and -out (visible, height still climbing),
|
||||
// and "is the IME up" drives the bench's own state machine
|
||||
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||
// reading as "closed" is a miscount.
|
||||
// Visibility and height disagree during IME animation, so neither
|
||||
// can be inferred from the other.
|
||||
int imeBottom = 0;
|
||||
int imeVisible = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
|
||||
+2
-6
@@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
public abstract class RustView extends SurfaceView
|
||||
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
||||
// with one deliberate change: `protected` rather than package-private, so a
|
||||
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
|
||||
// it to the window-insets native call android-view itself has no hook for --
|
||||
// see iris/src/android/insets.rs's doc comment for why that call exists at
|
||||
// all. No other line differs from upstream.
|
||||
// Vendored from android-view bec6c62. The only local change is `protected`,
|
||||
// allowing IrisView to forward insets through this native peer.
|
||||
protected final long mViewPeer;
|
||||
final InputMethodManager mInputMethodManager;
|
||||
|
||||
|
||||
@@ -1,39 +1,6 @@
|
||||
//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed
|
||||
//! transcript screen in a phone-shaped window, for looking at.
|
||||
//!
|
||||
//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture
|
||||
//!
|
||||
//! `--phone` sets the headless sway output to the phone's own 1080x2424
|
||||
//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's
|
||||
//! phone reports (`ai_app::ui::fixture::PHONE_SCALE`) rather than the
|
||||
//! desktop's 1.0 -- same screen, same fixture and the same folding as
|
||||
//! the Android bench and the headless tests, so what differs between a
|
||||
//! screenshot here and one from the phone is the renderer, never the
|
||||
//! data.
|
||||
//!
|
||||
//! `--message TEXT` (through `RUN_HEADLESS_ARGS`) starts with that text
|
||||
//! already in the composer, `\n` for a newline -- the composer's grown
|
||||
//! and overflowing states are otherwise unreachable here, since this
|
||||
//! window has no keyboard to type into (UI_RULES.md's "check the states
|
||||
//! you can't see by default"). `--typed TEXT` *enters* the same text
|
||||
//! instead, one character per 100ms: laying the composer out from
|
||||
//! scratch and growing one already on screen are different cases, and
|
||||
//! only the second reproduced the caret landing in the bar's padding
|
||||
//! (decided 2026-09-08).
|
||||
//!
|
||||
//! No server: `transcript-fixture` embeds the transcript. Colour,
|
||||
//! spacing, type and anything a person has to *see* is answered here;
|
||||
//! anything with an assertion behind it belongs in `tests/
|
||||
//! phone_screen.rs` one layer down.
|
||||
|
||||
use iris::prelude::*;
|
||||
use winit::{dpi::PhysicalSize, window::WindowAttributes};
|
||||
|
||||
/// The `--ime PX` argument: the bottom inset a keyboard would report,
|
||||
/// applied after the first frame the way Android's `on_insets_changed`
|
||||
/// does. The composer's keyboard-open layout is otherwise unreachable
|
||||
/// here, and it is where its mask went wrong before (see
|
||||
/// `ActiveData::own_mask`).
|
||||
fn ime_argv() -> Option<f32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -44,8 +11,6 @@ fn ime_argv() -> Option<f32> {
|
||||
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() {
|
||||
@@ -56,12 +21,6 @@ fn message_argv() -> Option<String> {
|
||||
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() {
|
||||
@@ -127,9 +86,6 @@ impl DefaultAppState for Client {
|
||||
}
|
||||
Some(opened.screen)
|
||||
}
|
||||
// On screen rather than a panic: this window exists to be
|
||||
// looked at, and "the fixture stopped folding" is something
|
||||
// to read, not a process that vanished (UI_RULES.md).
|
||||
Err(message) => {
|
||||
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
||||
.color(Color::WHITE)
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
//! I5's desktop proof: the transcript screen built from synthetic
|
||||
//! `ai_app::client::transcript_fold` rows (no network, no server -- see
|
||||
//! `lib.rs`'s doc for why `transcript-ui` itself never fetches anything),
|
||||
//! run via `iris/run-headless.sh transcript -- -p transcript-ui` for a
|
||||
//! screenshot on the winit backend, or `cargo run --example transcript -p
|
||||
//! transcript-ui` with a real compositor.
|
||||
//!
|
||||
//! The rows exercise every one of the seven "hard to get back" behaviours
|
||||
//! this box's markdown/selection work is meant to show: a heading, bold,
|
||||
//! italic, an inline code span, a link, a fenced code block (rich inline
|
||||
//! text), a multi-message conversation (bottom-anchored virtualised list),
|
||||
//! and a three-call tool run (collapsed by default -- tap it, or drive it
|
||||
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
||||
//! hold-the-edge expand).
|
||||
|
||||
use ai_app::client::QuestionOption;
|
||||
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use iris::prelude::*;
|
||||
@@ -44,17 +29,10 @@ fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
|
||||
})
|
||||
}
|
||||
|
||||
/// One tool call. `result` is `None` for a call with no result yet and
|
||||
/// `Some((output, failed))` for one that answered.
|
||||
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
|
||||
tool_call_in("run1", id, tool, input, result)
|
||||
}
|
||||
|
||||
/// The same, in a named run. Two runs in one transcript must not share a
|
||||
/// `run_id`: it is the row's identity in the list (`row::row_key`), and
|
||||
/// two rows under one key is the duplicate-key fault AGENTS.md's
|
||||
/// "Importing" section describes. Here it made two rows swap cached
|
||||
/// heights and draw at each other's boxes.
|
||||
fn tool_call_in(
|
||||
run: &str,
|
||||
id: &str,
|
||||
@@ -76,7 +54,6 @@ fn tool_call_in(
|
||||
}
|
||||
}
|
||||
|
||||
/// A call stopped on the reader: one unanswered permission question.
|
||||
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||
@@ -104,8 +81,6 @@ fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||
call
|
||||
}
|
||||
|
||||
/// Longer than the card's own cap, so the "Show all N lines" control is on
|
||||
/// screen in the expanded shot.
|
||||
fn long_output() -> String {
|
||||
(0..200)
|
||||
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
|
||||
@@ -125,12 +100,6 @@ fn synthetic_rows() -> Vec<FoldedRow> {
|
||||
false,
|
||||
"# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
||||
),
|
||||
// Every state a tool card has to draw, in one run (P1b): a call
|
||||
// that worked, one the tool reported as failed, one whose result
|
||||
// never arrived, and one still running. The last two look the same
|
||||
// in the events -- an empty output and `done: false` -- and are
|
||||
// told apart only by whether the session is still working, which
|
||||
// is what `TranscriptScreen::set_session_working` says.
|
||||
FoldedRow::Tools(vec![
|
||||
tool_call(
|
||||
"t1",
|
||||
@@ -149,9 +118,6 @@ fn synthetic_rows() -> Vec<FoldedRow> {
|
||||
),
|
||||
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||
]),
|
||||
// A lone call is a card too rather than a group of one -- and this
|
||||
// one carries the kilobyte output a collapsed card must not lay
|
||||
// out.
|
||||
FoldedRow::Single(tool_call(
|
||||
"t5",
|
||||
"Bash",
|
||||
@@ -159,19 +125,10 @@ fn synthetic_rows() -> Vec<FoldedRow> {
|
||||
Some((&long_output(), false)),
|
||||
)),
|
||||
msg(6, true, "Looks good, thanks!"),
|
||||
// Every block kind `ai_app::client::markdown_blocks` names, in one
|
||||
// row, so P1a's appearance can be looked at against the Compose
|
||||
// app's without a server (docs/RUST.md's P1a box). The heading,
|
||||
// paragraph, fence and table are the *same source* the bench
|
||||
// fixture carries (`app/bench-fixture/generate.py`), so the two
|
||||
// screenshots differ only in the renderer; the list and the quote
|
||||
// are extra, because the fixture has neither.
|
||||
msg(7, false, BLOCK_SAMPLER),
|
||||
]
|
||||
}
|
||||
|
||||
/// One of each markdown block, for the P1a screenshot pair. See
|
||||
/// [`synthetic_rows`].
|
||||
const BLOCK_SAMPLER: &str = "\
|
||||
## What changed
|
||||
|
||||
@@ -181,7 +138,6 @@ iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||
|
||||
```rust
|
||||
fn fold_event(items: Vec<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
|
||||
@@ -208,10 +164,6 @@ impl DefaultAppState for Client {
|
||||
_: 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 {
|
||||
@@ -219,11 +171,6 @@ impl DefaultAppState for Client {
|
||||
text: "clear".into(),
|
||||
}),
|
||||
);
|
||||
// A second run at the live end, so the *running* state is on
|
||||
// screen too. It cannot share a row with "no result": the two are
|
||||
// the same events and are told apart only by whether the session
|
||||
// is working, which is a property of the row rather than of the
|
||||
// call (`TranscriptScreen::set_session_working`).
|
||||
screen.push_row(
|
||||
rsc,
|
||||
&FoldedRow::Tools(vec![
|
||||
@@ -242,12 +189,6 @@ impl DefaultAppState for Client {
|
||||
Some(("error: unused variable `x`", true)),
|
||||
),
|
||||
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||
// Waiting on a permission, so this card is drawn *open*
|
||||
// whatever the reader last chose -- the command is the
|
||||
// thing being decided, and a row saying only "Bash"
|
||||
// cannot be decided on. It is also how the expanded card
|
||||
// (input block, output block, timeout) gets into the
|
||||
// screenshot without a finger.
|
||||
asking(
|
||||
"t9",
|
||||
"Bash",
|
||||
@@ -256,9 +197,6 @@ impl DefaultAppState for Client {
|
||||
]),
|
||||
);
|
||||
screen.set_session_working(rsc, true);
|
||||
// The expanded picture has no other way to be looked at on a
|
||||
// machine with no display and no finger -- see `run-headless.sh`
|
||||
// and docs/RUST.md's P1b box.
|
||||
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||
assert!(
|
||||
screen.expand_tail_tools(rsc, true),
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs).
|
||||
# The pin is dated rather than "nightly" because the const-traits feature set
|
||||
# changes shape between nightlies: on 2026-09-04 the vendored January tree would
|
||||
# not parse at all, because `impl const Trait for T` had become
|
||||
# `const impl Trait for T`. A rolling channel turns that into a build that
|
||||
# breaks unattended on whatever machine Dev Updater happens to build on.
|
||||
# Advance this deliberately, with the feature list in RUST.md's I0b.
|
||||
[toolchain]
|
||||
channel = "nightly-2026-09-03"
|
||||
channel = "nightly"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||
@@ -1,17 +1,6 @@
|
||||
//! The platform half of this app's logging: what
|
||||
//! `crate::client::log_ring` needs that only Android can supply, which is
|
||||
//! `android_logger` as the logger to forward to and nothing else.
|
||||
//!
|
||||
//! Everything general -- the ring, its bounds, the `log::Log` backend --
|
||||
//! is in `client-core`, shared with the desktop app (AGENTS.md's sharing
|
||||
//! rule).
|
||||
//!
|
||||
//! **Why an app carries its own log at all**: Iris tests these builds on a
|
||||
//! GrapheneOS phone with no `adb`, and Android forbids one app reading
|
||||
//! another's `logcat`. Nothing outside this process can recover what it
|
||||
//! wrote, so the process keeps a copy -- and hands it to Dev Updater on
|
||||
//! the same phone through `devlog`'s `ContentProvider`. See
|
||||
//! decided 2026-09-07.
|
||||
|
||||
use crate::client::log_ring::{self, LogRing};
|
||||
|
||||
@@ -43,23 +32,11 @@ pub fn install(max_level: log::LevelFilter) {
|
||||
install_panic_hook();
|
||||
}
|
||||
|
||||
/// The process's ring -- what `Copy report` appends, what the diagnostics
|
||||
/// pane counts, and what `devlog`'s provider hands to Dev Updater.
|
||||
pub fn ring() -> &'static LogRing {
|
||||
log_ring::process_ring()
|
||||
}
|
||||
|
||||
/// Only the bench build has a diagnostics pane to put this in; the
|
||||
/// transcript build's screen is the app's own and has no room for a
|
||||
/// readout. Gated rather than left dead so the build stays warning-clean.
|
||||
#[cfg(feature = "bench")]
|
||||
/// Two lines for the diagnostics pane: how much of this app's log is held,
|
||||
/// and where it can be read from.
|
||||
///
|
||||
/// The second names the provider's authority rather than saying "logging
|
||||
/// is on", so a screenshot of this pane is enough to tell whether the
|
||||
/// contract is live and which package's log it is -- the bench build and
|
||||
/// the ordinary one have different ones.
|
||||
pub fn diagnostics_line() -> String {
|
||||
let where_to_read = match crate::android::devlog::authority() {
|
||||
Some(authority) => format!("devlog provider: content://{authority}"),
|
||||
@@ -88,28 +65,11 @@ const CRASH_FILE: &str = "last-panic.txt";
|
||||
/// unbounded paste would evict the run that is actually being watched.
|
||||
const CRASH_CONTEXT_LINES: usize = 80;
|
||||
|
||||
/// The target the replayed context lines carry, so a reader can tell a
|
||||
/// line from the run that died from one this run wrote. They keep their
|
||||
/// original timestamp and level inside the text, which is why the level
|
||||
/// they are re-pushed at is not meaningful and the target has to be.
|
||||
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||
|
||||
static CRASH_PATH: std::sync::OnceLock<std::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.
|
||||
/// Copies aborting panics into the device-readable log ring.
|
||||
fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
@@ -117,8 +77,6 @@ fn install_panic_hook() {
|
||||
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
|
||||
None => "an unknown location".to_string(),
|
||||
};
|
||||
// `info`'s own `Display` repeats the location and a newline;
|
||||
// the payload alone keeps this to the one line the ring wants.
|
||||
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
|
||||
let line = format!("iris panic at {where_at}: {message}");
|
||||
log::error!("{line}");
|
||||
@@ -128,8 +86,6 @@ fn install_panic_hook() {
|
||||
// `set_crash_dir`.
|
||||
let context = ring()
|
||||
.try_tail_text(CRASH_CONTEXT_LINES)
|
||||
// Said rather than left empty, so "the ring was locked as
|
||||
// we died" cannot be read as "nothing had been logged".
|
||||
.unwrap_or_else(|| {
|
||||
"(the log ring was locked as this run died; no context)".to_string()
|
||||
});
|
||||
@@ -167,13 +123,6 @@ pub fn set_crash_dir(dir: &std::path::Path) {
|
||||
|
||||
/// Puts a previous run's report back in the ring: its context lines in
|
||||
/// the order they happened, then the panic itself.
|
||||
///
|
||||
/// Chronological, so the Runtime tab reads as one story -- the lines that
|
||||
/// led to the crash, then the crash, then this run. The context goes in
|
||||
/// through `LogRing::push` rather than through `log::info!` so it is not
|
||||
/// stamped with this run's clock: each line already carries the time and
|
||||
/// level it was written at, and [`PREVIOUS_RUN_TARGET`] is what says
|
||||
/// whose run it was.
|
||||
fn replay_crash(report: &str) {
|
||||
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
|
||||
for line in context.lines().filter(|line| !line.is_empty()) {
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
|
||||
//! the same fixture, scroll loop and streaming phase the Compose `bench`
|
||||
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
|
||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||
//! that measures the renderer rather than the data or the network.
|
||||
//!
|
||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||
//! network half replaced by the checked-in fixture. Reading that fixture
|
||||
//! and folding it into a screen is **`transcript-fixture`'s** job, not
|
||||
//! this file's -- the same crate the headless harness and the
|
||||
//! phone-shaped desktop window open, so all three measure one screen
|
||||
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
|
||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||
//! this file exists to measure -- see docs/RUST.md's P0 box for the
|
||||
//! before/after report.
|
||||
|
||||
use crate::android::bench_jni::PlatformHandle;
|
||||
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||
@@ -27,35 +8,16 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||
/// implement the identical four phases -- see that box before changing any
|
||||
/// constant here, since a mismatch would make the two reports stop
|
||||
/// measuring the same thing while still looking like they do.
|
||||
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||
const STREAM_SECONDS: u64 = 20;
|
||||
|
||||
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
|
||||
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
|
||||
/// exact line even though the swipe loop it names no longer runs there
|
||||
/// either (the fling phase replaced it); nothing here drives an actual
|
||||
/// swipe with these any more.
|
||||
const LEGACY_CYCLES: usize = 6;
|
||||
|
||||
/// Fling phase (v2): a real fling through `Scroll::fling`, not a tween --
|
||||
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
|
||||
/// tween can never exceed the distance/time it is given while a real
|
||||
/// fling decays from an initial velocity the way a finger flick does.
|
||||
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
|
||||
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||
const FLING_COUNT: usize = 8;
|
||||
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||
const FLING_PAUSE_MS: u64 = 300;
|
||||
|
||||
/// Type phase (v2): long, multisyllabic words so the composer actually
|
||||
/// wraps and the transcript above it is pushed upward, typed and deleted
|
||||
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
|
||||
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
|
||||
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
||||
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
||||
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
||||
@@ -66,72 +28,29 @@ keyboard-adjacent box, which is exactly what a real reader typing a long message
|
||||
happening now!!!";
|
||||
const TYPE_CHAR_MS: u64 = 50;
|
||||
|
||||
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
|
||||
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
|
||||
/// `KEYBOARD_HIDE_WAIT_MS`.
|
||||
const KEYBOARD_CYCLES: usize = 5;
|
||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||
|
||||
/// How often this file *asks a question of* the running app -- polls for
|
||||
/// a `ctx.update` closure's answer, or for a fling to have settled.
|
||||
///
|
||||
/// It is not an animation cadence and nothing on screen moves at this
|
||||
/// rate: the frame loop advances animations once per frame at the
|
||||
/// display's own refresh (`UiData::tick_animations`). It used to be both,
|
||||
/// and that is the defect Iris reported on 2026-09-08 -- see
|
||||
/// `wait_for_fling_settle`.
|
||||
const POLL_MS: u64 = 16;
|
||||
|
||||
/// How much of the screen a *filled* benchmark report may take before it
|
||||
/// scrolls instead of growing -- roughly a third of a phone screen, the
|
||||
/// share the pane used to reserve unconditionally. An empty report takes
|
||||
/// nothing at all; see `new`'s comment at the tree it is used in.
|
||||
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||
|
||||
pub struct BenchClient {
|
||||
ui_state: AndroidUiState,
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
report_display: WeakWidget<TextEdit>,
|
||||
/// The top button row, in a `WidgetPtr` slot rather than added
|
||||
/// directly (like `content`) so `on_insets_changed` can swap in a
|
||||
/// version padded for the status bar once insets are known -- RUST.md's
|
||||
/// P0 box, "the status-bar inset is not applied," found the row sitting
|
||||
/// directly under it because nothing here read `insets().top` at all.
|
||||
top_bar: WeakWidget<WidgetPtr>,
|
||||
screen: Option<crate::ui::TranscriptScreen>,
|
||||
items: Vec<TranscriptItem>,
|
||||
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||
/// clone, kept here only as the source a second run would need (the
|
||||
/// button can be pressed more than once; `running` just stops overlap,
|
||||
/// not repeat).
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
platform: Option<Arc<PlatformHandle>>,
|
||||
last_report: Option<String>,
|
||||
running: bool,
|
||||
/// The keyboard phase's own confirmation channel -- updated from
|
||||
/// `on_insets_changed` (the platform's own answer for whether the IME
|
||||
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
|
||||
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
|
||||
/// `ctx.update`, since neither side needs the widget tree for this.
|
||||
ime_state: Arc<Mutex<ImeState>>,
|
||||
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
||||
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
||||
/// first where it is not, so opening the keyboard fires this once
|
||||
/// rather than on every insets update while it stays open (a rotation
|
||||
/// or a status-bar change with the keyboard already up would otherwise
|
||||
/// re-fire it).
|
||||
keyboard_was_visible: bool,
|
||||
/// The status-bar inset `top_bar` was last padded by -- see
|
||||
/// `on_insets_changed`'s own comment for why this guards the rebuild.
|
||||
last_top_pad: f32,
|
||||
}
|
||||
|
||||
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
|
||||
/// count real 0->visible / visible->0 transitions `on_insets_changed`
|
||||
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
|
||||
/// present an inferred value as a measured one. `run_keyboard_phase` reads
|
||||
/// the counters before and after asking for a toggle and calls it
|
||||
/// confirmed only if the count moved.
|
||||
#[derive(Default)]
|
||||
struct ImeState {
|
||||
visible: bool,
|
||||
@@ -157,10 +76,6 @@ fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
.any()
|
||||
}
|
||||
|
||||
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
|
||||
/// the syscall itself fails, which UI_RULES.md's "never present an
|
||||
/// inferred value as a measured one" says to keep apart from a real (and
|
||||
/// here, impossible) zero.
|
||||
fn process_cpu_ms() -> Option<u64> {
|
||||
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||
// initialises on success; on failure it is never read.
|
||||
@@ -175,9 +90,6 @@ fn process_cpu_ms() -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
|
||||
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
|
||||
/// two reports' numbers mean the same thing.
|
||||
fn peak_rss_kb() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.ok()?
|
||||
@@ -192,12 +104,6 @@ fn battery_line(samples: &[i32]) -> String {
|
||||
return " battery current: unavailable on this device".to_string();
|
||||
}
|
||||
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||
// `min`/`max` are guarded by the `is_empty` check above, three lines
|
||||
// up -- pairing the `Option` unwraps with the emptiness check right
|
||||
// here (rather than two statements apart, with `mean` in between
|
||||
// reading the same slice) is what keeps a future reorder from
|
||||
// separating the guard from what it protects (review, 2026-09-06
|
||||
// finding 7).
|
||||
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||
unreachable!("samples is non-empty, checked above");
|
||||
};
|
||||
@@ -226,22 +132,6 @@ impl AndroidAppState for BenchClient {
|
||||
let top_bar = WidgetPtr::new().add(rsc);
|
||||
let controls = bench_controls(rsc, 0.0);
|
||||
top_bar(rsc).set(controls);
|
||||
// The report pane is sized to whatever report it is holding, not
|
||||
// to a share of the window: `rest(1)` here reserved a third of
|
||||
// the screen for an *empty* `TextEdit` at every launch, which is
|
||||
// what Iris's 2026-09-06 11:39 phone report described as "the app
|
||||
// does not start with keyboard spacing correct" -- the composer
|
||||
// two thirds down with black below it, nothing to do with the IME
|
||||
// inset (measured: `iris insets:` reports bottom=63 ime_bottom=0
|
||||
// at launch, while the `Message` field's own box sat 789px above
|
||||
// the bottom of a 2282px surface -- exactly this pane's third).
|
||||
// Capped and scrollable so a long report cannot take the screen
|
||||
// back over, the same idiom `composer.rs` uses for the field.
|
||||
// Above the transcript, not below it: the report is what the
|
||||
// header's own "Run benchmark" button produces (UI_RULES.md --
|
||||
// results appear where the action was started), and a pane under
|
||||
// the composer would eat the navigation-bar clearance
|
||||
// `set_bottom_inset` gives it.
|
||||
let tree = (
|
||||
top_bar,
|
||||
report_display
|
||||
@@ -254,10 +144,6 @@ impl AndroidAppState for BenchClient {
|
||||
.any();
|
||||
ui_state.set_root(tree);
|
||||
|
||||
// Startup log line (RUST.md's P0 box, "log once at startup ... the
|
||||
// number of font families found, the default family resolved"):
|
||||
// what font discovery actually found on this device, before
|
||||
// anything is drawn.
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
log::info!(
|
||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||
@@ -310,47 +196,6 @@ impl AndroidAppState for BenchClient {
|
||||
false
|
||||
}
|
||||
|
||||
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
||||
/// field comment. Rebuilds the row rather than mutating a stored
|
||||
/// `Padding` in place, since nothing here holds a handle to one --
|
||||
/// but **only when `insets.top` actually changed**: this callback
|
||||
/// also fires on every `ime_bottom` change (the keyboard sliding
|
||||
/// in/out fires several intermediate insets updates), which has
|
||||
/// nothing to do with the status bar, and rebuilding on every one of
|
||||
/// those was the root cause of a real bug (found on Iris's phone,
|
||||
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
|
||||
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
|
||||
/// `needs_redraw.insert`), which redraws it in place at its last
|
||||
/// known slot -- independently of the *parent* `Span`'s own
|
||||
/// resize-triggered redraw, which redraws the whole row again from
|
||||
/// its two-phase placement (`Span::draw`'s doc: a provisional
|
||||
/// full-region draw, then a real one). A `.set()` landing between
|
||||
/// those two phases left one dirty-widget redraw's primitives
|
||||
/// un-freed while the `Span`-driven redraw drew its own copy,
|
||||
/// producing two live copies of the same three buttons in one frame
|
||||
/// -- one at the header's real slot, one wherever `Span`'s
|
||||
/// provisional phase happened to leave it (visibly inside the
|
||||
/// transcript area), each still holding its own working `on(click)`
|
||||
/// handlers, so a tap meant for whatever was under the stray copy
|
||||
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
|
||||
/// depends on changed removes the repeated `.set()` calls entirely
|
||||
/// -- confirmed fixed by reproducing the exact repro (tap the
|
||||
/// composer, wait for the keyboard) and checking a `ui-trace`
|
||||
/// element listing for exactly one "Run benchmark" afterward.
|
||||
///
|
||||
/// Also two things downstream of the same `ime_bottom` transition:
|
||||
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
||||
/// doc -- the platform's own answer for whether the IME actually
|
||||
/// opened or closed, rather than assumed from having called
|
||||
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
|
||||
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
|
||||
/// surface is exactly the case a previous commit found wiped text,
|
||||
/// and Iris needs a way to get a report off the phone even if that
|
||||
/// (or some other keyboard-triggered regression) is still happening
|
||||
/// on the build she is holding -- `capture_keyboard_diagnostics`
|
||||
/// below fires ~500ms after the keyboard becomes visible, once per
|
||||
/// keyboard opening, and shows its report in a plain overlay view
|
||||
/// that draws independently of whatever iris itself is doing.
|
||||
fn on_insets_changed(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
@@ -362,12 +207,6 @@ impl AndroidAppState for BenchClient {
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
}
|
||||
|
||||
// The composer bar sits directly on whichever of the IME or the
|
||||
// navigation bar is currently the bottom of usable space -- see
|
||||
// `crate::ui::composer::Composer::set_bottom_inset`'s doc.
|
||||
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
|
||||
// keyboard covers it, so the larger of the two is always the right
|
||||
// answer without needing to know which is currently showing.
|
||||
if let Some(screen) = &self.screen {
|
||||
screen
|
||||
.composer
|
||||
@@ -407,13 +246,6 @@ impl AndroidAppState for BenchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait after the keyboard becomes visible before capturing
|
||||
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
||||
/// still happening) and a couple of frames have all had time to land, per
|
||||
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
||||
/// on the way that nothing can observe" reasoning applied the other way:
|
||||
/// this wants to observe the state *after* the transition settles, not
|
||||
/// mid-flight.
|
||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
@@ -452,39 +284,8 @@ const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||
/// naming that builder's type at each call site is more machinery than a
|
||||
/// top-of-screen padding number is worth.
|
||||
///
|
||||
/// **Backed by an opaque rect the full size of the row, not just the three
|
||||
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
||||
/// build a9232ac): "the header buttons have nothing behind them and
|
||||
/// overlap the transcript text" -- before this, only each button's own
|
||||
/// `rect(...)` painted anything, so the gaps between and around them (and
|
||||
/// the status-bar strip above them) showed whatever was one layer back
|
||||
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
||||
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
||||
/// density-correct size the transcript below was already using post-P0 --
|
||||
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
||||
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
||||
/// function), and every size below moved from a bare number (physical
|
||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||
/// so the row's reserved height in the outer `Span::DOWN`
|
||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||
/// The size every label in the header row is drawn at.
|
||||
///
|
||||
/// One constant for all four rather than a number per button, because the
|
||||
/// whole row has to be sized together. Adding the trace switch made four
|
||||
/// controls too wide for one row at the size three had used (18), and an
|
||||
/// earlier pass shrank this constant to 13 to make them fit -- exactly
|
||||
/// what UI_RULES forbids ("never shrink text to make it fit": a label a
|
||||
/// different size from its neighbours elsewhere in the app for a reason
|
||||
/// the reader cannot see). The fix is [`bench_controls`]'s two rows
|
||||
/// instead, which leaves room to put this back. Whoever adds a fifth
|
||||
/// control reconsiders the row split, not this number.
|
||||
const HEADER_TEXT: f32 = 18.0;
|
||||
|
||||
/// The height of one row of header controls, in dp. `bench_controls` now
|
||||
/// stacks two of these, so this is the one number to change if a control's
|
||||
/// own padding ever changes instead of `dp(56)` and `dp(112)` needing to
|
||||
/// be kept in sync by hand.
|
||||
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
@@ -548,11 +349,6 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
// session fills the 2000-line ring in seconds, so "is it on right
|
||||
// now" is the question somebody has while looking at a log that is
|
||||
// either full of trace or has none.
|
||||
//
|
||||
// The visible text carries the state and the accessibility label does
|
||||
// not, deliberately: the label is also what `run-bench.sh` taps by
|
||||
// name, and a control that renames itself when pressed is one no
|
||||
// script can find twice.
|
||||
let tracing = iris::diagnostics::trace_enabled();
|
||||
let trace_rect = rect(if tracing {
|
||||
Color::rgb(90, 70, 30)
|
||||
@@ -576,12 +372,6 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
// Two rows rather than one: four controls at the restored `HEADER_TEXT`
|
||||
// no longer fit a 1080px-wide row (that was the shrink this replaces --
|
||||
// see the constant's own doc). Grouped by what they act on: the first
|
||||
// row starts a benchmark and copies its result; the second is the
|
||||
// diagnostics pane and the switch that decides what it will contain
|
||||
// next time.
|
||||
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
||||
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
||||
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
||||
@@ -607,12 +397,6 @@ impl BenchClient {
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
|
||||
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
|
||||
/// benchmark report uses) rather than a separate widget, so the
|
||||
/// existing "Copy report" button and clipboard path work on whichever
|
||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||
/// so it's set here too rather than adding a second copy path.
|
||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
self.report_display.edit(rsc).set(&report);
|
||||
@@ -621,11 +405,6 @@ impl BenchClient {
|
||||
|
||||
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
||||
/// switch that says so, and shows the pane that now reports it.
|
||||
///
|
||||
/// Showing the pane is the point rather than a convenience: this is a
|
||||
/// control whose whole effect is on what a *later* report says, so
|
||||
/// putting the state on screen at the moment of the press is the only
|
||||
/// thing that distinguishes it from a button that did nothing.
|
||||
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
||||
let on = !iris::diagnostics::trace_enabled();
|
||||
iris::diagnostics::set_trace(on);
|
||||
@@ -652,10 +431,8 @@ impl BenchClient {
|
||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||
};
|
||||
// The insets line goes in the pane, not just the log: Iris has no
|
||||
// logcat on her phone, and "the keyboard does not push the
|
||||
// composer up" cannot be told from "the listener never fired"
|
||||
// without it (`AndroidUiState::insets_report`).
|
||||
// Insets must be visible without adb so a missing callback can be
|
||||
// distinguished from a callback reporting zero IME height.
|
||||
format!(
|
||||
"{renderer}\n{}\n{}\n{}\n{}",
|
||||
trace_line(
|
||||
@@ -672,40 +449,11 @@ impl BenchClient {
|
||||
)
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
/// doc comment. **Logged only.** It used to also copy the report to
|
||||
/// the clipboard unprompted and put it in the shell's overlay view,
|
||||
/// from when the keyboard-inset callback was not firing at all and a
|
||||
/// report could not be got off the phone any other way. Both are gone
|
||||
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
|
||||
/// `MainActivity.java`), and the overlay covered the whole screen on
|
||||
/// *every* keyboard open with its own Copy/Close buttons underneath
|
||||
/// the keyboard, so it could not be dismissed -- an interruption for
|
||||
/// something nobody asked for, over an app you are trying to type
|
||||
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
|
||||
/// same text on demand, and `iris surface:`/`iris insets:` (view.rs)
|
||||
/// carry the lifecycle a `logcat` pull actually needs.
|
||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
log::info!("iris keyboard diagnostics:\n{report}");
|
||||
}
|
||||
|
||||
/// Always copies something, and never depends on `Diagnostics` or
|
||||
/// `Run benchmark` having been pressed first (docs/IRIS_TODO.md,
|
||||
/// 2026-09-07 night: "the copy report button seemed impossible to hit
|
||||
/// until I hit the diagnostics one" -- it was silently declining
|
||||
/// instead of reporting where it had failed, the UI_RULES failure "a
|
||||
/// failure is reported where it happened"). With no benchmark run yet,
|
||||
/// it copies the diagnostics pane's own text instead, with a first
|
||||
/// line saying so -- `diagnostics_text` needs no prior button press
|
||||
/// either, so this is never actually empty-handed.
|
||||
/// The report carries **no copy of the app log** (removed 2026-09-08,
|
||||
/// Iris: "please remove the app log from the diagnostics. Those can be
|
||||
/// obtained through dev updater now"). Dev Updater's Runtime tab reads
|
||||
/// the same ring through `devlog`'s provider, and the diagnostics
|
||||
/// pane's own `devlog provider:` line names the authority to read it
|
||||
/// from -- so what is left here is the measurement, not a second copy
|
||||
/// of something already reachable.
|
||||
fn copy_report(&mut self, rsc: &mut Rsc) {
|
||||
let Some(platform) = &self.platform else {
|
||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||
@@ -725,10 +473,6 @@ impl BenchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
|
||||
/// then type, then keyboard, then the report -- run in-process for the
|
||||
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
|
||||
/// on a real phone, no agent that can drive one).
|
||||
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||
if self.running {
|
||||
log::info!("iris bench report: already running");
|
||||
@@ -742,11 +486,6 @@ impl BenchClient {
|
||||
let platform = self.platform.clone();
|
||||
let stream_tail = self.stream_tail.clone();
|
||||
let ime_state = self.ime_state.clone();
|
||||
// What the platform *says*, kept apart from what the run measured
|
||||
// -- see where the two are resolved below. A phone that varies its
|
||||
// refresh rate answers with whichever mode it is in when asked, so
|
||||
// this alone judged a 120Hz run against a 60Hz budget (Iris's
|
||||
// phone, 2026-09-09).
|
||||
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
|
||||
let cpu_start = process_cpu_ms();
|
||||
// Read at the start as well as the end, because the switch is on
|
||||
@@ -804,13 +543,6 @@ impl BenchClient {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
state.running = false;
|
||||
let now = Instant::now();
|
||||
// **The larger of the two, because each can only be wrong
|
||||
// one way.** The platform under-reports a display that
|
||||
// varies its rate (60 for a run that sustained 120 on
|
||||
// Iris's phone), and the sustained rate is a floor -- an
|
||||
// app that cannot keep up says nothing about the panel.
|
||||
// Printed together whenever they disagree, so the
|
||||
// resolution is visible rather than silent.
|
||||
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
|
||||
let refresh_hz = match (drawn_hz, platform_hz) {
|
||||
(Some(d), Some(p)) => d.max(p),
|
||||
@@ -934,16 +666,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||
/// from it (toward older messages) through `Scroll::fling`, then
|
||||
/// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s
|
||||
/// convention, which is the finger's: a finger dragged down the screen
|
||||
/// brings earlier content into view. It was negative here until
|
||||
/// 2026-09-08, when the transcript's scroll position moved out of the
|
||||
/// `LazySpan` -- whose anchor offset ran the other way -- and into the
|
||||
/// `ScrollArea` around it. The two apps' *travel* is directly comparable
|
||||
/// whichever way the signs run, because both report it as a row index plus
|
||||
/// a pixel offset rather than a signed distance.
|
||||
async fn run_fling_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
@@ -988,9 +710,6 @@ async fn run_fling_phase(
|
||||
}
|
||||
let end = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
// Says how the fling was advanced, because that is what changed on
|
||||
// 2026-09-08 and a report from before then is not comparable: the
|
||||
// phase used to tick the fling itself at ~60Hz.
|
||||
format!("start={start} outward={outward} end={end} ticked=frame-loop")
|
||||
}
|
||||
|
||||
@@ -1005,32 +724,11 @@ async fn read_anchor_position(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Register the scrolling widget with the frame loop, exactly as a
|
||||
/// finger's own release does (`crate::ui::Selection::drag`'s
|
||||
/// `Released` arm) -- `Scrollable::fling` sets a velocity and drives
|
||||
/// nothing by itself.
|
||||
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||
let id = scroll.id();
|
||||
rsc.ui_mut().animate(id);
|
||||
}
|
||||
|
||||
/// Waits for the fling started above to settle, or for
|
||||
/// `FLING_SETTLE_CAP_MS` -- belt-and-suspenders the same way
|
||||
/// `BenchRun.kt`'s own `waitForSettle` is, since a fling's own
|
||||
/// spline-decided `duration()` already caps how long it can run.
|
||||
///
|
||||
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
|
||||
/// `Scrollable::tick_fling` itself every `POLL_MS`, which advanced the
|
||||
/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame
|
||||
/// redrew the list at a position it had already drawn, and the benchmark
|
||||
/// looked distinctly less smooth than the same list under her finger.
|
||||
/// That is what she reported that day, and it was the rig rather than the
|
||||
/// renderer: a real fling is ticked once per frame by
|
||||
/// `UiData::tick_animations`, from the frame callback. So the bench now
|
||||
/// starts the fling the way a gesture does (`fling` + `UiData::animate`)
|
||||
/// and polls `is_scrolling` to know when it is over, which makes the
|
||||
/// phase measure the same path a finger takes. The poll interval is only
|
||||
/// how often the *question* is asked and has no bearing on the animation.
|
||||
async fn wait_for_fling_settle(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
@@ -1050,11 +748,6 @@ async fn wait_for_fling_settle(
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
|
||||
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
||||
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
|
||||
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
|
||||
/// `(sent, total)`.
|
||||
async fn run_stream_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
@@ -1085,17 +778,10 @@ async fn run_stream_phase(
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts
|
||||
// -- `BenchRun.kt`'s own closing delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
(sent, total)
|
||||
}
|
||||
|
||||
/// Phase 3: focuses the real composer, shows the keyboard, then types
|
||||
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
|
||||
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
|
||||
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
|
||||
/// way.
|
||||
async fn run_type_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
@@ -1114,9 +800,6 @@ async fn run_type_phase(
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
// Lets focus and the keyboard's opening animation land before typing
|
||||
// starts, so the frames this phase records are the wrap/reflow it is
|
||||
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let mut typed = String::new();
|
||||
@@ -1145,13 +828,6 @@ async fn run_type_phase(
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
|
||||
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
|
||||
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
|
||||
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
|
||||
/// "keyboard: could not be shown" if the platform never confirms it even
|
||||
/// once, per UI_RULES.md ("design the unknown/failed state before the
|
||||
/// answer's").
|
||||
async fn run_keyboard_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
@@ -1198,10 +874,6 @@ async fn run_keyboard_phase(
|
||||
mod tests {
|
||||
use super::TYPE_TEXT;
|
||||
|
||||
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
|
||||
/// is the same string, so it has to match exactly or the two apps'
|
||||
/// type phases stop typing the same content -- RUST.md's "Benchmark
|
||||
/// v2" spec is one shared string for both.
|
||||
#[test]
|
||||
fn type_text_is_exactly_600_characters() {
|
||||
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
|
||||
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
|
||||
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
|
||||
//! report's real late-frame budget and `InputMethodManager.
|
||||
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
|
||||
//! of these are part of `android_view::context`'s own `Context`/
|
||||
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
|
||||
//! this calls them directly rather than growing that crate's wrapper for
|
||||
//! calls this crate alone needs.
|
||||
//!
|
||||
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
|
||||
//! whichever thread calls it -- the battery sampler runs on a background
|
||||
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
|
||||
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
|
||||
//! already attached (the `jni` crate detects it and does not double
|
||||
//! attach), so no caller here needs to know or care which thread it is.
|
||||
|
||||
use android_view::jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{GlobalRef, JObject, JValue},
|
||||
@@ -69,13 +48,6 @@ impl PlatformHandle {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
|
||||
/// on any JNI failure, on a device with no `BatteryManager` service,
|
||||
/// or when the platform itself answers "not supported" -- `0` or
|
||||
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
|
||||
/// both would read as a real (and wrong) measurement if folded into an
|
||||
/// average rather than named apart. UI_RULES.md: never present an
|
||||
/// inferred value as a measured one.
|
||||
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
@@ -135,14 +107,6 @@ impl PlatformHandle {
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
|
||||
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
|
||||
/// frames are judged against *this* device's real budget, not an
|
||||
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
|
||||
/// "late" that met their own faster deadline. `None` if the view is
|
||||
/// not yet attached to a window (`getDisplay` returns `null`) or the
|
||||
/// platform reports a non-positive rate, which is not a real answer
|
||||
/// either.
|
||||
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
@@ -167,20 +131,10 @@ impl PlatformHandle {
|
||||
if rate > 0.0 { Some(rate) } else { None }
|
||||
}
|
||||
|
||||
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
|
||||
/// own show, called directly rather than through the focus-driven
|
||||
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
|
||||
/// since RUST.md's "Benchmark v2" spec asks for this "through the
|
||||
/// shell's InputMethodManager" independent of focus state. `true` only
|
||||
/// if the platform itself reports the request succeeded -- whether the
|
||||
/// IME actually became visible is confirmed separately, from
|
||||
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
|
||||
/// value as a measured one").
|
||||
pub fn show_ime(&self) -> bool {
|
||||
self.try_toggle_ime(true).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
|
||||
pub fn hide_ime(&self) -> bool {
|
||||
self.try_toggle_ime(false).unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
||||
//! for a `ContentProvider` that Dev Updater queries.
|
||||
//!
|
||||
//! **Why**: Iris runs these builds on a phone with no `adb`, and Android
|
||||
//! forbids one app reading another's `logcat`, so nothing outside this
|
||||
//! process can recover what it wrote. The app already keeps a bounded copy
|
||||
//! (`crate::client::log_ring`); this is how the copy leaves the process. Dev
|
||||
//! Updater is on the same phone, so handing it over needs no tunnel, no
|
||||
//! token and no second enrolment -- and it is Dev Updater's own contract
|
||||
//! rather than something invented here, so any app it delivers can do the
|
||||
//! same (its `README.md`, "An app's own log").
|
||||
//!
|
||||
//! **Everything general stays in `client-core`** (AGENTS.md's sharing
|
||||
//! rule). What is here is only what Android forces: the JNI boundary and
|
||||
//! the Java class on the other side of it.
|
||||
//!
|
||||
//! Both entry points answer a **flat `String[]`** rather than a row of
|
||||
//! typed columns. That is the whole of the JNI, and it is one array type
|
||||
//! instead of three interleaved ones for a payload the provider is about
|
||||
//! to hand back over binder as a `MatrixCursor` anyway; `DevLogProvider`
|
||||
//! parses the two numeric fields. Kept flat rather than nested for the
|
||||
//! same reason -- an array of arrays is four more JNI calls per line.
|
||||
|
||||
use android_view::jni::JNIEnv;
|
||||
use android_view::jni::objects::{JClass, JObject, JString};
|
||||
use android_view::jni::sys::{jlong, jobjectArray};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How many `String`s each log line occupies in the flat answer:
|
||||
/// `seq`, `t_ms`, `level`, `target`, `message`, in that order. The Java
|
||||
/// side has the same constant, and the two are the one place the shape is
|
||||
/// written down on each side.
|
||||
///
|
||||
/// Gated with its one reader: the tabs demo links no `client-core` and so
|
||||
/// has no ring to lay out, and an ungated constant is a warning in that
|
||||
/// build (`iris-android-app` without `transcript-screen`).
|
||||
@@ -43,21 +18,11 @@ const FIELDS_PER_LINE: usize = 5;
|
||||
/// the same from inside this process otherwise.
|
||||
static AUTHORITY: OnceLock<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
|
||||
@@ -76,8 +41,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||
authority: JString,
|
||||
files_dir: JString,
|
||||
) {
|
||||
// Before the authority line, so the previous run's death is above the
|
||||
// line announcing this one rather than buried under it.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
||||
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||
@@ -91,7 +54,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||
let _ = AUTHORITY.set(authority);
|
||||
}
|
||||
|
||||
/// One `String` argument, or `None` for a null or unreadable one.
|
||||
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||
if value.is_null() {
|
||||
return None;
|
||||
@@ -99,18 +61,11 @@ fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||
env.get_string(value).ok().map(Into::into)
|
||||
}
|
||||
|
||||
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
|
||||
/// three strings.
|
||||
///
|
||||
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
||||
/// what tells a reader holding a cursor that this process **restarted**:
|
||||
/// the ring is in memory, so a new process starts again at zero and a
|
||||
/// stale cursor would otherwise skip everything silently.
|
||||
///
|
||||
/// Exported by name rather than registered, matching this crate's other
|
||||
/// activity-side natives: the mangled name is the whole of what a class
|
||||
/// this app owns needs.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
@@ -121,9 +76,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
||||
string_array(&mut env, &status_fields())
|
||||
}
|
||||
|
||||
/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at
|
||||
/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each.
|
||||
///
|
||||
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
|
||||
/// is, and one definition of the cursor is what keeps the app's own
|
||||
/// uploaded report and this provider describing the same lines.
|
||||
@@ -141,7 +93,6 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSinc
|
||||
string_array(&mut env, &line_fields(since.max(0) as u64))
|
||||
}
|
||||
|
||||
/// The three status numbers, as the provider's row.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
fn status_fields() -> Vec<String> {
|
||||
let ring = crate::client::log_ring::process_ring();
|
||||
@@ -181,8 +132,6 @@ 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
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
//! Which `ai-server` this app talks to, and how it was told.
|
||||
//!
|
||||
//! The parsing, the file and its owner-only mode are
|
||||
//! `crate::client::config` (`EnrolledServer`/`EnrollmentStore`), shared with
|
||||
//! the desktop app. What is genuinely this platform's, and all that is
|
||||
//! here, is the intent plumbing: Android hands an `aiapp://enroll?...`
|
||||
//! link to `MainActivity`, which passes it and the app's private files
|
||||
//! directory across JNI (see `lib.rs`'s two exported functions).
|
||||
//!
|
||||
//! **Why the app is told at runtime rather than at build time.** The APK
|
||||
//! is cross-compiled in a VM and run against the server on the host, whose
|
||||
//! CA and token are not this machine's -- so nothing about the destination
|
||||
//! can be baked in, and no token or CA may sit in a repo or a delivered
|
||||
//! artifact either way. The CA arrives with the link (`ca` parameter,
|
||||
//! `wg_app_link::enroll::ca_param`), which is what makes an APK built
|
||||
//! anywhere able to pin the server it is pointed at.
|
||||
//!
|
||||
//! The files directory is process-wide state, which this project otherwise
|
||||
//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the
|
||||
//! first thing that wants the enrollment -- has no parameter it could come
|
||||
//! in through. Same shape, and the same reason, as
|
||||
//! `crate::client::log_ring`'s process ring.
|
||||
|
||||
#[cfg(not(feature = "bench"))]
|
||||
use crate::client::api::UreqTransport;
|
||||
use crate::client::config::{EnrolledServer, EnrollmentStore};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds
|
||||
/// the view. Set once per process; a second call with a different path is
|
||||
/// a programmer error rather than something to recover from, and a second
|
||||
/// call with the same one is what a re-created activity does.
|
||||
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn set_files_dir(dir: PathBuf) {
|
||||
@@ -42,17 +15,12 @@ pub fn set_files_dir(dir: PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` before `MainActivity` has handed the directory over -- which is
|
||||
/// **not** the same as "not enrolled", and is why [`status`] has a state
|
||||
/// for it (UI_RULES: design the unknown state first).
|
||||
fn store() -> Option<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
|
||||
@@ -75,11 +43,6 @@ pub fn status() -> Status {
|
||||
/// One line for the diagnostics pane. The three states read differently on
|
||||
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||
/// must not be mistaken for it.
|
||||
///
|
||||
/// Only the bench build has a pane to put this in -- same gate, and the
|
||||
/// same reason, as `app_log::diagnostics_line`. The transcript build says
|
||||
/// the same things where they matter to it, in the message
|
||||
/// [`transport`]'s error becomes on screen.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn status_line() -> String {
|
||||
match status() {
|
||||
@@ -89,13 +52,6 @@ pub fn status_line() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever
|
||||
/// was enrolled before -- opening a link is how somebody says "this server
|
||||
/// now", including after the old one's token was rotated.
|
||||
///
|
||||
/// The returned `Err` is the message for a person: this is called from a
|
||||
/// tap on a link, and a link that did nothing with nothing said is the
|
||||
/// failure the UI rules are most insistent about.
|
||||
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||
let server = EnrolledServer::parse_link(uri)?;
|
||||
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||
@@ -105,8 +61,6 @@ pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||
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
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
//! The android-view demo app: by default, iris's `tabs` widget tree
|
||||
//! (`tabs_ui::build`, shared with the winit example) running through
|
||||
//! `iris::android`'s `ViewPeer`. This is RUST.md's I2 pass condition made
|
||||
//! concrete -- there is no UI here beyond what `tabs-ui` already draws.
|
||||
//!
|
||||
//! `JNI_OnLoad` and `new_view_peer` mirror android-view's own demo
|
||||
//! (`~/src/android-view/demo/src/lib.rs`): the only android-view-specific
|
||||
//! plumbing a real app needs is registering its `View` subclass and
|
||||
//! wrapping `iris::android::new_peer`'s generic function in a concrete
|
||||
//! `extern "system" fn`, since `register_view_class` wants a plain
|
||||
//! function pointer.
|
||||
//!
|
||||
//! **`transcript-screen` feature (RUST.md's I5 Android integration):** with
|
||||
//! `--features transcript-screen`, `new_view_peer` instantiates
|
||||
//! `transcript_client::TranscriptClient` instead of the tabs `Client`
|
||||
//! below, against a real `ai-server` (see that module's doc). Chosen over a
|
||||
//! third shell crate: this one already has the Gradle project, the
|
||||
//! `IrisView`/`MainActivity` Java, and the JNI registration I2 built and
|
||||
//! measured against, and the only thing a transcript screen needs on top
|
||||
//! is a different `AndroidAppState` -- the same axis `tabs_ui::build` vs.
|
||||
//! `crate::ui::build` already varies along on the winit side (compare
|
||||
//! `iris/examples/tabs.rs` and `iris/transcript-ui/examples/transcript.rs`).
|
||||
//! A build picks one screen or the other, never both, so `Client` and
|
||||
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
||||
//! there is no in-app navigation to switch *to* on either side yet.
|
||||
//!
|
||||
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
|
||||
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
|
||||
//! `crate::ui::build_tree` again, this time against the checked-in
|
||||
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
|
||||
//! server, with a "Run benchmark" control that drives the same scroll loop
|
||||
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
|
||||
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
|
||||
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
|
||||
//! enabled together -- `ActiveClient` below gives `bench` priority in that
|
||||
//! case, the same way `transcript-screen` already takes priority over the
|
||||
//! default `tabs-screen`.
|
||||
|
||||
use android_view::{
|
||||
Context, View,
|
||||
jni::{
|
||||
@@ -61,14 +23,7 @@ mod app_log;
|
||||
mod bench_client;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_jni;
|
||||
/// This app's log ring, handed to Dev Updater on the phone through a
|
||||
/// `ContentProvider`. Declared in every build for the reason the module
|
||||
/// gives: the Java class is in the manifest either way, and a `native`
|
||||
/// method the library does not export fails the class load.
|
||||
mod devlog;
|
||||
/// Which server this app talks to, told to it at runtime by an
|
||||
/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain
|
||||
/// tabs demo makes no network call and has nothing to enrol against.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
mod enrollment;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
@@ -105,12 +60,6 @@ impl AndroidAppState for Client {
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<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
|
||||
}
|
||||
}
|
||||
@@ -163,11 +112,6 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) ->
|
||||
/// and hands out one function pointer, whereas these two are this app's
|
||||
/// own activity and the mangled name is the whole of what is needed.
|
||||
///
|
||||
/// Declared in every build, including the tabs demo that has no
|
||||
/// `client-core` to store anything -- a `native` method Java declares and
|
||||
/// the library does not export is an `UnsatisfiedLinkError` when the class
|
||||
/// loads, which would take down a build that merely shares the activity.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
@@ -187,14 +131,6 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir
|
||||
log::debug!("iris app: files directory is {dir}");
|
||||
}
|
||||
|
||||
/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the
|
||||
/// VIEW intent that started or resumed the activity.
|
||||
///
|
||||
/// Logged either way rather than answered: the activity has nothing to do
|
||||
/// with the result, and where the enrollment shows up is the diagnostics
|
||||
/// pane (`enrollment::status_line`), which reads the stored answer rather
|
||||
/// than being told it.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
@@ -217,9 +153,6 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||
}
|
||||
|
||||
/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one --
|
||||
/// neither is worth taking the app down for, and both are logged where
|
||||
/// they happen.
|
||||
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||
if value.is_null() {
|
||||
log::warn!("iris app: the activity passed a null string across JNI");
|
||||
|
||||
@@ -1,42 +1,3 @@
|
||||
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the
|
||||
//! whole window on android-view, against a real `ai-server` through
|
||||
//! `client-core` -- the missing half `iris-android-app` (I2) only had for
|
||||
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
|
||||
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
|
||||
//! demo I2/I4 already measured against.
|
||||
//!
|
||||
//! **Deliberate simplification, recorded rather than left to be
|
||||
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
|
||||
//! session list here -- the first session `ApiClient::fetch_sessions`
|
||||
//! returns is opened automatically, since there is nothing to tap to get
|
||||
//! there, which is what `transcript-bench.sh` and `ui-trace` need to land
|
||||
//! straight on the screen under test.
|
||||
//!
|
||||
//! Which server it opens it against is no longer baked in: it is the
|
||||
//! enrollment an `aiapp://enroll` link left behind (`crate::android::enrollment`,
|
||||
//! and `desktop-app`'s identical `--link`), because an APK
|
||||
//! cross-compiled here cannot pin the CA of a server on the host.
|
||||
//!
|
||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||
//! `crate::client::transcript_fold`, a `generation` counter guarding against
|
||||
//! a stale background response. What differs is only the redraw
|
||||
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||
//! doc for why.
|
||||
//!
|
||||
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
|
||||
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
|
||||
//! a ~3,200-row transcript otherwise): `apply_event` calls
|
||||
//! `crate::ui::TranscriptScreen::apply` with the item list before and
|
||||
//! after `fold_event`, which updates only the row(s) that actually
|
||||
//! changed (almost always just the one open assistant message) instead of
|
||||
//! refolding and rebuilding every row. `rebuild_transcript` still runs
|
||||
//! the whole widget tree once, for the opening page and for `apply`'s own
|
||||
//! rare regroup fallback.
|
||||
|
||||
use crate::client::api::{ApiClient, UreqTransport};
|
||||
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||
use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||
@@ -55,20 +16,8 @@ pub struct TranscriptClient {
|
||||
/// by rebuilding the session list beside it.
|
||||
content: WeakWidget<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>,
|
||||
}
|
||||
|
||||
@@ -81,14 +30,6 @@ impl HasAndroidUiState for TranscriptClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds one `UreqTransport` from the stored enrollment. Called twice per
|
||||
/// session load, same as `desktop-app`'s `build_transport` closure --
|
||||
/// `ApiClient` and the live-stream follow each need their own, since
|
||||
/// `UreqTransport` holds its own `ureq::Agent`.
|
||||
///
|
||||
/// Read afresh each time rather than held: opening a new enrolment link
|
||||
/// while the app is running is how somebody points it at another server,
|
||||
/// and a cached transport would keep talking to the old one.
|
||||
fn build_transport() -> Result<UreqTransport, String> {
|
||||
crate::android::enrollment::transport()
|
||||
}
|
||||
@@ -102,15 +43,6 @@ fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
.any()
|
||||
}
|
||||
|
||||
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
|
||||
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
|
||||
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
|
||||
/// frames at all -- this is the screen's own equivalent of the Compose
|
||||
/// app's "Copy render timings" control, logged rather than clipboarded
|
||||
/// (no clipboard wiring exists here) under this crate's own fixed
|
||||
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
|
||||
/// grep-able on the fixed string `"iris frame report"` the way
|
||||
/// `transcript-bench.sh` greps `"ai-app render report"`.
|
||||
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||
type Rsc = AndroidRsc<TranscriptClient>;
|
||||
let report_rect = rect(Color::rgb(50, 50, 60))
|
||||
@@ -224,10 +156,6 @@ impl TranscriptClient {
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads the opening page, then follows the live SSE stream for the
|
||||
/// rest of this session's life -- `desktop-app`'s `select_session`
|
||||
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
|
||||
/// `redraw.request_redraw()` (see this module's doc).
|
||||
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.items.clear();
|
||||
@@ -254,17 +182,9 @@ impl TranscriptClient {
|
||||
};
|
||||
let api = ApiClient::new(rest);
|
||||
|
||||
// The most recent 200 events, coalesced -- the same page size
|
||||
// `desktop-app` uses; RUST.md's I3/history-paging work is what
|
||||
// a real scrollback would reuse (out of scope here, same as
|
||||
// E4).
|
||||
let page: Result<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()
|
||||
@@ -295,12 +215,6 @@ impl TranscriptClient {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
// The outer closure here is an `FnMut` -- `follow_session_events`
|
||||
// calls it once per line -- so it captures `live_generation` by
|
||||
// move and re-clones it for each inner `ctx.update` closure
|
||||
// rather than moving a shared `stop`-style helper into itself:
|
||||
// a value moved out of an `FnMut`'s captures on one call leaves
|
||||
// nothing there for the next.
|
||||
let _ =
|
||||
follow_session_events(
|
||||
&stream_transport,
|
||||
@@ -329,10 +243,6 @@ impl TranscriptClient {
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
|
||||
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
|
||||
/// Reads `self.session_id` rather than taking one, since every caller
|
||||
/// (the opening page, and every live event) already has it set there.
|
||||
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||
let in_progress = self
|
||||
.screen
|
||||
@@ -365,12 +275,7 @@ impl TranscriptClient {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, event);
|
||||
match &self.screen {
|
||||
// The common path: update only the row(s) that actually
|
||||
// changed instead of refolding and rebuilding all ~3,200 of
|
||||
// them per event (RUST.md's P0 streaming-phase fix).
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
// No screen yet (the opening page hasn't landed) -- build one
|
||||
// the ordinary way once it has.
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
//! RUST.md's E4: the transcript screen (`transcript-ui`, I5) in a real
|
||||
//! winit window on the desktop, with a session list beside it, talking to
|
||||
//! a real `ai-server` over `client-core`'s REST + SSE clients. See
|
||||
//! `app.rs`'s module doc for the widget tree and the event flow.
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B'
|
||||
//! desktop-app # after the first run above
|
||||
//! desktop-app --ca /path/to/ca.pem # a link that carries no CA
|
||||
//!
|
||||
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
|
||||
//! phone would scan as a QR (decided 2026-09-05) -- pasted rather
|
||||
//! than scanned, since a desktop has no camera to assume. It is parsed and
|
||||
//! saved once; later runs read it back and `--link` is only needed again
|
||||
//! to enrol against a different server.
|
||||
//!
|
||||
//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which
|
||||
//! `ai-server` now always includes) and is saved with it. `--ca` is the
|
||||
//! override for a link that carries none, and names the same
|
||||
//! `certs/ca.pem` a `curl --cacert` call uses.
|
||||
|
||||
use ai_app::desktop::{app, startup};
|
||||
|
||||
fn main() {
|
||||
// Validated once here so a bad `--ca`/`--link` is reported on stderr
|
||||
// before any window opens; `Client::new` calls this same function
|
||||
// again once the window exists, so this first call is a fast-fail
|
||||
// rather than the only place the values come from.
|
||||
if let Err(e) = startup::load_startup_config() {
|
||||
eprintln!("desktop-app: {e}");
|
||||
std::process::exit(2);
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
//! What a tool printed, with its terminal styling applied and everything
|
||||
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
||||
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
||||
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
||||
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
||||
//! [`Style`] onto whatever it draws with.
|
||||
//!
|
||||
//! Bash output arrives exactly as the program wrote it, escape sequences
|
||||
//! included, and drawn verbatim those are line noise in the middle of the
|
||||
//! thing being read. Stripping them all would be the other half-answer --
|
||||
//! colour is often the whole of what a diff or a test run is saying.
|
||||
//!
|
||||
//! So the sequences that decide how text *looks* become spans, and every
|
||||
//! other one is dropped rather than shown: the rest move a cursor around a
|
||||
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
||||
//! document.
|
||||
//!
|
||||
//! A carriage return is honoured the way a terminal honours it: what was
|
||||
//! written since the last line break is thrown away and the line starts
|
||||
//! again. That is what makes a progress bar show its final state rather
|
||||
//! than every state it passed through.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||
@@ -38,18 +16,10 @@ impl Rgb {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sixteen colours a terminal program names, and the two it assumes.
|
||||
///
|
||||
/// Its own palette rather than the syntax one: a program that prints in red
|
||||
/// has chosen red, where a highlighter's colours are this app's reading of
|
||||
/// somebody else's code.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnsiPalette {
|
||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||
pub colours: [Rgb; 16],
|
||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||
pub foreground: Rgb,
|
||||
/// What the text sits on, needed for reverse video.
|
||||
pub background: Rgb,
|
||||
}
|
||||
|
||||
@@ -67,8 +37,6 @@ pub struct Style {
|
||||
pub strikethrough: bool,
|
||||
}
|
||||
|
||||
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
||||
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct StyledText {
|
||||
pub text: String,
|
||||
@@ -87,11 +55,7 @@ impl StyledText {
|
||||
const ESC: char = '\u{1B}';
|
||||
const BELL: char = '\u{7}';
|
||||
|
||||
/// [text] with its terminal styling applied and everything else taken out;
|
||||
/// see the module doc.
|
||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated
|
||||
// to find that out.
|
||||
if !text.contains(ESC) && !text.contains('\r') {
|
||||
return StyledText::plain(text.to_string());
|
||||
}
|
||||
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
}
|
||||
});
|
||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||
// A bare carriage return rewrites the line. One before a newline
|
||||
// is the other half of a Windows line ending: it rewrites
|
||||
// nothing, and it is dropped rather than kept, since that pair
|
||||
// is one line break.
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
drop_line(&mut runs);
|
||||
at += 1;
|
||||
} else if c == '\r' {
|
||||
at += 1;
|
||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||
// Everything printable, plus the two control characters that are
|
||||
// layout rather than terminal commands. A stray bell or
|
||||
// backspace goes for the same reason a cursor move does.
|
||||
plain.push(c);
|
||||
at += 1;
|
||||
} else {
|
||||
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
StyledText { text: out, spans }
|
||||
}
|
||||
|
||||
/// Throws away everything written since the last line break, as a carriage
|
||||
/// return does.
|
||||
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||
while let Some((text, style)) = runs.pop() {
|
||||
if let Some(break_at) = text.rfind('\n') {
|
||||
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes that end a CSI sequence.
|
||||
fn is_csi_final(c: char) -> bool {
|
||||
('@'..='~').contains(&c)
|
||||
}
|
||||
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
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();
|
||||
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
}
|
||||
}
|
||||
']' | '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 {
|
||||
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Sgr {
|
||||
fg: Option<Rgb>,
|
||||
@@ -227,7 +174,6 @@ struct Sgr {
|
||||
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 {
|
||||
@@ -242,7 +188,6 @@ impl Sgr {
|
||||
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;
|
||||
@@ -275,15 +220,7 @@ impl Sgr {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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))
|
||||
@@ -377,12 +314,6 @@ impl Sgr {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||
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() {
|
||||
@@ -452,8 +378,6 @@ mod tests {
|
||||
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
|
||||
@@ -509,8 +433,6 @@ mod tests {
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
//! 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`.
|
||||
@@ -33,8 +20,6 @@ impl std::fmt::Display for ApiError {
|
||||
}
|
||||
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 {
|
||||
@@ -51,10 +36,7 @@ pub struct RawResponse {
|
||||
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,
|
||||
@@ -62,10 +44,6 @@ pub trait Transport: Send + Sync {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -104,10 +82,6 @@ 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,
|
||||
}
|
||||
@@ -282,19 +256,6 @@ impl<T: Transport> ApiClient<T> {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -320,9 +281,6 @@ impl<T: Transport> ApiClient<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -343,12 +301,6 @@ fn transcript_path(
|
||||
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,
|
||||
@@ -356,8 +308,6 @@ pub struct UreqTransport {
|
||||
}
|
||||
|
||||
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>,
|
||||
@@ -372,10 +322,6 @@ impl UreqTransport {
|
||||
.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()
|
||||
@@ -453,9 +399,6 @@ impl Transport for UreqTransport {
|
||||
.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()
|
||||
@@ -481,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -507,8 +447,6 @@ mod tests {
|
||||
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)>>,
|
||||
@@ -569,7 +507,6 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
//! 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
|
||||
@@ -37,19 +15,11 @@ 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`
|
||||
@@ -99,13 +69,11 @@ impl EnrolledServer {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -140,9 +108,6 @@ impl EnrollmentStore {
|
||||
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();
|
||||
@@ -157,10 +122,6 @@ impl EnrollmentStore {
|
||||
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) {
|
||||
@@ -232,8 +193,6 @@ mod tests {
|
||||
|
||||
#[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");
|
||||
@@ -248,9 +207,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The CA travels as base64url of the DER and comes back out as the
|
||||
/// PEM every consumer of it wants -- the same round trip
|
||||
/// `wg_app_link::enroll::ca_param` mints.
|
||||
#[test]
|
||||
fn a_ca_in_the_link_comes_back_as_pem() {
|
||||
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||
@@ -276,16 +232,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 =
|
||||
@@ -314,7 +266,6 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
//! 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
|
||||
@@ -14,9 +5,6 @@
|
||||
/// 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));
|
||||
@@ -47,8 +35,6 @@ pub fn format_millis(ms: i64) -> String {
|
||||
.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),
|
||||
@@ -60,40 +46,28 @@ pub fn format_millis_text(text: &str) -> String {
|
||||
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(""), "");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! 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;
|
||||
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
|
||||
/// 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 },
|
||||
}
|
||||
|
||||
@@ -59,7 +43,6 @@ pub fn follow_session_events(
|
||||
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(());
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! 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)]
|
||||
@@ -32,8 +27,6 @@ pub enum Language {
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||
/// (`Language.entries`).
|
||||
pub const ALL: [Language; 22] = [
|
||||
Language::C,
|
||||
Language::Coffeescript,
|
||||
@@ -60,24 +53,14 @@ impl Language {
|
||||
];
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -91,8 +74,6 @@ pub struct BlockComment {
|
||||
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,
|
||||
@@ -100,18 +81,13 @@ pub struct Quote {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -151,10 +127,6 @@ 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 {
|
||||
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
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!["#"],
|
||||
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> 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 {
|
||||
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
|
||||
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
|
||||
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
|
||||
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";
|
||||
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
|
||||
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 =
|
||||
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
|
||||
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 =
|
||||
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
||||
.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
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
//! 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 = ".,:;!?";
|
||||
|
||||
@@ -46,15 +28,10 @@ impl MarkdownScanner {
|
||||
// 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;
|
||||
@@ -76,7 +53,6 @@ impl MarkdownScanner {
|
||||
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()
|
||||
@@ -85,8 +61,6 @@ impl MarkdownScanner {
|
||||
.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);
|
||||
@@ -102,8 +76,6 @@ impl MarkdownScanner {
|
||||
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;
|
||||
@@ -132,7 +104,6 @@ impl MarkdownScanner {
|
||||
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;
|
||||
@@ -151,7 +122,6 @@ impl MarkdownScanner {
|
||||
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;
|
||||
@@ -166,7 +136,6 @@ impl MarkdownScanner {
|
||||
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') {
|
||||
@@ -175,8 +144,6 @@ impl MarkdownScanner {
|
||||
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 {
|
||||
@@ -193,20 +160,14 @@ impl MarkdownScanner {
|
||||
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;
|
||||
@@ -217,12 +178,8 @@ impl MarkdownScanner {
|
||||
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);
|
||||
@@ -238,8 +195,6 @@ impl MarkdownScanner {
|
||||
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] == '#' {
|
||||
@@ -256,7 +211,6 @@ impl MarkdownScanner {
|
||||
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) {
|
||||
@@ -277,8 +231,6 @@ impl MarkdownScanner {
|
||||
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) {
|
||||
@@ -304,16 +256,11 @@ impl MarkdownScanner {
|
||||
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)
|
||||
@@ -331,7 +278,6 @@ impl MarkdownScanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// `` `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] == '`' {
|
||||
@@ -354,11 +300,9 @@ impl MarkdownScanner {
|
||||
}
|
||||
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;
|
||||
@@ -397,8 +341,6 @@ impl MarkdownScanner {
|
||||
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;
|
||||
@@ -422,8 +364,6 @@ impl MarkdownScanner {
|
||||
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;
|
||||
@@ -465,8 +405,6 @@ impl MarkdownScanner {
|
||||
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;
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
//! `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;
|
||||
|
||||
@@ -26,7 +5,6 @@ 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,
|
||||
@@ -38,7 +16,6 @@ pub enum Kind {
|
||||
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,
|
||||
@@ -70,8 +47,6 @@ 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 = "()={}<>-+[]|&";
|
||||
|
||||
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
|
||||
|
||||
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()
|
||||
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
|
||||
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' {
|
||||
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
|
||||
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();
|
||||
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||
fn raw_string(&mut self) -> bool {
|
||||
if !self.rules.raw_strings {
|
||||
return false;
|
||||
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
|
||||
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;
|
||||
@@ -403,7 +362,6 @@ 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() {
|
||||
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||
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;
|
||||
@@ -640,10 +596,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = [
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
//! 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
|
||||
@@ -36,10 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
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,
|
||||
@@ -53,9 +23,6 @@ pub struct LogLine {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -74,11 +41,6 @@ impl LogLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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;
|
||||
@@ -108,15 +70,9 @@ struct Inner {
|
||||
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>>);
|
||||
|
||||
@@ -136,8 +92,6 @@ impl LogRing {
|
||||
})))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
@@ -153,7 +107,6 @@ impl LogRing {
|
||||
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 {
|
||||
@@ -180,14 +133,10 @@ impl LogRing {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -234,8 +183,6 @@ impl LogRing {
|
||||
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()
|
||||
@@ -258,8 +205,6 @@ impl LogRing {
|
||||
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,
|
||||
};
|
||||
@@ -317,9 +262,6 @@ fn is_own_target(target: &str) -> bool {
|
||||
|| 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
|
||||
@@ -336,24 +278,9 @@ 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,
|
||||
}
|
||||
|
||||
@@ -368,10 +295,6 @@ impl RingLogger {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -391,9 +314,6 @@ impl log::Log for RingLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -408,8 +328,6 @@ pub fn install(
|
||||
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!`
|
||||
@@ -421,20 +339,10 @@ pub fn install(
|
||||
/// 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,
|
||||
@@ -474,7 +382,6 @@ mod tests {
|
||||
|
||||
#[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)));
|
||||
@@ -491,9 +398,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -529,8 +433,6 @@ mod tests {
|
||||
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);
|
||||
@@ -568,9 +470,6 @@ mod tests {
|
||||
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);
|
||||
@@ -602,8 +501,6 @@ mod tests {
|
||||
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(),
|
||||
@@ -613,9 +510,6 @@ mod tests {
|
||||
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;
|
||||
@@ -632,9 +526,6 @@ mod tests {
|
||||
|
||||
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)),
|
||||
@@ -668,11 +559,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
//! 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. the 2026-09-06 decision 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
|
||||
@@ -40,20 +10,13 @@ use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
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,
|
||||
@@ -73,16 +36,9 @@ fn kind_of(tag: &Tag) -> BlockKind {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -101,9 +57,6 @@ pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
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]);
|
||||
@@ -163,9 +116,6 @@ mod tests {
|
||||
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");
|
||||
@@ -176,9 +126,6 @@ mod tests {
|
||||
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.");
|
||||
@@ -187,10 +134,6 @@ mod tests {
|
||||
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 [
|
||||
@@ -206,11 +149,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -232,12 +170,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.";
|
||||
@@ -271,19 +203,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `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]);
|
||||
@@ -297,9 +219,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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";
|
||||
@@ -311,9 +230,6 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! 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;
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
//! ([`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};
|
||||
|
||||
@@ -20,8 +12,6 @@ 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 {
|
||||
@@ -32,9 +22,6 @@ pub struct SessionNotification {
|
||||
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 {
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
//! 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 {
|
||||
@@ -17,10 +5,6 @@ pub struct Frame {
|
||||
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,
|
||||
@@ -32,8 +16,6 @@ impl SseReader {
|
||||
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() {
|
||||
@@ -50,7 +32,6 @@ impl SseReader {
|
||||
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||
self.name = Some(rest.trim().to_string());
|
||||
}
|
||||
// `id:`, comments -- nothing to do.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
//! 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,
|
||||
@@ -72,8 +39,6 @@ pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usiz
|
||||
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")
|
||||
}
|
||||
@@ -98,8 +63,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -108,7 +71,6 @@ mod tests {
|
||||
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);
|
||||
@@ -118,9 +80,6 @@ mod tests {
|
||||
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);
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
//! 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
|
||||
@@ -36,25 +16,14 @@ pub struct ToolInput {
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -65,13 +34,8 @@ const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("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.
|
||||
@@ -87,11 +51,6 @@ fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||
(!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 {
|
||||
@@ -117,10 +76,6 @@ fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||
.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())
|
||||
@@ -148,9 +103,6 @@ mod tests {
|
||||
|
||||
#[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"),
|
||||
@@ -175,9 +127,6 @@ mod tests {
|
||||
|
||||
#[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"}"#,
|
||||
@@ -196,9 +145,6 @@ mod tests {
|
||||
|
||||
#[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}"#,
|
||||
@@ -219,8 +165,6 @@ mod tests {
|
||||
|
||||
#[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()]
|
||||
@@ -234,8 +178,6 @@ mod tests {
|
||||
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()]
|
||||
|
||||
@@ -1,53 +1,17 @@
|
||||
//! This phone's copy of the transcripts it has already been sent, so
|
||||
//! reopening a session does not download it again. Ported from
|
||||
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
|
||||
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
|
||||
//!
|
||||
//! What is stored is the server's own JSON for one event per line, in
|
||||
//! transcript order. Reading the cache means running the same [`seq_of`]
|
||||
//! the network path runs, so a cached transcript and a fetched one cannot
|
||||
//! draw differently, and an event type this build does not know keeps
|
||||
//! every field it arrived with for the build that will. Rows are
|
||||
//! deliberately *not* what is stored: a row is a rendering, and a cache of
|
||||
//! rows would need throwing away on every update that touched the fold.
|
||||
//!
|
||||
//! Four rules run through all of it:
|
||||
//! 1. what is on screen is what the server's transcript says, in order,
|
||||
//! with nothing missing -- the cache is a copy and is never inferred,
|
||||
//! folded or edited here;
|
||||
//! 2. a cached line is never ahead of the live cursor, and the cursor never
|
||||
//! ahead of the cache;
|
||||
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
|
||||
//! unwritable all degrade to a cold open, never to a blank or a wrong
|
||||
//! screen;
|
||||
//! 4. a line already on the phone is not fetched again.
|
||||
//!
|
||||
//! No JSON parser here: what it needs off a line is the sequence number and
|
||||
//! whether the line is a streamed delta, both read with a regex-free scan
|
||||
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
|
||||
//! treated as damage.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// How much of this phone's cache directory all of one server's transcripts
|
||||
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
|
||||
/// for 24,000 events) and a small fraction of a phone. A number to revisit
|
||||
/// against real use rather than a measurement of anything.
|
||||
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
||||
|
||||
/// What the newest cached line says, which is what the probe checks against
|
||||
/// the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CachedTail {
|
||||
pub seq: u64,
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// This phone's cache root for one server, holding one directory per session.
|
||||
pub struct TranscriptCache {
|
||||
root: PathBuf,
|
||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||
@@ -68,8 +32,6 @@ impl TranscriptCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache for one session, whether or not anything has been stored
|
||||
/// for it yet.
|
||||
pub fn session(&self, id: &str) -> SessionCache {
|
||||
SessionCache::new(self.root.join(id), self.warn.clone())
|
||||
}
|
||||
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// One session's cached lines, as a directory of chunks.
|
||||
///
|
||||
/// A chunk is a set of lines *and a claim about what they cover*, and the
|
||||
/// two are not the same thing: a coalesced page joins each run of streamed
|
||||
/// deltas into one event carrying the seq of the run's oldest delta, so a
|
||||
/// page whose newest event is seq 1,200 may cover everything up to the
|
||||
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
|
||||
/// is the half-open range in the file's name:
|
||||
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
|
||||
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
|
||||
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
|
||||
/// its last line's seq + 1.
|
||||
///
|
||||
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
||||
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
||||
/// ever served: chunks behind a gap are kept, because the gap is usually
|
||||
/// closed by paging back through it, but nothing is served across one.
|
||||
///
|
||||
/// **The newest chunk is always raw**, which is what makes the stream
|
||||
/// cursor and the probe well defined.
|
||||
///
|
||||
/// Nothing here is load-bearing. Every operation that touches the disk
|
||||
/// answers as though the cache were empty when it cannot, and a write
|
||||
/// failure disables writing for the rest of this instance's life so that a
|
||||
/// full disk costs one log line rather than one per delta.
|
||||
///
|
||||
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
|
||||
/// the stream appends live events from its own thread while a reader
|
||||
/// scrolling back reads pages from another, and this is what keeps the open
|
||||
/// chunk's name, its end and its writer from being read half-rotated.
|
||||
pub struct SessionCache {
|
||||
dir: PathBuf,
|
||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||
@@ -202,8 +138,6 @@ pub struct SessionCache {
|
||||
|
||||
#[derive(Default)]
|
||||
struct WriterState {
|
||||
/// Set by the first write that fails: a second would fail the same way,
|
||||
/// once per delta.
|
||||
disabled: bool,
|
||||
writer: Option<fs::File>,
|
||||
open_file: Option<PathBuf>,
|
||||
@@ -238,7 +172,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
|
||||
pub fn newest(&self, limit: usize) -> Vec<String> {
|
||||
self.guard(Vec::new(), |this, state| {
|
||||
let mut taken: VecDeque<String> = VecDeque::new();
|
||||
@@ -262,11 +195,6 @@ impl SessionCache {
|
||||
/// below `before` -- and means the server has to be asked. Deliberately
|
||||
/// not an empty list: an empty page is how the screen is told it has
|
||||
/// reached the start of the conversation.
|
||||
///
|
||||
/// With `rows` the count is rows rather than lines, mirroring the
|
||||
/// server's `parse_coalesced`. The deltas are not joined here -- the
|
||||
/// fold does that, and the joined row keeps the seq of its first delta
|
||||
/// either way.
|
||||
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
||||
self.guard(None, |this, state| {
|
||||
let suffix = this.suffix(state)?;
|
||||
@@ -291,18 +219,12 @@ impl SessionCache {
|
||||
continue;
|
||||
}
|
||||
this.each_line(state, chunk, |line| {
|
||||
// The page is what is *before* the cursor; the rows at
|
||||
// or above it are already on screen.
|
||||
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
||||
if seq >= before {
|
||||
return true;
|
||||
}
|
||||
if rows {
|
||||
let delta = is_delta(line);
|
||||
// Stop only between rows: a delta continuing the
|
||||
// run being gathered is part of a row already
|
||||
// counted, and breaking on it would drop the half
|
||||
// of that row already taken.
|
||||
if counted >= limit && !(delta && in_run) {
|
||||
wanting = false;
|
||||
} else {
|
||||
@@ -338,9 +260,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores a fetched page covering `[first, end)`; `false` when it was
|
||||
/// not stored.
|
||||
///
|
||||
/// Refused when it overlaps a chunk already here, because there is no
|
||||
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
||||
/// delta run. The caller keeps that from arising by bounding what it
|
||||
@@ -377,13 +296,6 @@ impl SessionCache {
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends one live event, which is also how a freshly fetched opening
|
||||
/// window is stored.
|
||||
///
|
||||
/// A seq equal to the open chunk's end extends it. A larger one is a
|
||||
/// gap -- which is what a `reset` looks like from here -- and closes
|
||||
/// the open chunk under the end it turned out to have. A smaller one is
|
||||
/// already covered and is ignored; the SSE contract is `seq > after`.
|
||||
pub fn append(&self, line: &str, seq: u64) {
|
||||
self.guard((), |this, state| {
|
||||
if state.disabled {
|
||||
@@ -392,13 +304,6 @@ impl SessionCache {
|
||||
let Some(writer) = this.writer_for(state, seq)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// Written as it arrived. A newline inside it would split one
|
||||
// event into two unreadable halves. No source here can produce
|
||||
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
||||
// fetched line is one element of a compact JSON array -- but
|
||||
// that is a fact about the *server's* serializer rather than
|
||||
// anything this file controls, so it is checked rather than
|
||||
// trusted.
|
||||
debug_assert!(
|
||||
!line.contains('\n'),
|
||||
"a cached transcript line must be one line: {line}"
|
||||
@@ -411,7 +316,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
/// Flushes what [`Self::append`] has buffered.
|
||||
pub fn flush(&self) {
|
||||
self.guard((), |_this, state| {
|
||||
if let Some(writer) = state.writer.as_mut() {
|
||||
@@ -422,12 +326,10 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
/// What [`Self::purge`] would discard, for the reload row in session settings.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
||||
}
|
||||
|
||||
/// Marks this session as visited, which is what eviction ranks by.
|
||||
pub fn touch(&self) {
|
||||
self.guard((), |this, _state| {
|
||||
if this.dir.is_dir() {
|
||||
@@ -448,8 +350,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
// -- chunks ------------------------------------------------------------------------------
|
||||
|
||||
/// Every chunk on disk, oldest first. A name this does not recognise is
|
||||
/// not ours and is ignored. Recomputed per operation rather than kept:
|
||||
/// another operation may have changed the directory.
|
||||
@@ -475,9 +375,6 @@ impl SessionCache {
|
||||
} else {
|
||||
end_str.parse::<u64>().ok()
|
||||
};
|
||||
// A chunk covering nothing is one that was created and never
|
||||
// written to -- an append whose very first write failed. It
|
||||
// says nothing, so it is not a chunk.
|
||||
if let Some(end) = end
|
||||
&& end > first
|
||||
{
|
||||
@@ -494,13 +391,6 @@ impl SessionCache {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The open chunk's end: its last line's seq plus one, or the in-memory
|
||||
/// end while this instance is the one writing it.
|
||||
///
|
||||
/// An open chunk whose last line cannot be read is this app having died
|
||||
/// mid-write. That line is dropped and the file truncated to the last
|
||||
/// good one, which is the one place damage is repaired rather than
|
||||
/// discarded.
|
||||
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
||||
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
||||
return Some(state.open_end);
|
||||
@@ -516,12 +406,6 @@ impl SessionCache {
|
||||
Some(end)
|
||||
}
|
||||
|
||||
/// The contiguous run of adjacent chunks ending at the newest one,
|
||||
/// oldest first.
|
||||
///
|
||||
/// A newest chunk that is not raw cannot happen while this code is the
|
||||
/// only writer, and means the directory is not to be trusted -- so the
|
||||
/// session is discarded.
|
||||
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
||||
let all = self.chunks(state)?;
|
||||
let Some(newest) = all.last() else {
|
||||
@@ -540,8 +424,6 @@ impl SessionCache {
|
||||
Ok(run.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Each line of `chunk`, newest first, until `take` says stop.
|
||||
///
|
||||
/// Damage anywhere but at the tail of the open chunk was not written by
|
||||
/// this code, and there is no honest way to say what a chunk covers
|
||||
/// with a line of it unreadable -- so it is treated as damage rather
|
||||
@@ -565,10 +447,6 @@ impl SessionCache {
|
||||
});
|
||||
}
|
||||
|
||||
// -- writing -----------------------------------------------------------------------------
|
||||
|
||||
/// The writer for the chunk `seq` belongs in, opening or rotating one
|
||||
/// as it has to.
|
||||
fn writer_for<'s>(
|
||||
&self,
|
||||
state: &'s mut WriterState,
|
||||
@@ -581,14 +459,10 @@ impl SessionCache {
|
||||
if seq < state.open_end {
|
||||
return Ok(None);
|
||||
}
|
||||
// A gap: what this instance has written covers up to `open_end`,
|
||||
// and that is the name the chunk gets before a new one starts
|
||||
// at the arriving seq.
|
||||
let end = state.open_end;
|
||||
self.close_open_chunk(state, end);
|
||||
}
|
||||
fs::create_dir_all(&self.dir)?;
|
||||
// An open chunk left by an earlier instance, or by an earlier screen.
|
||||
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
||||
if let Some(existing) = existing {
|
||||
if seq < existing.end {
|
||||
@@ -632,8 +506,6 @@ impl SessionCache {
|
||||
Ok(state.writer.as_mut())
|
||||
}
|
||||
|
||||
/// Renames the open chunk to the range it turned out to cover, so it
|
||||
/// stops being open.
|
||||
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
||||
let file = state.open_file.clone();
|
||||
close_writer(state);
|
||||
@@ -647,31 +519,17 @@ impl SessionCache {
|
||||
}
|
||||
}
|
||||
|
||||
// -- failure -----------------------------------------------------------------------------
|
||||
|
||||
/// Runs `body`, answering `if_broken` when the directory cannot give a
|
||||
/// real answer. None of this is reported on screen: every read here has
|
||||
/// a network path beside it producing the same result, and the reader
|
||||
/// has nothing to do about it. Damage discards this session's cache,
|
||||
/// which makes the next open an ordinary cold one.
|
||||
fn guard<T>(
|
||||
&self,
|
||||
if_broken: T,
|
||||
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
||||
) -> T {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// A disk that refused once will refuse again, once per delta, so
|
||||
// the first refusal is also the last.
|
||||
if state.disabled {
|
||||
return if_broken;
|
||||
}
|
||||
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
||||
let result = body(self, &mut state);
|
||||
// Damage takes priority over whatever `body` returned, `Ok` or
|
||||
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
|
||||
// precisely so this check catches it before the branch below
|
||||
// mistakes it for a real I/O failure and disables the whole cache
|
||||
// over one corrupt session.
|
||||
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
||||
(self.warn)(&format!(
|
||||
"transcript cache damaged at {}; discarding {}",
|
||||
@@ -695,11 +553,6 @@ impl SessionCache {
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// How [`SessionCache::each_line`] reports a line it cannot make sense
|
||||
/// of back up to [`SessionCache::guard`], since the callback it hands
|
||||
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
|
||||
/// rather than a field: the guard that reads it always runs on the same
|
||||
/// call stack that could have set it, one `guard` call at a time.
|
||||
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
@@ -727,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
|
||||
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
||||
}
|
||||
|
||||
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
|
||||
/// not ours.
|
||||
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||
let rest = name.strip_suffix(".jsonl")?;
|
||||
let (rest, kind) = rest.rsplit_once('.')?;
|
||||
@@ -744,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||
}
|
||||
|
||||
/// One line's sequence number, or `None` when the line is not one of ours.
|
||||
///
|
||||
/// A hand-rolled scan rather than a JSON parse, so this module carries no
|
||||
/// parser and stays testable with no server: the seq is the first field the
|
||||
/// server writes, so the first match is the top-level one.
|
||||
pub fn seq_of(line: &str) -> Option<u64> {
|
||||
find_number_field(line, "seq")
|
||||
}
|
||||
|
||||
/// Whether a line is one streamed piece of a reply, which is what makes a
|
||||
/// run of them one row.
|
||||
pub fn is_delta(line: &str) -> bool {
|
||||
find_string_field(line, "type").as_deref() == Some("assistantText")
|
||||
}
|
||||
|
||||
/// The value of `"key":N` (any amount of whitespace around the colon), or
|
||||
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
|
||||
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||
let pattern = format!("\"{key}\"");
|
||||
let at = line.find(&pattern)?;
|
||||
@@ -777,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `"key":"..."`, or `None`. Mirrors
|
||||
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
|
||||
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||
let pattern = format!("\"{key}\"");
|
||||
let at = line.find(&pattern)?;
|
||||
@@ -789,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||
Some(after_quote[..end].to_string())
|
||||
}
|
||||
|
||||
/// How much of a file is read at a time when walking it backwards. One
|
||||
/// block covers a page of a transcript comfortably, and the walk stops as
|
||||
/// soon as the caller has what it asked for.
|
||||
const READ_BLOCK: usize = 64 * 1024;
|
||||
|
||||
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
||||
/// along with the byte offset it starts at, until `on_line` answers false.
|
||||
///
|
||||
/// Every question the cache is asked is about the newest end of a chunk,
|
||||
/// and a live run reaches the size of the conversation, so reading forwards
|
||||
/// means reading a transcript to answer with the last eighty lines of it.
|
||||
///
|
||||
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
||||
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
||||
/// missing file yields nothing.
|
||||
@@ -823,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
||||
}
|
||||
let mut buffer = block;
|
||||
buffer.extend_from_slice(&pending);
|
||||
// `buffer` is now `block` followed by `pending`; walk it backwards.
|
||||
let mut line_end = buffer.len();
|
||||
let mut at = buffer.len() as isize - 1;
|
||||
while at >= 0 {
|
||||
@@ -840,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
||||
pending = buffer[..line_end].to_vec();
|
||||
unread = start;
|
||||
}
|
||||
// The first line of a file has no newline before it to be found.
|
||||
let first = String::from_utf8_lossy(&pending);
|
||||
if !first.trim().is_empty() {
|
||||
on_line(0, &first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops a final line that is not one of ours, by truncating the file to
|
||||
/// where it starts.
|
||||
///
|
||||
/// This app having died mid-write is the one kind of damage that is
|
||||
/// repaired rather than discarded: the tail of an append-only file is the
|
||||
/// only place a partial line can be. A second bad line is not this, and is
|
||||
/// left for the read path to notice.
|
||||
fn repair_tail(file: &Path) -> io::Result<()> {
|
||||
let mut truncate_to: Option<u64> = None;
|
||||
each_line_backwards(file, |offset, line| {
|
||||
@@ -869,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `body`, translating an I/O or permission failure into `if_broken`
|
||||
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
|
||||
/// [`TranscriptCache`]'s own maintenance.
|
||||
fn guard_io<T>(
|
||||
if_broken: T,
|
||||
warn: &(impl Fn(&str) + ?Sized),
|
||||
@@ -886,10 +708,6 @@ fn guard_io<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a path's modified time, without pulling in a crate for it: a single
|
||||
/// `utimensat`-backed call would be one more platform-specific dependency
|
||||
/// for one call site, so this touches the file instead, which every
|
||||
/// filesystem this runs on updates the mtime for.
|
||||
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
// Rewriting a marker file's contents (rather than the directory itself,
|
||||
@@ -920,7 +738,6 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `cache`, but also hands back the messages it warned with.
|
||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
||||
let said2 = said.clone();
|
||||
@@ -996,8 +813,6 @@ mod tests {
|
||||
})
|
||||
);
|
||||
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
||||
// More than there is is what there is, which is a short opening
|
||||
// window and not a failure.
|
||||
assert_eq!(session.newest(80).len(), 3);
|
||||
}
|
||||
|
||||
@@ -1009,8 +824,6 @@ mod tests {
|
||||
for seq in 1..=3u64 {
|
||||
session.append(&tool_line(seq), seq);
|
||||
}
|
||||
// What a `reset` looks like from here: the next event is not the
|
||||
// one after the last.
|
||||
session.append(&tool_line(90), 90);
|
||||
session.flush();
|
||||
|
||||
@@ -1052,14 +865,11 @@ mod tests {
|
||||
}
|
||||
session.flush();
|
||||
|
||||
// Adjacent: its end is the open chunk's first.
|
||||
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
||||
assert!(session.store_page(&page, 60, 100, true));
|
||||
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
||||
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
||||
|
||||
// Behind a gap: kept on disk, because paging usually closes the
|
||||
// gap, but never served across it.
|
||||
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
||||
assert!(session.store_page(&page2, 1, 10, true));
|
||||
assert_eq!(session.page(10, 5, false), None);
|
||||
@@ -1095,8 +905,6 @@ mod tests {
|
||||
}
|
||||
session.flush();
|
||||
|
||||
// At or below where the run starts, so what the reader is
|
||||
// scrolling into is the server's.
|
||||
assert_eq!(session.page(100, 40, true), None);
|
||||
assert_eq!(session.page(40, 40, true), None);
|
||||
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
||||
@@ -1121,8 +929,6 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = cache(temp.path());
|
||||
let session = cache.session("s");
|
||||
// Two replies of three deltas each, split by a tool call: the same
|
||||
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
|
||||
let lines = vec![
|
||||
delta(1),
|
||||
delta(2),
|
||||
@@ -1137,14 +943,8 @@ mod tests {
|
||||
session.append(&tool_line(9), 9);
|
||||
session.flush();
|
||||
|
||||
// Three rows: the tool call at 8, the run 5..7, and the tool call
|
||||
// at 4. The cut lands between rows, so the older run is not
|
||||
// started.
|
||||
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
||||
// One row is one whole run, however many deltas it is made of.
|
||||
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
||||
// A page of lines counts lines, which is what the anchor restore
|
||||
// asks for.
|
||||
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
||||
}
|
||||
|
||||
@@ -1163,10 +963,7 @@ mod tests {
|
||||
session.append(&tool_line(10), 10);
|
||||
session.flush();
|
||||
|
||||
// A run straddling the boundary is one row, as it will be once folded.
|
||||
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
||||
// Asking for more rows than the suffix holds is a short page, not a
|
||||
// failure and not a claim that the conversation starts here.
|
||||
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
||||
}
|
||||
|
||||
@@ -1190,13 +987,9 @@ mod tests {
|
||||
session.append(&tool_line(90), 90);
|
||||
session.flush();
|
||||
|
||||
// The run behind the gap, which is what makes the fetched page
|
||||
// adjacent to it.
|
||||
assert_eq!(session.covered_up_to(90), Some(40));
|
||||
assert_eq!(session.covered_up_to(41), Some(40));
|
||||
assert_eq!(session.covered_up_to(10), Some(10));
|
||||
// Nothing at or below the oldest chunk's start, so the page is
|
||||
// bounded only by its limit.
|
||||
assert_eq!(session.covered_up_to(9), None);
|
||||
}
|
||||
|
||||
@@ -1212,9 +1005,6 @@ mod tests {
|
||||
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Only reachable by dying between closing one live run and opening
|
||||
// the next, and there is no cursor to be read off a coalesced line
|
||||
// -- so the open is a cold one.
|
||||
assert_eq!(session.tail(), None);
|
||||
assert!(!dir_of(temp.path(), "s").exists());
|
||||
}
|
||||
@@ -1243,7 +1033,6 @@ mod tests {
|
||||
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
||||
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
||||
);
|
||||
// And the run continues from where the good tail left off.
|
||||
session.append(&tool_line(3), 3);
|
||||
session.flush();
|
||||
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
||||
@@ -1261,7 +1050,6 @@ mod tests {
|
||||
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
||||
);
|
||||
|
||||
// Not seen by the tail, which reads the newest line and stops.
|
||||
assert_eq!(
|
||||
session.tail(),
|
||||
Some(CachedTail {
|
||||
@@ -1269,8 +1057,6 @@ mod tests {
|
||||
line: tool_line(3)
|
||||
})
|
||||
);
|
||||
// Reached by a read that walks past it: what is served is nothing,
|
||||
// and the session opens cold from here on.
|
||||
assert_eq!(session.newest(80), Vec::<String>::new());
|
||||
assert!(!dir_of(temp.path(), "s").exists());
|
||||
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
||||
@@ -1298,9 +1084,6 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cache = cache(temp.path());
|
||||
let session = cache.session("s");
|
||||
// Well past the 64 kB block the backwards reader takes at a time,
|
||||
// so a page has to be stitched across several of them -- including
|
||||
// a line that straddles a boundary.
|
||||
let padding = "x".repeat(300);
|
||||
let lines: Vec<String> = (1..=500u64)
|
||||
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
||||
@@ -1310,8 +1093,6 @@ mod tests {
|
||||
assert_eq!(session.tail().unwrap().seq, 500);
|
||||
assert_eq!(session.newest(80), lines[420..].to_vec());
|
||||
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
||||
// And a non-ASCII line, whose bytes a naive split could cut through
|
||||
// a character.
|
||||
let accented =
|
||||
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
||||
session.append(&accented, 501);
|
||||
@@ -1333,15 +1114,10 @@ mod tests {
|
||||
let when =
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
||||
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
||||
// The mtime touch above always sets "now", not `when` (see its
|
||||
// own doc) -- space the three writes out in real time instead,
|
||||
// since only relative order matters to eviction.
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let each = dir_size(&dir_of(temp.path(), "old"));
|
||||
|
||||
// Room for two of the three, so the oldest goes -- and the session
|
||||
// being read never does, however long ago it was last touched.
|
||||
cache.evict_to_budget("open", each * 2);
|
||||
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
||||
.unwrap()
|
||||
@@ -1394,8 +1170,6 @@ mod tests {
|
||||
session.purge();
|
||||
assert_eq!(session.bytes(), 0);
|
||||
assert_eq!(session.tail(), None);
|
||||
// And the session is usable again straight afterwards, which is
|
||||
// what a reload does next.
|
||||
session.append(&tool_line(9), 9);
|
||||
session.flush();
|
||||
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
//! What the transcript renders: the event stream folded into displayable
|
||||
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
||||
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
||||
//!
|
||||
//! Events are the only data source, and there is deliberately no second
|
||||
//! shape for history to drift from: a page fetched backwards, a live
|
||||
//! frame, and a line read out of the transcript cache are all the same
|
||||
//! events through the same fold.
|
||||
//!
|
||||
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
||||
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
||||
//! exists to bound how much a lazy list composes per frame, which is a
|
||||
//! fact about the UI framework drawing it, not about the transcript. See
|
||||
//! `CLIENT_CORE.md`.
|
||||
//!
|
||||
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
||||
//! deserializes straight into [`event_model::Event`], which has no
|
||||
//! `Unknown` catch-all -- an event type this build does not recognise
|
||||
//! fails to parse rather than degrading to a placeholder row. Closing that
|
||||
//! gap means giving `event_model::Event` its own forward-compatible
|
||||
//! variant, which is a shared-model decision for both sides of the wire
|
||||
//! and is deliberately left for whoever picks this up next (see
|
||||
//! `CLIENT_CORE.md`).
|
||||
|
||||
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||
|
||||
/// A question this build has already asked the reader about, with what was
|
||||
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
||||
/// be chosen.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuestionCard {
|
||||
pub seq: u64,
|
||||
@@ -38,24 +11,14 @@ pub struct QuestionCard {
|
||||
pub answers: Vec<String>,
|
||||
}
|
||||
|
||||
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
||||
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
||||
/// logic name it explicitly.
|
||||
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||
|
||||
/// This item's identity in the list: a `Seq` for everything with no
|
||||
/// identity of its own, `RunId` for a tool call (which keeps one across
|
||||
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
||||
/// in the Kotlin original.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ItemKey {
|
||||
Seq(u64),
|
||||
RunId(String),
|
||||
}
|
||||
|
||||
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
||||
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
||||
/// doc only says what changed in translation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TranscriptItem {
|
||||
UserMsg {
|
||||
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
|
||||
AssistantMsg {
|
||||
seq: u64,
|
||||
text: String,
|
||||
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
||||
/// Kotlin doc for why the split it licenses matters.
|
||||
settled: bool,
|
||||
},
|
||||
ToolRun {
|
||||
@@ -95,9 +56,6 @@ pub enum TranscriptItem {
|
||||
seq: u64,
|
||||
r#ref: String,
|
||||
},
|
||||
/// A message from another agent. `arrived` is this row's own identity
|
||||
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
||||
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
||||
PeerNote {
|
||||
seq: u64,
|
||||
from: String,
|
||||
@@ -108,8 +66,6 @@ pub enum TranscriptItem {
|
||||
seq: u64,
|
||||
text: String,
|
||||
},
|
||||
/// Placeholder for an event kind this build could not fold -- see the
|
||||
/// module doc's "known gap".
|
||||
Note {
|
||||
seq: u64,
|
||||
text: String,
|
||||
@@ -201,14 +157,10 @@ fn update_tool(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a status means the session is still doing something, mirroring
|
||||
/// `sessionWorking` in `Events.kt`.
|
||||
pub fn session_working(status: SessionStatus) -> bool {
|
||||
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||
}
|
||||
|
||||
/// A status saying the session stopped working is the moment its newest
|
||||
/// reply is finished.
|
||||
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||
if session_working(status) {
|
||||
return items.to_vec();
|
||||
@@ -223,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
|
||||
items
|
||||
}
|
||||
|
||||
/// A peer message goes above the turn it started, not where it happened to
|
||||
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
||||
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
||||
fn place_peer_note(
|
||||
items: &[TranscriptItem],
|
||||
seq: u64,
|
||||
@@ -264,8 +213,6 @@ fn place_peer_note(
|
||||
out
|
||||
}
|
||||
|
||||
/// The calls the note now sits in front of, renamed if they were sharing a
|
||||
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
||||
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||
let Some(TranscriptItem::ToolRun {
|
||||
run_id: first_run_id,
|
||||
@@ -299,14 +246,6 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
out
|
||||
}
|
||||
|
||||
/// Puts a page of older items in front of the ones already loaded, healing
|
||||
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
|
||||
/// `joinPages`.
|
||||
///
|
||||
/// Two things straddle a boundary: a tool call separated from its result,
|
||||
/// and a message separated from the rest of itself. Both were one thing
|
||||
/// before the transcript was cut into pages.
|
||||
///
|
||||
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||
@@ -319,23 +258,12 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
/// exactly what a page boundary destroys. The older row wins on what a
|
||||
/// start knows and the newer on what an end knows, which is the only way
|
||||
/// round that loses nothing.
|
||||
///
|
||||
/// The third thing is the *run*, and it is the one the Kotlin original used
|
||||
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
|
||||
/// here, but `adopt_run` must run on *every* join, not only the one where a
|
||||
/// split call was found -- a boundary landing cleanly between two finished
|
||||
/// calls, which is most of them, would otherwise leave the older page's
|
||||
/// calls under the run name they were folded with. On screen: one run of
|
||||
/// tool calls drawn as two groups, with the seam wherever the reader
|
||||
/// happened to have paged.
|
||||
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||
let (older, newer) = heal_split_message(earlier, later);
|
||||
let started_earlier: std::collections::HashSet<&str> = older
|
||||
.iter()
|
||||
.filter_map(TranscriptItem::as_tool_run)
|
||||
.collect();
|
||||
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
|
||||
// value, and a map borrowing it would keep that alive.
|
||||
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
||||
.iter()
|
||||
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||
@@ -374,9 +302,6 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
||||
output: output.clone(),
|
||||
done,
|
||||
failed,
|
||||
// Kept from both halves: a question or an image can be
|
||||
// attached to either, depending on which side of the
|
||||
// boundary its event fell.
|
||||
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
||||
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
||||
}
|
||||
@@ -411,18 +336,10 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
||||
out
|
||||
}
|
||||
|
||||
/// Rejoins a message the page boundary cut, and hands back the two pages to
|
||||
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
|
||||
///
|
||||
/// `fold_event` never leaves two assistant messages next to each other
|
||||
/// inside one page, so two meeting at a join are always the two halves of
|
||||
/// one reply, and leaving them apart drew a single answer as two with a
|
||||
/// paragraph break through the middle of a sentence.
|
||||
///
|
||||
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
|
||||
/// gives. It grows by what the older half brings, which is safe here and
|
||||
/// nowhere else -- the join is at the oldest end of what is loaded, so the
|
||||
/// growth extends off the top of the screen.
|
||||
fn heal_split_message(
|
||||
earlier: &[TranscriptItem],
|
||||
later: &[TranscriptItem],
|
||||
@@ -450,21 +367,10 @@ fn heal_split_message(
|
||||
(earlier[..earlier.len() - 1].to_vec(), newer)
|
||||
}
|
||||
|
||||
/// Hands the older calls at the join the name of the run they are joining.
|
||||
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
|
||||
///
|
||||
/// The two pages were folded separately, so a run split by the boundary
|
||||
/// came back as two runs with two names. Naming the joined run after the
|
||||
/// *older* half would be the obvious way round and is wrong: the newer half
|
||||
/// is the part already on screen, and renaming it is renaming the row the
|
||||
/// reader is looking at, which is how a list loses its anchor.
|
||||
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||
return earlier.to_vec();
|
||||
};
|
||||
// A question is in a run of its own on both sides of the join, the same as it would be
|
||||
// had the two pages been folded as one. Without this the heal would merge a group
|
||||
// straight through the row the reader was asked something on.
|
||||
if tool == ASK_USER_QUESTION {
|
||||
return earlier.to_vec();
|
||||
}
|
||||
@@ -494,10 +400,6 @@ fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<Transc
|
||||
out
|
||||
}
|
||||
|
||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||
/// at the parse layer).
|
||||
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||
let seq = entry.seq;
|
||||
match &entry.event {
|
||||
@@ -512,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
});
|
||||
items
|
||||
}
|
||||
// `MessageTaken` is folded into `UserMessage` by the manager before
|
||||
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
||||
// (a raw transcript line, say), it reads the same way.
|
||||
Event::MessageTaken {
|
||||
text, attachments, ..
|
||||
} => {
|
||||
@@ -527,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
items
|
||||
}
|
||||
Event::AssistantText { delta } => {
|
||||
// Deltas accumulate into the message they're streaming, which
|
||||
// keeps the seq of the *first* of them: a row whose identity
|
||||
// changed with every delta would be a new row every frame.
|
||||
// "A message growing again is not finished" -- whatever a
|
||||
// status said in between -- is why this always clears
|
||||
// `settled` rather than preserving it.
|
||||
if let Some(TranscriptItem::AssistantMsg {
|
||||
seq: first_seq,
|
||||
text,
|
||||
@@ -704,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
});
|
||||
items
|
||||
}
|
||||
// Screen-level state, not transcript rows.
|
||||
Event::CommandQueued { .. }
|
||||
| Event::MessageQueued { .. }
|
||||
| Event::MessageDropped { .. }
|
||||
@@ -769,9 +661,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of one tool call -- every state a card has to be able to
|
||||
/// draw, including the two that are not answers.
|
||||
///
|
||||
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||
/// and a call whose result never arrived both leave an empty `output`,
|
||||
@@ -780,34 +669,18 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
/// turn ended before anything came back.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolState {
|
||||
/// Started, no result yet, and the session is still working -- the
|
||||
/// ordinary state of a call in flight.
|
||||
Running,
|
||||
/// Stopped on the reader: a permission or question this call carries
|
||||
/// has not been answered, so nothing is happening until somebody
|
||||
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||
/// is differs, which is the Compose card's "your turn".
|
||||
Deciding,
|
||||
/// A result arrived and the tool did not report a failure.
|
||||
Succeeded,
|
||||
/// A result arrived and the tool reported that the call failed
|
||||
/// (`is_error`).
|
||||
Failed,
|
||||
/// No result ever arrived and the session is not working any more --
|
||||
/// the turn was interrupted, or the process went away. Not a verdict
|
||||
/// on the call: it says only that nobody found out.
|
||||
NoResult,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
/// The state of one call. `session_working` is
|
||||
/// [`session_working`]'s answer for the session this call is in --
|
||||
/// the only thing here that is not a property of the call itself, and
|
||||
/// what separates "still running" from "never came back".
|
||||
///
|
||||
/// Written once, over the fields rather than per call site, because
|
||||
/// the five states are decided by four conditions and every place
|
||||
/// that re-derived a subset of them got a different subset.
|
||||
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||
let TranscriptItem::ToolRun {
|
||||
done, failed, asks, ..
|
||||
@@ -820,9 +693,6 @@ impl ToolState {
|
||||
"a call cannot have failed before its result arrived"
|
||||
);
|
||||
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||
// Ahead of `done`: a call waiting on permission has not
|
||||
// finished either, and which of the two the reader is being
|
||||
// told about is the one they can act on.
|
||||
Self::Deciding
|
||||
} else if !*done {
|
||||
match session_working {
|
||||
@@ -837,14 +707,9 @@ impl ToolState {
|
||||
}
|
||||
}
|
||||
|
||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||
/// of this crate.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TranscriptRow {
|
||||
Single(TranscriptItem),
|
||||
/// Two or more calls with nothing between them.
|
||||
Tools(Vec<TranscriptItem>),
|
||||
}
|
||||
|
||||
@@ -864,9 +729,6 @@ impl TranscriptRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs of adjacent tool calls become one row; everything else passes
|
||||
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
||||
/// run each call names rather than by adjacency worked out here.
|
||||
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
let mut rows = Vec::new();
|
||||
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||
@@ -902,15 +764,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
||||
/// `Vec<Value>`) into the flat item list this module works over. A line
|
||||
/// this build can't parse fails the whole page rather than being skipped --
|
||||
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
|
||||
/// silently dropping one event could hide, say, a user message that then
|
||||
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
|
||||
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
|
||||
/// fold: "write the logic once" applies to any caller embedding
|
||||
/// `transcript-ui` against a live server, not just the first one.
|
||||
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||
let mut items = Vec::new();
|
||||
for value in values {
|
||||
@@ -922,13 +775,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// The wire `seq` a raw transcript line carries -- the live-stream resume
|
||||
/// cursor after loading a page must be this, not a folded item's `seq()`.
|
||||
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
|
||||
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
|
||||
/// re-deliver every delta already folded into it, duplicating the tail of
|
||||
/// a reply that was mid-stream when the page was fetched -- found via a
|
||||
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
|
||||
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||
value.get("seq")?.as_u64()
|
||||
}
|
||||
@@ -1161,12 +1007,6 @@ mod tests {
|
||||
obj
|
||||
}
|
||||
|
||||
/// The regression for a bug a real `run-headless.sh` screenshot found
|
||||
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
|
||||
/// last *item's* seq re-delivers the deltas already folded into a
|
||||
/// still-open assistant message, doubling its tail. `raw_seq` of the
|
||||
/// last wire line must be the true high-water mark instead, which for a
|
||||
/// run of deltas is higher than every item's own `seq()`.
|
||||
#[test]
|
||||
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||
let values = vec![
|
||||
@@ -1260,13 +1100,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// AGENTS.md's "things that have bitten": `joinPages` used to run
|
||||
/// `adoptRun` only on the path where a *split* call was found, so a
|
||||
/// boundary landing cleanly between two already-finished calls -- most
|
||||
/// of them -- left the older page's calls under the run name they were
|
||||
/// folded with, drawing one run of tool calls as two groups. Two
|
||||
/// finished, unrelated calls (no id in common) must still end up under
|
||||
/// one run name after the join.
|
||||
#[test]
|
||||
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
||||
@@ -1346,9 +1179,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A question is in a run of its own on both sides of a join -- healing
|
||||
/// must never rename the run of calls the reader was asked something
|
||||
/// on, the same rule `splitRun` enforces for a live turn boundary.
|
||||
#[test]
|
||||
fn adopt_run_never_renames_into_a_question_row() {
|
||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
||||
@@ -1372,10 +1202,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ToolState`] is what a card colours itself by, so each of its five
|
||||
/// states is asserted from the events that actually produce it rather than
|
||||
/// from a hand-built item -- a mapping that agreed with a fixture and
|
||||
/// disagreed with the fold would be invisible until it was on screen.
|
||||
#[cfg(test)]
|
||||
mod tool_state_tests {
|
||||
use super::*;
|
||||
@@ -1433,10 +1259,6 @@ mod tool_state_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The pair this enum exists for. Both calls have an empty `output`
|
||||
/// and nothing else distinguishes them, so a card that only looked at
|
||||
/// the text would draw the interrupted one as a call that ran fine and
|
||||
/// printed nothing.
|
||||
#[test]
|
||||
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||
assert_eq!(
|
||||
@@ -1451,9 +1273,6 @@ mod tool_state_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The same call, mid-turn: still running rather than abandoned. The
|
||||
/// only thing separating the two is the session's own status, which is
|
||||
/// why `of` takes it.
|
||||
#[test]
|
||||
fn no_result_while_the_session_works_is_still_running() {
|
||||
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||
@@ -1483,8 +1302,6 @@ mod tool_state_tests {
|
||||
answers: vec!["Allow".to_string()],
|
||||
},
|
||||
);
|
||||
// Ahead of both "still running" and "no result": the reader can
|
||||
// act on this one, and cannot act on either of those.
|
||||
assert_eq!(
|
||||
state_of(&[start("a"), asking.clone()], true),
|
||||
ToolState::Deciding
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
//! 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`
|
||||
@@ -49,9 +25,6 @@ impl std::fmt::Display for ParseError {
|
||||
}
|
||||
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),
|
||||
@@ -70,23 +43,9 @@ impl From<ParseError> for PageError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -94,8 +53,6 @@ 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,
|
||||
@@ -113,11 +70,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
|
||||
/// 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);
|
||||
@@ -126,9 +78,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
}
|
||||
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
|
||||
@@ -136,9 +85,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -151,15 +97,10 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
/// `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),
|
||||
@@ -177,9 +118,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
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
|
||||
@@ -193,21 +131,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
|
||||
/// 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);
|
||||
@@ -228,9 +151,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
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);
|
||||
@@ -240,9 +160,6 @@ impl<T: Transport> TranscriptSource<T> {
|
||||
))
|
||||
}
|
||||
|
||||
/// [`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
|
||||
@@ -289,11 +206,6 @@ mod tests {
|
||||
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)>>,
|
||||
@@ -371,7 +283,6 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -399,8 +310,6 @@ mod tests {
|
||||
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}]"));
|
||||
@@ -429,10 +338,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -463,8 +368,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -484,17 +387,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -512,9 +408,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -524,8 +417,6 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -1,44 +1,3 @@
|
||||
//! 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::{
|
||||
@@ -49,18 +8,8 @@ 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 {
|
||||
@@ -89,13 +38,6 @@ pub fn run() {
|
||||
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>,
|
||||
@@ -104,8 +46,6 @@ struct Client {
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -117,12 +57,6 @@ impl DefaultAppState for Client {
|
||||
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);
|
||||
@@ -237,9 +171,6 @@ impl Client {
|
||||
&& 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);
|
||||
@@ -268,11 +199,6 @@ impl Client {
|
||||
(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());
|
||||
@@ -286,24 +212,9 @@ impl Client {
|
||||
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()
|
||||
@@ -316,9 +227,6 @@ impl Client {
|
||||
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;
|
||||
@@ -385,8 +293,6 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
//! 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 shared store owns the file name,
|
||||
//! JSON, and owner-only mode required for a bearer token.
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! 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;
|
||||
@@ -30,12 +30,6 @@ fn parse_args() -> Result<Args, String> {
|
||||
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();
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
//! 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")]
|
||||
|
||||
@@ -1,46 +1,3 @@
|
||||
//! 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};
|
||||
@@ -51,17 +8,10 @@ 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,
|
||||
@@ -99,9 +49,6 @@ pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'l
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
//! 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;
|
||||
@@ -28,15 +8,6 @@ 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(|| {
|
||||
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
|
||||
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>,
|
||||
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
|
||||
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>,
|
||||
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||
fn native_on_start_command<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||
fn native_on_destroy<'local>(
|
||||
_env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -45,17 +44,6 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||
/// 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> {
|
||||
@@ -328,11 +316,6 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
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),
|
||||
@@ -381,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
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(())
|
||||
@@ -393,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
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) {
|
||||
@@ -416,9 +390,6 @@ fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
@@ -534,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
||||
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
|
||||
|
||||
@@ -28,11 +28,6 @@ impl ServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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";
|
||||
@@ -81,7 +76,6 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
|
||||
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)?;
|
||||
@@ -106,9 +100,6 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
||||
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(
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
//! 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;
|
||||
@@ -53,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||
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!(
|
||||
@@ -86,9 +64,6 @@ fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Resu
|
||||
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}"))
|
||||
}
|
||||
|
||||
@@ -107,9 +82,6 @@ fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
//! 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
|
||||
@@ -88,39 +57,11 @@ where
|
||||
.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
|
||||
// A wrapping editor occupies the composer's width even when its
|
||||
// current text is short. Without this inner constraint the vertical
|
||||
// ScrollArea reports the text's narrow natural width to Pad; settling
|
||||
// then offers that width back to the editor, where a trailing space
|
||||
// wraps and doubles its height. The next parent pass restores the
|
||||
// wide one-line answer, so the two layouts have no fixed point.
|
||||
.width(rest(1))
|
||||
// `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))
|
||||
|
||||
@@ -1,54 +1,20 @@
|
||||
//! 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 = 3202;
|
||||
|
||||
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>,
|
||||
@@ -95,7 +61,6 @@ impl Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's opening page as the rows a screen is built from.
|
||||
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
group_tool_runs(items)
|
||||
}
|
||||
@@ -134,8 +99,6 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
/// [`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,
|
||||
@@ -149,9 +112,6 @@ where
|
||||
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();
|
||||
|
||||
@@ -1,60 +1,9 @@
|
||||
//! 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; the 2026-09-06 decision 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,
|
||||
@@ -64,31 +13,16 @@ const fn mocha(hex: u32) -> UiColor {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
@@ -101,24 +35,13 @@ fn syntax_color(kind: Kind) -> UiColor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -134,16 +57,12 @@ pub fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -152,9 +71,6 @@ pub struct Rendered {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -178,10 +94,6 @@ fn heading_size(level: HeadingLevel) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -202,13 +114,8 @@ fn ensure_line(out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
@@ -234,8 +141,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
// `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());
|
||||
@@ -251,8 +156,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
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,
|
||||
@@ -278,11 +181,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
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
|
||||
@@ -296,8 +194,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
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();
|
||||
}
|
||||
@@ -331,8 +227,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
.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);
|
||||
}
|
||||
@@ -341,9 +235,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -373,9 +264,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
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 {
|
||||
@@ -385,20 +273,10 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -432,33 +310,14 @@ pub(crate) fn highlight_into(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. decided 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())
|
||||
@@ -491,8 +350,6 @@ pub fn table_text(src: &str) -> Rendered {
|
||||
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(' ');
|
||||
@@ -516,7 +373,6 @@ pub fn table_text(src: &str) -> Rendered {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -543,10 +399,6 @@ fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||
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();
|
||||
@@ -601,8 +453,6 @@ mod tests {
|
||||
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();
|
||||
@@ -653,23 +503,15 @@ mod tests {
|
||||
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```");
|
||||
@@ -763,8 +605,6 @@ mod tests {
|
||||
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";
|
||||
|
||||
@@ -1,48 +1,3 @@
|
||||
//! The transcript screen, in iris -- RUST.md's I5. Built the same way
|
||||
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
|
||||
//! `Rsc::State: FocusHost + OpenUrl`, so the winit example (`iris/examples/
|
||||
//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the
|
||||
//! same [`build`]. See RUST.md's I5 box for the full account of what is
|
||||
//! and is not proved yet, and this doc for the shape.
|
||||
//!
|
||||
//! ```text
|
||||
//! +------------------------------------------+
|
||||
//! | iris::widget::LazySpan (crate::ui::row) | <- .height(rest(1))
|
||||
//! | row 1: sender label + one TextEdit |
|
||||
//! | row 2: sender label + one TextEdit |
|
||||
//! | row 3 (Tools): collapsed/expanded |
|
||||
//! | ... |
|
||||
//! +------------------------------------------+
|
||||
//! | composer bar (crate::ui::composer) | <- natural height
|
||||
//! +------------------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! **What this crate does not do itself**: fetch anything over the network
|
||||
//! or read the transcript cache. [`build`] takes an already-folded
|
||||
//! `Vec<crate::client::transcript_fold::TranscriptRow>` and
|
||||
//! [`TranscriptScreen::push_row`] takes one more as it arrives -- the
|
||||
//! caller (an app's own `main`, or a future `iris-android-app`-shaped
|
||||
//! cdylib) owns `crate::client::ApiClient`/
|
||||
//! `event_stream::follow_session_events` and the transcript cache, per the
|
||||
//! code rules' "ask for the least you need": a widget-tree builder that
|
||||
//! also knew how to make an HTTPS request would be untestable without a
|
||||
//! server and unable to be driven by `run-headless.sh` with synthetic rows.
|
||||
//!
|
||||
//! **Gap closed, 2026-09-05**: a touch-drag that starts on a row's
|
||||
//! rendered text used to always begin a cross-row *selection* (`row.rs`'s
|
||||
//! `CursorSense::click_or_drag()` on each row's `TextEdit`), never a
|
||||
//! *scroll* of the list, because both wanted the same gesture over the
|
||||
//! same screen region and `core/src/sense.rs`'s `run_sensors` gave the
|
||||
//! widget in the *inner* layer (a row's own `TextEdit`) first refusal
|
||||
//! every frame it was pressed. `row.rs` now routes every row's drag
|
||||
//! through one shared `iris::sense::DragArbiter`
|
||||
//! (`Selection::drag`, `selection.rs`), which decides pan vs. select the
|
||||
//! way Android itself does -- `DragArbiter`'s own doc has the exact
|
||||
//! rule. `LazySpan` scrolls correctly when
|
||||
//! driven programmatically (I3's benchmark), via the mouse wheel (wired
|
||||
//! below, `CursorSense::Scroll`), and now via a touch pan starting on a
|
||||
//! row's own text too.
|
||||
|
||||
pub mod composer;
|
||||
// The checked-in bench fixture opened as a real screen -- 1.9 MB of
|
||||
// `include_str!`, so it is a feature rather than always present: a build
|
||||
@@ -71,22 +26,8 @@ pub struct TranscriptScreen {
|
||||
pub list: WeakWidget<LazySpan>,
|
||||
pub composer: composer::Composer,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
/// How many times [`Self::apply`] has fallen back to a full rebuild --
|
||||
/// `Cell` rather than requiring `&mut self`, matching every other
|
||||
/// method here (the real state lives behind `list`/`selection`'s own
|
||||
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||
/// [`Self::take_rebuilds`].
|
||||
rebuilds: std::cell::Cell<usize>,
|
||||
/// What the row at the live end of the list kept so the next event
|
||||
/// can change part of it rather than all of it -- one markdown block
|
||||
/// of a streaming message (`row::RowBlocks::apply_delta`), or one card
|
||||
/// of a tool run whose result just arrived (`tool::ToolRow::
|
||||
/// apply_calls`). `None` before anything has been pushed. Its removal
|
||||
/// is every path that replaces or drops the tail row, below.
|
||||
tail: RefCell<Option<(RowKey, row::TailRow)>>,
|
||||
/// Whether the session is still working -- see
|
||||
/// [`Self::set_session_working`], which is the only thing that writes
|
||||
/// it. `Cell`, like `rebuilds`, so every method here stays `&self`.
|
||||
session_working: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
@@ -116,15 +57,6 @@ impl TranscriptScreen {
|
||||
*self.tail.borrow_mut() = tail.map(|t| (key, t));
|
||||
}
|
||||
|
||||
/// Whether the session this transcript belongs to is still doing
|
||||
/// something (`crate::client::transcript_fold::session_working`).
|
||||
///
|
||||
/// The one thing a tool card cannot read off its own call: a call with
|
||||
/// no result is *running* while the session works and *never came
|
||||
/// back* once it stops, and those are different things to tell a
|
||||
/// reader. Only the newest row is affected -- every row behind it
|
||||
/// belongs to a turn that has already ended -- so changing it re-draws
|
||||
/// that row and nothing else.
|
||||
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
@@ -139,9 +71,6 @@ impl TranscriptScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many tool cards the newest row is drawing, `0` when it is not a
|
||||
/// tool row or its group is closed. Only the tests read it; nothing on
|
||||
/// screen is decided by it.
|
||||
#[cfg(test)]
|
||||
fn tail_card_count(&self) -> usize {
|
||||
match self.tail.borrow().as_ref() {
|
||||
@@ -170,12 +99,6 @@ impl TranscriptScreen {
|
||||
/// The `ReplaceLast` fast path: update the tail row in place if this
|
||||
/// really is a change to the same row, and say whether that worked.
|
||||
/// `false` for anything the caller must rebuild instead.
|
||||
///
|
||||
/// Two kinds of row have such a path and they are asked the same
|
||||
/// question: a message's blocks take a delta into the last block, and
|
||||
/// a tool row's cards take an arriving result on one card. Which one
|
||||
/// this is comes from what the row kept, not from a second decision
|
||||
/// here.
|
||||
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
@@ -218,28 +141,6 @@ impl TranscriptScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the effect of one more folded event without rebuilding the
|
||||
/// whole screen -- RUST.md's "streaming still costs a full rebuild"
|
||||
/// fix. `old`/`new` are `crate::client::transcript_fold::fold_event`'s
|
||||
/// own before/after item lists (never grouped into rows -- that
|
||||
/// happens here, over both, so the common tail cases can be told
|
||||
/// apart; `group_tool_runs` is pure bookkeeping over already-folded
|
||||
/// items, no widget is built doing it).
|
||||
///
|
||||
/// Three cases, cheapest first:
|
||||
/// - **nothing changed**: no-op.
|
||||
/// - **pure append** (a still-open reply's row now closed and stable,
|
||||
/// a new tool call, a new message): every new row is `push_back`ed,
|
||||
/// same cost as [`Self::push_row`].
|
||||
/// - **only the last row's content changed** (the common case: a delta
|
||||
/// folded into a still-open assistant message): that one row is
|
||||
/// rebuilt (`row::build_row`, the same path a fresh row goes
|
||||
/// through) and swapped in with [`LazySpan::replace_back`] -- every
|
||||
/// other row is untouched, so nothing else redraws or moves. Any
|
||||
/// further new rows are appended after it, for the (also common)
|
||||
/// case of a delta that both finishes the open reply and starts the
|
||||
/// next row in the same event.
|
||||
///
|
||||
/// Anything else -- a row *before* the tail changed, which only
|
||||
/// happens when `group_tool_runs` regroups already-seen items (a tool
|
||||
/// run's calls that used to be separate rows join once the run closes)
|
||||
@@ -264,19 +165,11 @@ impl TranscriptScreen {
|
||||
match diff_rows(&old_rows, &new_rows) {
|
||||
RowDiff::Unchanged => {}
|
||||
RowDiff::Appended { common } => {
|
||||
// Pure append: every already-drawn row is byte-for-byte the
|
||||
// same `FoldedRow` it was last time.
|
||||
for row in &new_rows[common..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::ReplaceLast { common } => {
|
||||
// Only the tail row's content changed. First try the
|
||||
// delta path: the row is a column of one widget per
|
||||
// markdown block, so a delta that lands in the last block
|
||||
// is one `set_with_spans` and the earlier blocks keep
|
||||
// their layouts (`row::RowBlocks::apply_delta`, whose doc
|
||||
// says why the row is shaped that way).
|
||||
let old_key = row::row_key(&old_rows[common].key());
|
||||
let new_key = row::row_key(&new_rows[common].key());
|
||||
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||
@@ -286,18 +179,7 @@ impl TranscriptScreen {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise rebuild that one row and swap it in place,
|
||||
// keeping every row before it untouched. `unregister`
|
||||
// unconditionally, not only when the key changed: a
|
||||
// rebuild with *fewer* blocks under the same key would
|
||||
// otherwise leave the extra blocks in `Selection`
|
||||
// pointing at widgets the `drop` below frees (the shape
|
||||
// a review on 2026-09-06 called out).
|
||||
self.selection.borrow_mut().unregister(old_key);
|
||||
// Uncapped: this is the row a delta just failed to land
|
||||
// in, and the reason may be that it *is* capped
|
||||
// (`RowBlocks::capped`). Rebuilding it capped again would
|
||||
// refuse the next delta the same way, once per event.
|
||||
let (new_key, widget, kept) = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
@@ -314,15 +196,6 @@ impl TranscriptScreen {
|
||||
}
|
||||
}
|
||||
RowDiff::Rebuild => {
|
||||
// A row before the tail changed (a regroup) -- nothing
|
||||
// short of a full rebuild expresses that. `Selection`
|
||||
// gets cleared the same way `LazySpan` does, right before the
|
||||
// rows it was pointing at go with it -- `push_row` below
|
||||
// re-`register`s whatever survives as it rebuilds each
|
||||
// row (review, 2026-09-06 finding 1: a key that
|
||||
// `group_tool_runs` regrouped away used to stay in
|
||||
// `Selection` pointing at a widget this `clear()` had
|
||||
// just freed, panicking the next long-press anywhere).
|
||||
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||
self.selection.borrow_mut().clear();
|
||||
(self.list)(rsc).clear();
|
||||
@@ -334,9 +207,6 @@ impl TranscriptScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times [`Self::apply`] has fallen back to a full rebuild
|
||||
/// since the last call, reset to 0 by reading it -- the same
|
||||
/// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4).
|
||||
pub fn take_rebuilds(&self) -> usize {
|
||||
self.rebuilds.replace(0)
|
||||
}
|
||||
@@ -361,13 +231,6 @@ where
|
||||
screen
|
||||
}
|
||||
|
||||
/// The same widget tree [`build`] makes, without claiming the window's
|
||||
/// whole root -- what a caller embedding this screen alongside something
|
||||
/// else of its own needs (RUST.md's E4: a session list beside the
|
||||
/// transcript on the desktop). `build` is `build_tree` plus
|
||||
/// `ui_state.set_root(tree)`; kept as its own function since most callers
|
||||
/// (the winit example, an eventual Android cdylib) want the screen to *be*
|
||||
/// the window and don't need the strong handle back.
|
||||
pub fn build_tree<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
rows: Vec<FoldedRow>,
|
||||
@@ -414,10 +277,6 @@ where
|
||||
list.on(
|
||||
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop | CursorSense::Cancel,
|
||||
move |ctx, rsc| {
|
||||
// Which *block* the finger is over, resolved from its
|
||||
// drawn box rather than from the row's extent -- a row is
|
||||
// a column of one widget per markdown block now, and the
|
||||
// block is what `Selection` selects (`SelKey`).
|
||||
let row = selection
|
||||
.borrow()
|
||||
.locate(&*rsc, ctx.data.render, ctx.data.cursor.pos);
|
||||
@@ -435,16 +294,6 @@ where
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
// The wheel, registered by hand rather than through
|
||||
// `LazySpan::scrollable()`, and this is the reason: that helper also
|
||||
// registers a finger drag driving the span's own `DragGesture`, and
|
||||
// the transcript already has an arbiter -- `Selection`, which has to
|
||||
// decide between panning and selecting text and so cannot let a second
|
||||
// `DragGesture` see the same frames. `DragGesture`'s doc states the
|
||||
// rule: one gesture, one arbiter, each frame delivered exactly once.
|
||||
// The wheel handler here is identical to the helper's; only the drag
|
||||
// differs, and it arrives through `Selection::drag`, which hands
|
||||
// committed pans and releases to this same span.
|
||||
list.on(CursorSense::Scroll(Axis::Y), |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
@@ -454,15 +303,6 @@ where
|
||||
|
||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||
|
||||
// `.masked()`, opted into here rather than done by the list: a
|
||||
// `LazySpan` culls the rows outside its box but draws a *straddling*
|
||||
// one in full, so without a clip the top of that row is drawn above
|
||||
// the list -- through whatever the app put there, which on the phone
|
||||
// is the header bar (docs/IRIS_TODO.md, 2026-09-07: "code and a
|
||||
// paragraph visible behind Run benchmark"). This screen is a list
|
||||
// under a header, so this screen wants the clip; a full-screen list
|
||||
// does not, and the widget is right not to assume either
|
||||
// (`lazy_span.rs`'s module doc).
|
||||
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
@@ -488,17 +328,9 @@ where
|
||||
/// harness to exercise logic that never touches one.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RowDiff {
|
||||
/// `old` and `new` are the same length and every row is identical.
|
||||
Unchanged,
|
||||
/// Rows `[common..]` of `new` are new; everything before `common` is
|
||||
/// byte-for-byte the same `FoldedRow` `old` already had.
|
||||
Appended { common: usize },
|
||||
/// Row `common` is the only one whose content differs; anything past
|
||||
/// it in `new` is a pure append after the replacement.
|
||||
ReplaceLast { common: usize },
|
||||
/// A row *before* the tail differs -- only `group_tool_runs` regrouping
|
||||
/// an earlier run does this, and nothing short of a full rebuild
|
||||
/// expresses it.
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
@@ -514,10 +346,6 @@ fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
|
||||
} else if common == old.len() {
|
||||
RowDiff::Appended { common }
|
||||
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
|
||||
// The `common < new.len()` guard is what tells "the tail row's
|
||||
// content changed" apart from "the tail row was removed and
|
||||
// nothing replaced it" (a shrinking list) -- the latter has
|
||||
// nothing at `new[common]` to rebuild into place.
|
||||
RowDiff::ReplaceLast { common }
|
||||
} else {
|
||||
RowDiff::Rebuild
|
||||
@@ -575,10 +403,6 @@ mod diff_tests {
|
||||
|
||||
#[test]
|
||||
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
|
||||
// The row that used to be the tail (a now-closed assistant
|
||||
// message) is unchanged; a new user message is appended after it
|
||||
// -- the transition every reply's *last* delta makes once the
|
||||
// next turn starts.
|
||||
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
|
||||
@@ -586,8 +410,6 @@ mod diff_tests {
|
||||
|
||||
#[test]
|
||||
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
|
||||
// The common streaming case: the assistant message's key (its
|
||||
// first delta's seq) never changes, only its text grows.
|
||||
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||
@@ -595,9 +417,6 @@ mod diff_tests {
|
||||
|
||||
#[test]
|
||||
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
|
||||
// `ReplaceLast` only claims the row it names; `apply` appends
|
||||
// whatever comes after it separately -- this just confirms the
|
||||
// diff still recognises the replace even with a trailing append.
|
||||
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||
@@ -605,10 +424,6 @@ mod diff_tests {
|
||||
|
||||
#[test]
|
||||
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
|
||||
// Two separate `Single` rows for the same run id become one
|
||||
// `Tools` row once `group_tool_runs` sees them adjacent -- that
|
||||
// changes row 0, not just the tail, so nothing short of a full
|
||||
// rebuild expresses it.
|
||||
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
|
||||
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||
@@ -622,14 +437,6 @@ mod diff_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises `TranscriptScreen::apply`'s `Rebuild` arm through a real
|
||||
/// `Selection`, the gap the 2026-09-06 review named: the
|
||||
/// pure `diff_rows` decision above and `selection.rs`'s own registration
|
||||
/// tests each pass in isolation, and neither alone catches finding 1 (a
|
||||
/// regrouped-away row's key surviving in `Selection` after `LazySpan::clear()`
|
||||
/// has already freed its widget). This fails before `Selection::clear()`
|
||||
/// existed and the `Rebuild` arm called it, with a panic from
|
||||
/// `TextEditable::edit` resolving the freed slot.
|
||||
#[cfg(test)]
|
||||
mod apply_tests {
|
||||
use super::*;
|
||||
@@ -638,12 +445,6 @@ mod apply_tests {
|
||||
struct TestFocus {
|
||||
focus: Option<WeakWidget<TextEdit>>,
|
||||
}
|
||||
/// The headless stand-in for `iris::platform::OpenUrl`'s real
|
||||
/// backends. Nothing in these tests taps a link -- the tap-vs-drag
|
||||
/// rule that decides whether one is followed is `iris`'s own
|
||||
/// (`sense_tests.rs`'s `a_press_released_without_moving_is_a_tap`),
|
||||
/// and which link is under a byte offset is `markdown.rs`'s -- so
|
||||
/// this only exists to satisfy the bound.
|
||||
impl OpenUrl for TestFocus {
|
||||
fn open_url(&mut self, _url: &str) {}
|
||||
}
|
||||
@@ -725,7 +526,6 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply of `paragraphs` paragraphs, the last one still growing.
|
||||
fn reply(paragraphs: usize, tail: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for i in 0..paragraphs {
|
||||
@@ -735,9 +535,6 @@ mod apply_tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// `(Widget::draw` calls, text layouts) caused by one streamed delta
|
||||
/// landing in the last paragraph of a reply that already has
|
||||
/// `paragraphs` of them.
|
||||
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -764,18 +561,6 @@ mod apply_tests {
|
||||
(draws, shapes)
|
||||
}
|
||||
|
||||
/// The pass condition for the per-block row: a delta
|
||||
/// costs the **last block**, not the message. A 3,000-character reply
|
||||
/// has a hundred paragraphs already laid out; redrawing one delta into it
|
||||
/// must cost exactly what the same delta costs in a one-paragraph
|
||||
/// reply, or the earlier blocks are being re-shaped.
|
||||
///
|
||||
/// Before the split this was one `TextEdit` for the whole message, so
|
||||
/// the count was the same *number* of widgets but each redraw
|
||||
/// re-shaped every paragraph through parley -- which a draw counter
|
||||
/// cannot see. What it can see is that the count does not *grow* with
|
||||
/// the message, which it now does not and could not before, since the
|
||||
/// one widget's own layout was O(message).
|
||||
#[test]
|
||||
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
|
||||
assert!(
|
||||
@@ -789,12 +574,6 @@ mod apply_tests {
|
||||
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
|
||||
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
|
||||
);
|
||||
// The half a draw counter cannot see, and the one the per-block
|
||||
// row actually exists for: a redraw is free if the text engine
|
||||
// hits its memo, and a re-shape is the expensive thing. One
|
||||
// shape, whatever the message is worth -- the block the delta
|
||||
// landed in. Before the split this was necessarily O(message),
|
||||
// since the whole reply was one buffer.
|
||||
assert_eq!(
|
||||
(short_shapes, long_shapes),
|
||||
(1, 1),
|
||||
@@ -812,9 +591,6 @@ mod apply_tests {
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// Same regroup shape as diff_tests' regroup case, plus a trailing
|
||||
// row (seq 4) that survives unchanged -- what a reader would tap
|
||||
// on right after the regroup lands.
|
||||
let old_items = vec![tool(1, "run-a"), user(2, "meanwhile"), user(4, "stable")];
|
||||
let new_items = vec![tool(1, "run-a"), tool(3, "run-a"), user(4, "stable")];
|
||||
assert_eq!(
|
||||
@@ -826,10 +602,6 @@ mod apply_tests {
|
||||
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
|
||||
screen.apply(&mut rsc, &old_items, &new_items);
|
||||
|
||||
// The surviving row (seq 4) is what a reader's long-press would
|
||||
// land on; `begin` deselects every *other* registered row first,
|
||||
// which is exactly what used to resolve a stale `WeakWidget` left
|
||||
// by the regrouped-away rows and panic.
|
||||
let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(4));
|
||||
screen.selection.borrow_mut().begin(
|
||||
&mut rsc,
|
||||
@@ -839,15 +611,6 @@ mod apply_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure half of the per-block row, and the one
|
||||
/// `a_row_dropped_by_a_regroup_...` cannot reach: the tail row is
|
||||
/// rebuilt under the **same key** with *fewer* blocks than it had.
|
||||
/// `Selection` is keyed by `(row, block)`, so the blocks that no
|
||||
/// longer exist are left pointing at widgets `replace_back`'s drop
|
||||
/// frees -- and `begin` resolves every registered handle on an
|
||||
/// ordinary press, so the next tap anywhere in the transcript
|
||||
/// panics. Nothing about the key changed, which is why the
|
||||
/// `if new_key != old_key` guard this replaced could not see it.
|
||||
#[test]
|
||||
fn a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection() {
|
||||
use crate::client::transcript_fold::group_tool_runs;
|
||||
@@ -856,9 +619,6 @@ mod apply_tests {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
// Three blocks, then one. The rewrite is of an *earlier* block
|
||||
// (the heading), so `RowBlocks::apply_delta` refuses it and the
|
||||
// rebuild path is the one taken -- assert that below.
|
||||
let old_items = vec![user(1, "stable"), assistant(2, "# Head\n\npara\n\n- item")];
|
||||
let new_items = vec![user(1, "stable"), assistant(2, "short")];
|
||||
assert_eq!(
|
||||
@@ -890,8 +650,6 @@ mod apply_tests {
|
||||
"the blocks the rebuild dropped are still registered"
|
||||
);
|
||||
|
||||
// What a reader does next: press the row that survived. `begin`
|
||||
// resolves every registered handle, so a stale one panics here.
|
||||
let surviving_key = row::row_key(&crate::client::transcript_fold::ItemKey::Seq(1));
|
||||
screen.selection.borrow_mut().begin(
|
||||
&mut rsc,
|
||||
@@ -901,7 +659,6 @@ mod apply_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A tool call with `output` bytes of output, `done` or not.
|
||||
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 1,
|
||||
@@ -923,9 +680,6 @@ mod apply_tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A screen holding one tool run, with the group opened the way a tap
|
||||
/// opens it, plus the counters drained -- so what a caller measures
|
||||
/// next is only what it asked for.
|
||||
fn open_run(
|
||||
rsc: &mut TestRsc,
|
||||
items: &[TranscriptItem],
|
||||
@@ -944,9 +698,6 @@ mod apply_tests {
|
||||
(screen, tree, render)
|
||||
}
|
||||
|
||||
/// The text shapes it costs to *open* a group of three cards whose
|
||||
/// calls carry `output` -- the cards themselves, since the collapsed
|
||||
/// group before the expansion drew none.
|
||||
fn shapes_to_open(output: &str) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -971,16 +722,6 @@ mod apply_tests {
|
||||
shapes
|
||||
}
|
||||
|
||||
/// **The O(last block) discipline, for tool cards** (RUST.md's P1b).
|
||||
/// A collapsed card draws its summary line and nothing else, so the
|
||||
/// kilobyte outputs the bench fixture carries cost nothing until
|
||||
/// somebody opens one. Counted in *text shapes*, the number a draw
|
||||
/// counter cannot stand in for: the widgets are the same either way,
|
||||
/// and it is parley's work that would grow with the output.
|
||||
///
|
||||
/// The group is *opened* here, so all three cards are really drawn --
|
||||
/// the cheap version of this test (a closed group, which draws no
|
||||
/// cards at all) would pass without saying anything about a card.
|
||||
#[test]
|
||||
fn collapsed_cards_shape_only_their_summary_lines() {
|
||||
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
|
||||
@@ -1000,11 +741,6 @@ mod apply_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The text shapes a screen holding `text` as its first message costs
|
||||
/// to draw. A second, tiny row follows it, so the message under test
|
||||
/// is **not** the tail -- the tail is deliberately uncapped
|
||||
/// (`row::build_row`'s `cap`), and measuring it would measure the one
|
||||
/// row the cap does not apply to.
|
||||
fn shapes_for_message(text: &str) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -1033,15 +769,6 @@ mod apply_tests {
|
||||
shapes
|
||||
}
|
||||
|
||||
/// **A long message is drawn as far as the cap and no further**
|
||||
/// (Iris, 2026-09-08). Counted in text shapes rather than draws,
|
||||
/// because that is the cost that grows with the message: every block
|
||||
/// past the cap is a parley layout of text nobody asked for.
|
||||
///
|
||||
/// The bound is the cap's own, not the short message's -- the capped
|
||||
/// row genuinely draws more than a two-word one -- so this asserts
|
||||
/// that the cost stops growing with the message rather than that it
|
||||
/// is zero.
|
||||
#[test]
|
||||
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
|
||||
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
|
||||
@@ -1058,9 +785,6 @@ mod apply_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// What one arriving result costs, in `Widget::draw` calls, in a run of
|
||||
/// `count` calls -- with the group open, so every card is really on
|
||||
/// screen and a rebuild of the wrong scope would show.
|
||||
fn cost_of_one_result(count: usize) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -1082,13 +806,6 @@ mod apply_tests {
|
||||
draws
|
||||
}
|
||||
|
||||
/// **A result changes one card**, whatever else is in the run --
|
||||
/// `RowBlocks::apply_delta`'s discipline applied to a group, which is
|
||||
/// a column of cards (`tool::ToolRow::apply_calls`). Stated as a
|
||||
/// comparison rather than a number, because the number is whatever a
|
||||
/// card happens to be made of and would have to be edited every time
|
||||
/// the card gains a widget; what must not change is that it does not
|
||||
/// grow with the run.
|
||||
#[test]
|
||||
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
|
||||
let small = cost_of_one_result(3);
|
||||
@@ -1104,9 +821,6 @@ mod apply_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The group's own state: opening it draws the cards, closing it takes
|
||||
/// them away again, and the reader's choice survives a result arriving
|
||||
/// in the middle of it.
|
||||
#[test]
|
||||
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1125,15 +839,10 @@ mod apply_tests {
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
|
||||
// Closed, a group is one line: no card is registered at all, which
|
||||
// is what makes the kilobyte outputs free.
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
assert!(screen.expand_tail_tools(&mut rsc, true));
|
||||
assert_eq!(screen.tail_card_count(), 3);
|
||||
|
||||
// A result arriving must not close what the reader opened -- the
|
||||
// card is rebuilt, and being open is the reader's state rather
|
||||
// than the event's.
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(screen.take_rebuilds(), 0);
|
||||
@@ -1147,10 +856,6 @@ mod apply_tests {
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
}
|
||||
|
||||
/// A call that joins a run while it is the live row appends one card
|
||||
/// rather than rebuilding the row -- the other half of `apply_calls`,
|
||||
/// and the case a page join does *not* produce (that one goes through
|
||||
/// `Rebuild`).
|
||||
#[test]
|
||||
fn a_call_joining_an_open_run_appends_one_card() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1173,10 +878,6 @@ mod apply_tests {
|
||||
assert_eq!(screen.tail_card_count(), 3);
|
||||
}
|
||||
|
||||
/// A tool row that becomes something else is a different row, not a
|
||||
/// changed one. Without the guard in `apply_calls` a `UserMsg` would
|
||||
/// reach the card builder, whose `debug_assert` is the last line of
|
||||
/// defence rather than the first.
|
||||
#[test]
|
||||
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1195,8 +896,6 @@ mod apply_tests {
|
||||
0,
|
||||
"this is a ReplaceLast, not a whole-screen rebuild"
|
||||
);
|
||||
// The row that replaced it is a message, so it keeps blocks rather
|
||||
// than cards -- and nothing panicked on the way.
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,3 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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};
|
||||
@@ -32,16 +8,8 @@ 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.
|
||||
@@ -123,25 +91,11 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
|
||||
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
|
||||
/// (decided 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 --
|
||||
@@ -174,13 +128,6 @@ fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
||||
/// `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
|
||||
@@ -200,12 +147,7 @@ fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
|
||||
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,
|
||||
@@ -213,13 +155,6 @@ fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
|
||||
});
|
||||
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);
|
||||
@@ -240,26 +175,10 @@ struct RowSource {
|
||||
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>,
|
||||
@@ -278,10 +197,6 @@ where
|
||||
.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
|
||||
@@ -298,25 +213,6 @@ where
|
||||
|
||||
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(
|
||||
@@ -347,16 +243,8 @@ where
|
||||
})
|
||||
.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))
|
||||
@@ -364,13 +252,6 @@ where
|
||||
.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 {
|
||||
@@ -385,20 +266,6 @@ where
|
||||
(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>,
|
||||
@@ -422,9 +289,6 @@ where
|
||||
(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`.
|
||||
@@ -499,9 +363,6 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -551,10 +412,6 @@ impl RowBlocks {
|
||||
/// 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,
|
||||
@@ -587,12 +444,6 @@ impl RowBlocks {
|
||||
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
|
||||
@@ -614,9 +465,6 @@ impl RowBlocks {
|
||||
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;
|
||||
}
|
||||
_ => {
|
||||
@@ -624,9 +472,6 @@ impl RowBlocks {
|
||||
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);
|
||||
}
|
||||
@@ -653,19 +498,12 @@ where
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -725,9 +563,6 @@ mod tests {
|
||||
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);
|
||||
@@ -736,8 +571,6 @@ mod tests {
|
||||
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);
|
||||
@@ -755,11 +588,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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));
|
||||
|
||||
@@ -1,66 +1,12 @@
|
||||
//! 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`), 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 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>>,
|
||||
}
|
||||
|
||||
@@ -80,10 +26,6 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
@@ -101,23 +43,11 @@ impl Selection {
|
||||
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, 2026-09-06 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) {
|
||||
@@ -145,8 +75,6 @@ impl Selection {
|
||||
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;
|
||||
@@ -168,9 +96,6 @@ impl Selection {
|
||||
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);
|
||||
@@ -204,17 +129,6 @@ impl Selection {
|
||||
.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,
|
||||
@@ -232,25 +146,12 @@ impl Selection {
|
||||
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
|
||||
@@ -277,17 +178,6 @@ impl Selection {
|
||||
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 -- \
|
||||
@@ -311,10 +201,6 @@ impl Selection {
|
||||
// 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);
|
||||
@@ -322,12 +208,6 @@ impl Selection {
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -346,11 +226,6 @@ impl Selection {
|
||||
// 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 (review, 2026-09-07).
|
||||
if let Some(scroll) = self.scroll
|
||||
&& scroll(ui).fling(v)
|
||||
{
|
||||
@@ -358,9 +233,6 @@ impl Selection {
|
||||
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
|
||||
@@ -388,10 +260,6 @@ impl Selection {
|
||||
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)
|
||||
@@ -434,19 +302,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -465,8 +320,6 @@ mod tests {
|
||||
.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
|
||||
@@ -482,8 +335,6 @@ mod tests {
|
||||
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,
|
||||
@@ -515,9 +366,6 @@ mod tests {
|
||||
.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));
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
//! 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>,
|
||||
@@ -31,9 +11,6 @@ pub(crate) fn on_tap<Rsc: HasEvents>(
|
||||
) 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,
|
||||
@@ -51,29 +28,7 @@ pub(crate) fn on_tap<Rsc: HasEvents>(
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,3 @@
|
||||
//! 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};
|
||||
@@ -41,79 +7,34 @@ 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,
|
||||
@@ -121,23 +42,12 @@ struct ToolRowState {
|
||||
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>,
|
||||
@@ -146,18 +56,10 @@ struct Shared {
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -176,26 +78,10 @@ fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuild
|
||||
.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,
|
||||
@@ -214,18 +100,8 @@ where
|
||||
.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)),
|
||||
@@ -258,12 +134,6 @@ 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),
|
||||
@@ -272,7 +142,6 @@ fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -283,8 +152,6 @@ fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
|
||||
.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
|
||||
@@ -327,11 +194,6 @@ where
|
||||
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>,
|
||||
@@ -356,9 +218,6 @@ where
|
||||
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 {
|
||||
@@ -367,10 +226,6 @@ where
|
||||
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,
|
||||
@@ -389,9 +244,6 @@ where
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -407,15 +259,9 @@ where
|
||||
.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)
|
||||
@@ -440,9 +286,6 @@ where
|
||||
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))
|
||||
@@ -450,13 +293,6 @@ where
|
||||
.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;
|
||||
@@ -465,17 +301,11 @@ where
|
||||
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);
|
||||
@@ -509,15 +339,11 @@ where
|
||||
.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;
|
||||
};
|
||||
@@ -525,10 +351,6 @@ where
|
||||
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>,
|
||||
@@ -537,9 +359,6 @@ fn build_card_ptr<Rsc: HasEvents>(
|
||||
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);
|
||||
@@ -576,13 +395,6 @@ where
|
||||
(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,
|
||||
@@ -609,9 +421,6 @@ where
|
||||
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.
|
||||
@@ -623,9 +432,6 @@ where
|
||||
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;
|
||||
}
|
||||
@@ -652,20 +458,6 @@ where
|
||||
.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 {
|
||||
@@ -673,9 +465,6 @@ where
|
||||
}
|
||||
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))
|
||||
@@ -697,8 +486,6 @@ where
|
||||
}
|
||||
|
||||
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!(
|
||||
@@ -722,8 +509,6 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -749,8 +534,6 @@ where
|
||||
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);
|
||||
@@ -767,16 +550,11 @@ impl ToolRow {
|
||||
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
|
||||
@@ -794,17 +572,6 @@ impl ToolRow {
|
||||
/// 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,
|
||||
@@ -814,9 +581,6 @@ impl ToolRow {
|
||||
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()
|
||||
@@ -828,17 +592,12 @@ impl ToolRow {
|
||||
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 {
|
||||
@@ -852,9 +611,6 @@ impl ToolRow {
|
||||
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);
|
||||
@@ -871,12 +627,6 @@ impl ToolRow {
|
||||
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);
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
//! 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");
|
||||
@@ -27,10 +11,6 @@ fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
(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);
|
||||
@@ -46,17 +26,10 @@ fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey)
|
||||
.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,
|
||||
@@ -101,10 +74,6 @@ fn drag_from(
|
||||
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();
|
||||
@@ -113,10 +82,6 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
||||
.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,
|
||||
@@ -132,11 +97,6 @@ fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
||||
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();
|
||||
@@ -177,10 +137,6 @@ fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -188,7 +144,6 @@ fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
||||
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,
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
//! 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
|
||||
@@ -86,12 +71,8 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
.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);
|
||||
@@ -104,7 +85,6 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
"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);
|
||||
@@ -116,7 +96,6 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
"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);
|
||||
@@ -132,19 +111,6 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
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};
|
||||
@@ -179,7 +145,6 @@ fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||
.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);
|
||||
@@ -191,10 +156,6 @@ fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||
"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)
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
//! 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::*;
|
||||
@@ -24,12 +14,6 @@ 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);
|
||||
@@ -45,11 +29,6 @@ fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey)
|
||||
.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();
|
||||
@@ -65,9 +44,6 @@ fn a_cancelled_flick_does_not_fling() {
|
||||
"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();
|
||||
@@ -82,24 +58,15 @@ fn a_cancelled_flick_does_not_fling() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -116,20 +83,6 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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};
|
||||
@@ -142,10 +95,6 @@ fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||
.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 \
|
||||
@@ -159,8 +108,6 @@ fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||
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)
|
||||
@@ -171,7 +118,6 @@ fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||
"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);
|
||||
@@ -180,8 +126,6 @@ fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||
|
||||
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(¶.key());
|
||||
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(para_key)
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
//! 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 the 2026-09-07 review 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)>>,
|
||||
}
|
||||
@@ -53,16 +23,10 @@ impl log::Log for CaptureLogger {
|
||||
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
|
||||
@@ -84,8 +48,6 @@ fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
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();
|
||||
@@ -104,8 +66,6 @@ fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||
"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);
|
||||
@@ -134,20 +94,12 @@ fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||
"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"),
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
//! 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");
|
||||
@@ -31,11 +18,6 @@ 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();
|
||||
@@ -47,41 +29,16 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
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
|
||||
// (review, 2026-09-07'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;
|
||||
@@ -111,8 +68,6 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
);
|
||||
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"
|
||||
@@ -121,19 +76,12 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
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();
|
||||
@@ -146,7 +94,6 @@ fn a_tap_on_a_row_moves_nothing() {
|
||||
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!(
|
||||
@@ -156,9 +103,6 @@ fn a_tap_on_a_row_moves_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -183,11 +127,6 @@ fn a_long_press_and_drag_selects_text() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -206,8 +145,6 @@ fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||
"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);
|
||||
@@ -225,11 +162,6 @@ fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A trailing space is narrower than the composer's available width but can
|
||||
/// wrap when the field is measured again in its own reported width. The
|
||||
/// settling walk must not bounce forever between those one- and two-line
|
||||
/// answers. On Android that recursion exhausted the native UI thread's stack
|
||||
/// and ended in SIGSEGV, before Rust's panic hook could write anything.
|
||||
#[test]
|
||||
fn a_space_in_the_composer_finishes_layout() {
|
||||
let (mut h, screen) = opened();
|
||||
@@ -259,23 +191,6 @@ fn a_space_in_the_composer_finishes_layout() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -286,8 +201,6 @@ fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||
|
||||
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);
|
||||
@@ -297,13 +210,9 @@ fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||
.debug(h.rsc.widgets(), "Message")
|
||||
.find(|a| !a.primitives.is_empty())
|
||||
.expect("the composer field is drawn");
|
||||
// The caret is the last primitive `TextEdit::draw` emits.
|
||||
let caret = h
|
||||
.render
|
||||
.primitive_corners(message.primitives.last().unwrap().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!(
|
||||
@@ -313,9 +222,6 @@ fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||
caret.bot_right.y,
|
||||
);
|
||||
|
||||
// The old assertion only guarded the last line. A viewport one line
|
||||
// shorter than the field still kept that caret above the bottom while
|
||||
// moving the first line above the bar's mask, visibly slicing it off.
|
||||
let mask = h.rsc.ui.masks[message.mask.idx()];
|
||||
let bar = h.render.primitive_corners(mask.primitive, &h.rsc);
|
||||
let visible_content_top = message
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
//! 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);
|
||||
|
||||
@@ -36,17 +20,12 @@ fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
(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
|
||||
@@ -62,32 +41,18 @@ fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f
|
||||
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);
|
||||
@@ -108,16 +73,6 @@ fn the_row_across_the_top_edge_is_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// (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();
|
||||
@@ -134,11 +89,6 @@ fn the_list_is_clipped_to_its_own_box() {
|
||||
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 (review, 2026-09-07's T3).
|
||||
let rows = h
|
||||
.render
|
||||
.active
|
||||
@@ -165,9 +115,6 @@ fn the_list_is_clipped_to_its_own_box() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -184,7 +131,6 @@ fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
||||
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;
|
||||
@@ -199,31 +145,12 @@ fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
||||
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
|
||||
/// (review, 2026-09-07'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",
|
||||
@@ -258,10 +185,6 @@ fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -287,12 +210,6 @@ fn the_row_across_the_bottom_edge_is_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -302,10 +219,6 @@ fn scrolling_past_the_first_row_settles_on_it() {
|
||||
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!(
|
||||
@@ -315,10 +228,6 @@ fn scrolling_past_the_first_row_settles_on_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
Reference in new issue
Block a user