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
@@ -196,10 +196,9 @@ guaranteed to have.
|
||||
own suite, which is slower and not about this product. Each workspace
|
||||
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
|
||||
warning-clean and rustfmt-clean at the defaults — there is no
|
||||
`rustfmt.toml` and there should not be one. `app-rust/` and `iris/` are
|
||||
pinned to the same dated nightly (`rust-toolchain.toml`, one copy each,
|
||||
because a pin applies per directory); `server/` and `event-model/` are
|
||||
stable.
|
||||
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
|
||||
UI profiling rig use the rolling nightly channel through per-directory
|
||||
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
|
||||
- **App**: from `app/`,
|
||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||
|
||||
+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();
|
||||
|
||||
+55
-202
@@ -1,221 +1,74 @@
|
||||
# `app-rust`'s `client` module
|
||||
|
||||
**Renamed 2026-09-08.** This was the `client-core` crate; it is now
|
||||
`app-rust/src/client/`, a module of the one app crate rather than a crate
|
||||
of its own (docs/RUST.md's "One app crate"). Nothing about what it *is*
|
||||
changed: it is still the app's pure logic held once instead of twice, per
|
||||
RUST.md's recommendation item 1, and still has **no UI framework
|
||||
dependency of any kind** -- the `iris` dependency sits behind the `screens`
|
||||
feature and nothing under `src/client` may reach it. That independence is
|
||||
what lets it outlive whichever framework the app draws with, and it is now
|
||||
an invariant of a module rather than of a manifest, so it is worth stating
|
||||
plainly: a `use iris::` under `src/client/` is a defect.
|
||||
`app-rust/src/client` contains platform- and UI-independent client logic. It
|
||||
must not depend on iris; a `use iris::` below this directory is a layering
|
||||
defect. `event-model` remains a separate crate because the server and client
|
||||
both depend on that wire contract.
|
||||
|
||||
`event-model/` stayed a crate, and is the one split in the port that was
|
||||
never optional: it is the wire shape both this app and `server/` depend on,
|
||||
extracted from `server/src/session/driver.rs` and `session/transcript.rs`
|
||||
on 2026-09-04, so a crate is what makes the two agree by construction.
|
||||
## Contents
|
||||
|
||||
Paths below are written as `src/client/…`, relative to `app-rust/`.
|
||||
- `api.rs`: REST client over the injectable `Transport` trait.
|
||||
- `sse.rs` and `event_stream.rs`: SSE framing and session-event following.
|
||||
- `transcript_cache.rs`: bounded, persistent transcript chunks.
|
||||
- `transcript_source.rs`: cache/server selection and live cache updates.
|
||||
- `transcript_fold.rs`: event folding, tool grouping, and page healing.
|
||||
- `markdown_blocks.rs`, `ansi.rs`, and `highlight/`: display-independent text
|
||||
parsing and spans.
|
||||
- `config.rs`: enrollment-link parsing and the shared `EnrolledServer` value.
|
||||
- `log_ring.rs`: bounded process-local diagnostics.
|
||||
|
||||
## What's here, and what Kotlin file it replaces
|
||||
## API coverage
|
||||
|
||||
| `src/client/…` | Kotlin original | Status |
|
||||
|------------------------------------------|-------------------------------------------|--------|
|
||||
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
||||
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
||||
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
||||
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
||||
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
|
||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||
| `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
|
||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||
`ApiClient` covers session list/read, messages, unqueue, answers, interrupt,
|
||||
stop/start, rename, working directory, model, permission mode, notification
|
||||
setting, commands, compaction, deletion, and transcript pages.
|
||||
|
||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||
cases ported alongside it, plus new tests for the pieces that had none
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
|
||||
`transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
|
||||
had no JVM unit tests of their own, so these were written fresh against the
|
||||
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
|
||||
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
|
||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||
them via `session::transcript`'s round-trip coverage).
|
||||
Still missing are setups and discovery, file operations, usage, models and
|
||||
downloads, attachments, imports, and the global notifications stream.
|
||||
`server/src/routes.rs` is the authoritative route table.
|
||||
|
||||
## Correspondence notes worth knowing before touching either side
|
||||
## Transcript invariants
|
||||
|
||||
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
||||
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
||||
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
||||
non-overlapping spans. Whatever UI framework ends up consuming this
|
||||
crate maps `Style` onto its own text-styling type; nothing here should
|
||||
change to accommodate a particular one.
|
||||
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
||||
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
||||
strings. `highlight::span_text` turns a `Span` back into text for a
|
||||
caller working the same way; a caller that wants byte offsets into a
|
||||
`&str` has to convert.
|
||||
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
||||
translation bug while it was being written: an early draft let a
|
||||
*damaged* chunk (one file unreadable, discard just this session) and a
|
||||
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
||||
the same `Err` from one closure, which would have disabled every
|
||||
session's cache over a single corrupt chunk. Fixed by checking a
|
||||
thread-local "was this damage" flag before deciding which failure mode
|
||||
it was -- see the comment on `guard` and the commit message for
|
||||
`transcript_cache.rs`.
|
||||
`join_pages` heals messages and tool runs split across page boundaries. It
|
||||
asserts that a tool id does not survive in both halves. It must not assert
|
||||
sequence ordering across the seam: a peer note carries the sequence of the
|
||||
turn it belongs above and can legitimately interleave with the page where it
|
||||
arrived.
|
||||
|
||||
## What `api.rs` covers, and what it does not yet
|
||||
`Event` has no catch-all variant. A newer server adding an event type will
|
||||
make an older client reject that line rather than draw a placeholder. Fixing
|
||||
that requires a shared wire-model decision, not a client-only workaround.
|
||||
|
||||
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
||||
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
||||
fake transport and no server). `UreqTransport` is the only real
|
||||
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
||||
(blocking, already a project dependency, no extra TLS crate needed since
|
||||
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
||||
`TranscriptSource::page(0, ..)` returns `OlderPage::NothingLoaded` without
|
||||
touching cache or network. This is deliberately distinct from
|
||||
`OlderPage::Events(vec![])`, which means the start of the conversation was
|
||||
actually reached. Network and cache parse failures are errors for the same
|
||||
reason: none of these states may latch a caller's “no more history” flag.
|
||||
|
||||
Covered: session list/read, message send, unqueue, answer, interrupt,
|
||||
stop, start, rename, cwd, model, permission-mode, notify, command,
|
||||
compact, delete, and one transcript page.
|
||||
Fetched transcript lines retain the server's exact JSON bytes through
|
||||
`RawValue`. Re-serializing parsed JSON can change floating-point text, causing
|
||||
the cached and streamed forms of one event to disagree byte-for-byte.
|
||||
|
||||
**Not covered, and each is real work rather than a stub to fill in:**
|
||||
setups (`/setups*`, machine and provider discovery), the file explorer
|
||||
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
||||
(`/models*`, HuggingFace browsing and downloads), attachments
|
||||
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
||||
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
||||
the full table to work from when one of these is next.
|
||||
The reconnect/backoff loop and cancellation of a live stream belong to the
|
||||
embedding runtime. `TranscriptSource::follow` only guarantees that each frame
|
||||
is cached before the caller receives it.
|
||||
|
||||
## What `transcript_fold.rs` covers, and what it does not yet
|
||||
## Enrollment
|
||||
|
||||
`fold_event` covers every `Event` variant server/ can produce today,
|
||||
including tool-call/question/image attachment and peer-message placement.
|
||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||
`EnrolledServer` and `parse_link` understand the same
|
||||
`aiapp://enroll?host=H&port=P&token=T[&ca=B]` value used by Android. Storage
|
||||
is caller-specific: Android uses its platform storage and the desktop writes a
|
||||
0600 file under its XDG config directory.
|
||||
|
||||
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
|
||||
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
|
||||
call split across two fetched pages, rejoins a message a boundary cut
|
||||
through, and renames a run of tool calls onto whichever name is already on
|
||||
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
|
||||
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
|
||||
own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||||
is the regression test for the bug that shipped -- `adopt_run` must run on
|
||||
*every* join, not only the one where a split call was found, or a boundary
|
||||
landing cleanly between two already-finished calls (most of them) leaves
|
||||
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
||||
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
||||
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
||||
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||
that no tool id survives in both halves -- the duplicate row it exists to
|
||||
prevent, checked rather than assumed. What it deliberately does *not*
|
||||
assert is seq ordering across the boundary: a peer note carries the seq
|
||||
its turn began at (`place_peer_note`), which can be older than the page
|
||||
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||
earlier draft asserted it and would have panicked in debug builds on an
|
||||
ordinary transcript.
|
||||
## Markdown scope
|
||||
|
||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||
A server newer than this build that adds an event type will fail to parse
|
||||
that line rather than degrading to a placeholder row. Closing this means
|
||||
deciding how `event_model` itself represents "a shape I don't recognise"
|
||||
-- a shared-model decision affecting `server/` too, not a `client`-only
|
||||
fix, so it is recorded here rather than silently worked around.
|
||||
`markdown_blocks` splits top-level headings, paragraphs, fences, lists,
|
||||
tables, and quotes. It intentionally does not build a full nested CommonMark
|
||||
AST; inline styling and nested presentation remain renderer concerns until a
|
||||
shared non-UI consumer needs them.
|
||||
|
||||
## `config.rs`: `EnrolledServer`
|
||||
## Verification
|
||||
|
||||
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
||||
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
||||
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
||||
parses on the phone -- so any Rust client enrols from the same text a
|
||||
phone would scan as a QR, with no second format invented for it (RUST.md's
|
||||
E4, decided 2026-09-05). Deliberately does not decide where it is
|
||||
persisted or under what file permissions -- a phone seals its token in the
|
||||
Android Keystore, `src/desktop/config.rs` writes it to
|
||||
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
||||
caller-specific (the code rules' "ask for the least you need"). Its only
|
||||
caller today is `src/desktop`; the Android entry point is a second one, not
|
||||
a reason to move the type.
|
||||
|
||||
## What `transcript_source.rs` covers, and what it does not
|
||||
|
||||
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
|
||||
page, ported test-for-test against the Kotlin doc rather than a JVM test
|
||||
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
|
||||
`page` and `follow`, each matching its Kotlin namesake's contract --
|
||||
including `probe`'s three-way outcome (matches / cache purged /
|
||||
unreachable, told apart so a caller never treats "couldn't ask" as "was
|
||||
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
|
||||
|
||||
Two additions beyond a literal port, both load-bearing:
|
||||
|
||||
- **`page(before, ..)` refuses `before == 0` before touching the cache or
|
||||
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
|
||||
`loadOlderPage` incident (`before = 0` is "no event before the first
|
||||
one," indistinguishable from "reached the start of history" if a caller
|
||||
ever asks it) moved out of the Kotlin screen and into this layer, so
|
||||
every future caller gets the guard rather than having to remember it.
|
||||
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
|
||||
The Kotlin's two falses are different answers -- `oldestSeq == 0`
|
||||
returns without touching `moreHistory`, an empty page latches it false
|
||||
-- so a port that answered both with an empty list would have moved the
|
||||
bug rather than fixed it, one layer down and out of sight of the screen
|
||||
that used to hold the check. `OlderPage::Events(vec![])` means the start
|
||||
of the conversation; `OlderPage::NothingLoaded` is not an answer about
|
||||
the conversation at all. Reviewed 2026-09-06.
|
||||
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
|
||||
transport calls, not just the variant, since a request that happens to
|
||||
answer empty is exactly what caused the original bug, and
|
||||
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
|
||||
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
|
||||
are the same rule for the two ways a page can fail.
|
||||
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
|
||||
paired with the exact server bytes it came from, via
|
||||
`serde_json::value::RawValue` rather than re-serializing a parsed
|
||||
`Value` -- the cache and a live SSE frame for the same event have to
|
||||
agree byte-for-byte, which is exactly what the `serde_json`
|
||||
float-rounding bug (AGENTS.md) was about. The existing
|
||||
`fetch_transcript_page` is untouched (other callers under `iris/`
|
||||
depend on its signature); the two share a `transcript_path` helper so
|
||||
the query string is written in one place.
|
||||
|
||||
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
|
||||
`TranscriptSource.close`'s ability to cancel a live stream from another
|
||||
thread. Both are wall-clock/thread-lifetime policy that belongs to
|
||||
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
|
||||
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
|
||||
"write to the cache, then hand the frame to the caller" decorator
|
||||
`src/desktop/app.rs` and `src/android/transcript_client.rs`
|
||||
already hand-wrote around `event_stream::follow_session_events` before this
|
||||
existed; the cache write moved into one shared place so a third caller
|
||||
does not repeat it again by hand.
|
||||
|
||||
## What is not started at all
|
||||
|
||||
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
|
||||
into its *top-level* blocks -- heading, paragraph, fence, list, table,
|
||||
quote -- with each block's own source, which is what a renderer needs to
|
||||
lay out prose versus code and what lets a streamed delta re-lay out one
|
||||
block instead of the message (docs/RUST.md's Task B). What it
|
||||
deliberately does **not** build is the tree below that: nested list
|
||||
items, table cells, inline spans. Inline styling is still the renderer's
|
||||
own job per block (`src/ui/markdown.rs`), and nothing
|
||||
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
|
||||
for a full CommonMark AST is Compose rendering plumbing, not something
|
||||
to port as-is.
|
||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||
it flattens a row into bounded units for a *specific* lazy-list
|
||||
framework's composition cost, which is a fact about that framework
|
||||
rather than about the transcript.
|
||||
|
||||
## Verifying
|
||||
|
||||
`./scripts/run-tests.sh` from the repo root runs `event-model`, `server` and
|
||||
`app-rust` in that order (each `cargo test`, forwarding arguments the same
|
||||
way it always has). From `app-rust/` directly: `cargo test`, `cargo clippy
|
||||
--all-targets`, `cargo fmt` -- all clean as of 2026-09-08, 229 tests across
|
||||
the crate and its headless harness suites.
|
||||
Run `./scripts/run-tests.sh` from the repository root. For this crate alone,
|
||||
run `cargo test`, `cargo clippy --all-targets`, and `cargo fmt --check` from
|
||||
`app-rust/`.
|
||||
+10
-28
@@ -1,14 +1,6 @@
|
||||
# iris: known problems and things still to build
|
||||
|
||||
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
||||
matters, so the work in RUST.md picks these up in a sensible order rather
|
||||
than rediscovering them. Each item says where it sits in the order and
|
||||
what "done" looks like.
|
||||
|
||||
**Only open items live here.** An item is deleted when it lands, not
|
||||
ticked: a list of finished work is context every future session pays for,
|
||||
and what a change did belongs at the code it changed. Fifty closed items
|
||||
and six phone-report sections went on 2026-09-08 for that reason.
|
||||
Only open Iris framework work lives here. Delete an item when it lands.
|
||||
|
||||
## Fix
|
||||
|
||||
@@ -20,12 +12,8 @@ and six phone-report sections went on 2026-09-08 for that reason.
|
||||
(17,17,27) became (73,73,91) in a desktop screenshot measured on
|
||||
2026-09-06.
|
||||
|
||||
The earlier entry called this desktop-only because the Android picture
|
||||
looked right. That was not a measurement: neither the startup line nor
|
||||
the diagnostics report records the selected surface format, and the
|
||||
Android backend contains the identical preference and shader path. A
|
||||
device exposing only a non-sRGB surface can happen to hide the bug; it
|
||||
does not make the pipeline correct.
|
||||
Neither diagnostics nor startup logging records Android's selected surface
|
||||
format. A device exposing only a non-sRGB surface can hide the shared bug.
|
||||
|
||||
Done means defining one convention for palette bytes, decoded images,
|
||||
colour emoji and the clear colour, then converting exactly once for the
|
||||
@@ -33,12 +21,10 @@ and six phone-report sections went on 2026-09-08 for that reason.
|
||||
test that draws known non-black, non-white pixels into an sRGB target and
|
||||
reads the stored bytes back; screenshots from desktop and Android then
|
||||
confirm the same Catppuccin values rather than serving as the definition.
|
||||
|
||||
## Build (for the port)
|
||||
|
||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||
iris does not have yet, one entry per gap, named against the P-step that
|
||||
first needs it. Move an entry up to "Fix" if it becomes a current defect;
|
||||
delete it once built rather than duplicating it there.
|
||||
Framework capabilities needed by `RUST.md`'s port plan:
|
||||
|
||||
- [ ] **Selectable, read-only text.** P0's report and P1's transcript rows
|
||||
use `TextEdit` because `Selectable` is implemented only for it. That
|
||||
@@ -88,18 +74,14 @@ delete it once built rather than duplicating it there.
|
||||
`Widget::tick` and `UiData::animate`. A widget that does not opt in must
|
||||
pay nothing and import nothing for them.
|
||||
|
||||
- [ ] **Remove `WidgetView` unless a real composite adopts it first.** The
|
||||
layout change this decision was waiting for has landed. Every composite
|
||||
in `app-rust/src/ui` now uses ordinary child handles plus a returned root;
|
||||
- [ ] **Remove `WidgetView` unless a real composite adopts it.** Every
|
||||
composite in `app-rust/src/ui` uses ordinary child handles plus a root;
|
||||
`WidgetView` and its derive are used only by `iris/examples/view.rs`.
|
||||
Today it demonstrates itself rather than shortening production code, so
|
||||
deletion is the concrete default—not another parallel composition style.
|
||||
It currently demonstrates itself rather than shortening production code.
|
||||
|
||||
- [ ] **A `Stack` that chooses its mask the way it chooses its size
|
||||
(Iris, 2026-09-08).** She asked whether `masked_by` deserves to exist:
|
||||
"a method that just does 2 separate things you can already easily do
|
||||
does not deserve to exist." For a square-cornered surface it is indeed
|
||||
redundant -- `.background(rect(BAR_FILL)).masked()` was measured
|
||||
should replace `masked_by`.** For a square-cornered surface,
|
||||
`.background(rect(BAR_FILL)).masked()` was measured
|
||||
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
|
||||
size and density and the two are identical to the pixel. What the pair
|
||||
cannot express is a clip that is not a box: `Painter::set_mask` writes
|
||||
|
||||
+50
-447
@@ -1,27 +1,8 @@
|
||||
# iris: one `draw` that records a size
|
||||
|
||||
Iris, 2026-09-04:
|
||||
|
||||
> I don't like that widgets need both a draw and size functions. I'd much
|
||||
> rather them have a single draw that reports a size, and if it needs to be
|
||||
> moved then that can be done after the fact efficiently, or resized just
|
||||
> done after as well. This should be done efficiently like everything else
|
||||
> tries to do right now.
|
||||
|
||||
**Implemented 2026-09-04; size dependencies made explicit 2026-09-09.**
|
||||
Every widget was migrated in one change; none kept
|
||||
`desired_width`/`desired_height`. `draw` no longer returns its size directly:
|
||||
it records it once on its `Painter`, and a parent that reads a child draw's
|
||||
`DrawResult::size()` records the retained dependency between them. What is
|
||||
kept below is the design as it stands, the corrections implementation forced
|
||||
(read those before
|
||||
touching `Aligned`, `Sized`, `MaxSize`, `Scroll` or the move-slot lifecycle
|
||||
in `render_state.rs` -- each is a real bug the first draft would have
|
||||
reproduced), and the two later additions that build on it. The
|
||||
pre-implementation framing -- what the old trait looked like, the checklist
|
||||
the design had to answer, the migration list, the pass conditions and the
|
||||
"copy this into the design log" note -- was deleted on 2026-09-08,
|
||||
having been carried out.
|
||||
A widget draws once and records its size on the `Painter`. Reading a child
|
||||
`DrawResult::size()` records a retained size dependency; drawing the child
|
||||
without reading that result does not make the parent's size depend on it.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -63,187 +44,36 @@ set in the context, which makes this slow and not cool." Folding sizing into
|
||||
an axis the widget declares without painter context or child access. A
|
||||
lying hint fails a debug assertion when the widget is drawn.
|
||||
|
||||
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
||||
### 2. O(1) subtree movement
|
||||
|
||||
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
||||
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
||||
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
||||
`active.region.size() == region.size()` after excluding the exact-match
|
||||
case). It rewrites every primitive's `region` field via
|
||||
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
||||
widget's own primitives, then recurses into every child — O(primitives in
|
||||
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
||||
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
||||
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
||||
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
||||
re-touches every glyph in every visible row, every frame of the drag, and
|
||||
I3's target is 800 rows on screen.
|
||||
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
|
||||
absolute-pixel delta and its parent slot; each primitive instance stores the
|
||||
slot of the widget that drew it. The vertex shader walks this bounded chain
|
||||
and adds the accumulated translation. Moving a subtree therefore writes one
|
||||
slot instead of rewriting every descendant primitive.
|
||||
|
||||
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
||||
resolved in the vertex shader.**
|
||||
The parent chain is required for independently movable nested subtrees, such
|
||||
as a swipeable row inside a scrolling list. A flat offset table would require
|
||||
rewriting the row whenever an ancestor moved and would restore the very
|
||||
O(subtree) work this design removes. Chain depth is bounded in both Rust and
|
||||
WGSL.
|
||||
|
||||
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
||||
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
||||
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
||||
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
||||
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
||||
add nothing more"). A pure abs-pixel translation, not a general
|
||||
`UiRegion` remap — sufficient for every existing call site (above).
|
||||
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
||||
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
||||
kind of per-instance handle.
|
||||
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
||||
assigned **when the widget is first drawn** (`draw_inner`, beside
|
||||
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
||||
`Painter` threads a `move_slot` field down exactly as it already threads
|
||||
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
||||
correct from its first frame — nothing is ever retrofitted onto an
|
||||
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
||||
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
||||
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
||||
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
||||
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
||||
recursion, since descendants already reference this slot transitively.
|
||||
- A container may also retain one optional **child-coordinate slot** between
|
||||
its own slot and every direct child's slot. `Painter::set_child_offset`
|
||||
creates that boundary before the first child is drawn and can update it
|
||||
after measuring a child on later redraws. The container's own primitives,
|
||||
hit region and mask stay fixed; its whole child subtree moves through one
|
||||
write and every existing GPU, hit-test and accessibility chain sees the
|
||||
same result. `LazySpan` uses this while still walking visible rows for
|
||||
virtualisation: row boxes stay in stable local coordinates and the shared
|
||||
boundary carries the changing screen translation.
|
||||
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
||||
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
||||
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
||||
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
||||
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
||||
paid every frame regardless of whether anything moved — negligible next
|
||||
to the per-fragment texture sampling TEXTURES.md already measures this
|
||||
GPU as not bound by.
|
||||
`Painter::set_child_offset` inserts a retained coordinate slot between a
|
||||
container and its direct children. `LazySpan` uses one so visible row boxes
|
||||
remain stable while scrolling changes a single shared translation. Ordinary
|
||||
window-relative positions remain `rel + abs`; move slots carry translation
|
||||
only, not general remapping.
|
||||
|
||||
**Why the chain, not the flatter thing first proposed.** Iris's own
|
||||
phrasing — "every instance carries an index into a small per-widget offset
|
||||
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
||||
no parent link. It breaks the moment two such subtrees nest — a row inside
|
||||
a scrolling list, itself later given its own animated offset (a
|
||||
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
||||
pick one slot and lose the other's contribution. The chain costs one extra
|
||||
field and a bounded shader loop in exchange for no such gap, and since
|
||||
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
||||
no more at the common depth of one than the flat version would.
|
||||
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
|
||||
hit-testing, accessibility, and public window-coordinate queries. Masks store
|
||||
the move slot of their owning widget and resolve it independently in the
|
||||
fragment shader, so a stationary viewport can clip moving content.
|
||||
|
||||
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
||||
O(primitives in the subtree) — the cost this section removes — but kept
|
||||
for a resize that changes a region's `rel` component (a genuine reflow,
|
||||
§3) and for a size-independent widget's resize (§3), where the content's
|
||||
shape doesn't change and one field write already suffices.
|
||||
|
||||
Provisional layout can still write an instance at an intermediate position
|
||||
and restore it before upload. `Primitives::set_instance` remembers the value
|
||||
at the first write in a frame and clears the dirty bit when the final bytes
|
||||
match it. The GPU therefore observes final layout state, not CPU-only
|
||||
measurement work.
|
||||
|
||||
### 2b. Two more readers of "where is this widget," and masks
|
||||
|
||||
Moving the offset into the vertex shader means `ActiveData.region` is no
|
||||
longer the on-screen truth once a widget has been moved — it is where the
|
||||
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
||||
if it still were, and both must move to a resolved query or they silently
|
||||
answer with the pre-move position: a click landing on a scrolled row would
|
||||
be routed to whatever used to be there, with nothing on screen to say so —
|
||||
exactly the "wrong answer that looks like a right one" case the code rules
|
||||
single out.
|
||||
|
||||
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
||||
does the actual pointer routing, and line 170 is the read in question:
|
||||
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
||||
immediately turned into pixels and tested against the cursor at `:171-172`.
|
||||
Under this design that region must be resolved through the same chain the
|
||||
GPU walks before it means anything. Add to `UiRenderState`:
|
||||
|
||||
```rust
|
||||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
||||
/// walk, over the same arena, so the two cannot disagree about where a
|
||||
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
||||
/// `move_offsets`, bounded by the same constant the shader loop uses
|
||||
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
||||
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
||||
/// const across the language boundary).
|
||||
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
||||
```
|
||||
|
||||
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
||||
coordinate query already used outside hit-testing
|
||||
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
||||
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
||||
before `.to_px(...)` instead of reading `.region` directly — one change
|
||||
covers both call sites listed there. `sense.rs:170` changes to
|
||||
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
||||
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
||||
`resolved_region` costs one arena read to find that out, so there is no
|
||||
version of this design where skipping the fix is a legitimate
|
||||
optimization — it is a correctness gap, not a performance one.
|
||||
|
||||
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
||||
painter's *current* region into a `Mask` pushed onto
|
||||
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
||||
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
||||
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
||||
widget that called `set_mask` — `Masked::draw`,
|
||||
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
||||
is itself later moved, its clip rectangle stays where it was drawn while
|
||||
its content moves out from under it: a visibly wrong clip, immediately on
|
||||
screen, not a latency question.
|
||||
|
||||
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
||||
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
||||
the mask-owning widget's own primitives already get (§2), not a second
|
||||
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
||||
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
||||
currently computes the mask's `top_left`/`bot_right` inline from
|
||||
`masks[in.mask_idx]`; that computation is extended to walk the same
|
||||
move-offset chain §2 added, via one shared function —
|
||||
|
||||
```wgsl
|
||||
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
||||
```
|
||||
|
||||
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
||||
for its mask's corners, so the walk is written once and the two stages
|
||||
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
||||
hand-copied second one in the other shader stage).
|
||||
|
||||
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
||||
primitive's mask is frequently owned by a *different* widget than the
|
||||
primitive itself — often several levels up a subtree, with its own,
|
||||
independent move slot — so a primitive's resolved offset and its mask's
|
||||
resolved offset are two different chain sums, both needed, and only the
|
||||
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
||||
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
||||
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
||||
mean, for every `mov()` call, walking forward to every mask instance the
|
||||
moved widget's slot could affect and rewriting its raw region — exactly
|
||||
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
||||
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
||||
frame (`:148`); one more arena read to resolve its chain costs nothing
|
||||
extra in kind.
|
||||
|
||||
**The scroll-container case, checked rather than assumed.** A masked,
|
||||
scrollable region is built as a `Masked` wrapping a `Scroll`
|
||||
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
||||
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
||||
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
||||
every frame the user drags. Because each widget's move slot is its own
|
||||
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
||||
its own, stationary slot, while the scrolled content underneath references
|
||||
a separate, deeper slot whose `parent` chain passes through — but does not
|
||||
write to — the viewport's slot. Moving the content therefore never touches
|
||||
the mask's resolved position, and the mask staying still while its content
|
||||
slides past it is what this design already produces with no special case,
|
||||
not an extra rule that had to be added for it.
|
||||
Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires
|
||||
its slot only after descendants are gone, and a reused arena slot is reset
|
||||
before new primitives can reference it. `Primitives::set_instance` also
|
||||
cancels a dirty mark when provisional layout restores the original bytes, so
|
||||
CPU-only measurement positions are never uploaded.
|
||||
|
||||
### 3. Resize scope
|
||||
|
||||
@@ -366,91 +196,7 @@ widget observed during that same draw; the next draw replaces the list, so a
|
||||
dependency disappears as soon as the widget stops reading it. Both fields
|
||||
have `ActiveData`'s existing lifecycle through `remove`/`remove_rec`.
|
||||
|
||||
### 6. Before / after
|
||||
|
||||
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
||||
|
||||
```rust
|
||||
// before
|
||||
impl Widget for Rect {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||
}
|
||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// after
|
||||
impl Widget for Rect {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||
painter.set_size(Size::REST); // fills whatever it was given
|
||||
}
|
||||
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
||||
}
|
||||
```
|
||||
|
||||
**A container that needs the child's size before placing it,
|
||||
`iris/src/widget/position/align.rs`**:
|
||||
|
||||
```rust
|
||||
// before
|
||||
impl Widget for Aligned {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
||||
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||||
UiRegion::new(x, UiSpan::FULL) }
|
||||
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||||
UiRegion::new(UiSpan::FULL, y) }
|
||||
(None, None) => UiRegion::FULL,
|
||||
};
|
||||
painter.widget_within(&self.inner, region);
|
||||
}
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// after
|
||||
impl Widget for Aligned {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
let full = painter.region();
|
||||
// Draw once at the full region to learn the child's real size --
|
||||
// this placement is provisional and corrected below without a
|
||||
// second draw.
|
||||
let used = painter.widget_within(&self.inner, full).size();
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
||||
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
||||
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||||
(None, None) => full,
|
||||
};
|
||||
painter.place(&self.inner, region);
|
||||
painter.set_size(used);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||||
return type from `()` to `DrawResult`. Calling `.size()` reads the size the
|
||||
child recorded on its painter and records the parent's dependency on that
|
||||
answer; leaving it unread records no dependency.
|
||||
`Painter::place` moves an already-drawn child when its used area fits the
|
||||
target box, and redraws it when the target changes its size. `SizeCtx` and
|
||||
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||||
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||||
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||||
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
||||
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
||||
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||||
|
||||
### 7. Rejected, and why
|
||||
### 6. Rejected alternatives
|
||||
|
||||
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
||||
phrasing — rejected in §2 for breaking under nested independent moves
|
||||
@@ -477,7 +223,7 @@ deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||||
but still not O(1), and the shader-side chain costs nothing extra to get
|
||||
the better bound.
|
||||
|
||||
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||
## Density: `Len::dp`, resolved at `apply_rest` time
|
||||
|
||||
Iris asked for a third length kind beside `abs` (physical pixels) and
|
||||
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
||||
@@ -529,171 +275,28 @@ resolution-independent, a fraction of the parent). `Span::gap` and
|
||||
on them the same as any other size; a bare number is still `abs`,
|
||||
physical pixels, unchanged.
|
||||
|
||||
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
|
||||
## Masks
|
||||
|
||||
Iris, on the code block's scrolling: "the code block scrolling currently
|
||||
masks in an inner rectangle. Ideally masks should have a shape
|
||||
associated with them, rounded rectangle being one of them, and/or
|
||||
another widget you can select, so that the mask becomes the parent
|
||||
container with rounded edges. Make sure alpha works properly with it,
|
||||
eg. on the corners where alpha should be decreased / multiplied."
|
||||
A `Mask` references a rectangle primitive and its parent mask. Nested masks
|
||||
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
|
||||
widget's region; `.masked_by(shape)` draws the shape behind the content and
|
||||
clips to its first primitive. Keeping the shape in one primitive prevents a
|
||||
rounded background and its clip from drifting apart.
|
||||
|
||||
**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and
|
||||
a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the
|
||||
rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`)
|
||||
sets the painter's mask to its own region. Separately, `draw_rounded_rect`
|
||||
already produces an anti-aliased rounded edge from
|
||||
`distance_from_rect(pos, center, corner, radius)` with a half-pixel
|
||||
`smoothstep`, and the border variant multiplies a second coverage in.
|
||||
Masks are rect-only. Glyph masks would require a CPU-readable alpha plane for
|
||||
hit-test agreement, and standalone image masks require a bind-group switch the
|
||||
fragment stage cannot make. Rendering and hit-testing both traverse the full
|
||||
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
|
||||
checks the WGSL implementation against the CPU SDF.
|
||||
|
||||
**Design** (revised the same day on Iris's two corrections: hit-testing
|
||||
applies the shape too, and a mask should reference a primitive rather
|
||||
than carry a copy of its shape).
|
||||
## Offered boxes
|
||||
|
||||
1. **A mask is a reference to a primitive already drawn, plus how to
|
||||
use it.** `Mask { kind, idx, flags, parent }`: the primitive's
|
||||
binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
|
||||
*alpha only* -- take the primitive's coverage and ignore its colour,
|
||||
which is the default and the only mode until a need for another
|
||||
appears), and the enclosing mask's slot for nesting. The fragment
|
||||
stage evaluates the referenced primitive *at the masked pixel* --
|
||||
for a `Rect`, the same `draw_rounded_rect` coverage from the same
|
||||
SDF; for a texture or glyph, the sampled alpha -- and does
|
||||
`color.a *= coverage`. Nothing about the shape is copied: a rounded
|
||||
container's corner and its children's clipped corner are the same
|
||||
primitive's arithmetic, and a texture mask (an alpha image as the
|
||||
clip) works with no new shader path.
|
||||
What this needs from the data layout: evaluating a primitive at an
|
||||
arbitrary pixel means its placement (its spans and `move_idx`, today
|
||||
vertex attributes) has to be readable from a storage buffer in the
|
||||
fragment stage. If it is not already there, put it there once, for
|
||||
every primitive, rather than keeping a second copy for masks -- the
|
||||
vertex stage can read the same buffer. Textures: the shader binds one
|
||||
image at a time (see `masks_layout`'s comment on why an image's own
|
||||
bind group must not name the masks buffer), so a texture mask is
|
||||
limited to what the fragment can sample without a bind-group switch:
|
||||
the atlas, and the primitive's own bound image when the masked
|
||||
primitive is drawn in the same image's batch. Say so at the flag.
|
||||
2. **Nested masks chain and multiply, like moves.** `parent` walks up
|
||||
the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
|
||||
debug-assert on overflow and print the chain); coverages multiply,
|
||||
so a pixel inside two feathered corners is dimmed by both, which is
|
||||
what a compositor does and what "alpha should be multiplied" asks.
|
||||
3. **`.masked()` points the mask at the current widget's own
|
||||
primitives.** `Masked` stops describing a region: it records which
|
||||
primitive(s) the wrapping widget drew this frame (the painter knows
|
||||
-- it just allocated the slots) and sets the mask to reference them.
|
||||
So a rounded `Rect` widget's `.masked()` clips its children to
|
||||
itself by pointing at the rect it already draws; an image widget's
|
||||
`.masked()` clips to its alpha. No radius or shape argument exists to
|
||||
fall out of sync. When a widget draws more than one primitive (a
|
||||
bordered rect is one primitive; a card with a stripe is two), the
|
||||
mask references the *first* and the doc says so; a widget that wants
|
||||
another names it.
|
||||
4. **Hit-testing applies the shape.** A press is inside a masked
|
||||
subtree only if the mask's coverage at that point is above one half.
|
||||
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
|
||||
-- one function in the shared crate, with the WGSL a transliteration
|
||||
of it and a test that compares the two at a grid of points
|
||||
(`headless` renders to a buffer and reads back, or the Rust version
|
||||
is checked against the values the shader produced once and recorded).
|
||||
For a texture, the CPU needs the alpha: keep the alpha channel of an
|
||||
image used as a mask readable on the CPU (it was uploaded from CPU
|
||||
memory; keeping the alpha plane is a quarter of the image), and read
|
||||
it at the point. A masked corner that cannot be tapped and a masked
|
||||
corner that is not drawn are then the same corner.
|
||||
`Pad` must work in every container: it offers an inset region to its child and
|
||||
reports the child's used size plus padding. In a generous parent it behaves as
|
||||
an inset; in a tight parent it grows the result outward.
|
||||
|
||||
**Rejected.** A stencil buffer (a second pass per mask level and no
|
||||
anti-aliasing); the scissor rectangle (rectangles only, no alpha);
|
||||
rendering a masked subtree to an offscreen texture and compositing
|
||||
(a texture allocation per mask, every frame it scrolls, on the phone).
|
||||
|
||||
**Pass conditions.** A headless test draws a rounded container with a
|
||||
masked child that overhangs all four sides and asserts the child's
|
||||
coverage at a corner pixel equals the container's own coverage there
|
||||
(same primitive evaluated, so exactly equal, not approximately); a
|
||||
nested-mask test asserts the product at a pixel inside both feathers; a
|
||||
texture-mask test clips a rect to an alpha image and asserts a
|
||||
transparent texel masks fully; a hit-test asserts a press in a
|
||||
container's clipped corner misses and one just inside the curve hits,
|
||||
and that the CPU SDF and the shader agree at a grid of points; a
|
||||
`run-headless.sh --phone` screenshot of a scrolled code block shows
|
||||
rounded corners with no square pixels poking out at the top and bottom
|
||||
of the scrolled content. Record the commands in RUST.md when it lands.
|
||||
|
||||
### What was built (2026-09-08), and where it differs
|
||||
|
||||
The commands and the screenshot are in docs/RUST.md's queue entry. Four
|
||||
places the code is narrower than the design above, each deliberate:
|
||||
|
||||
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
|
||||
The referenced instance already carries its own `binding`, so a copy
|
||||
of it in the mask is a second thing to keep in step; *alpha only* is
|
||||
the only mode there is, so there is nothing to select. Both are a
|
||||
field away if a second mode appears.
|
||||
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
|
||||
by name, rather than leaving the shader to read a `rects` entry that
|
||||
is not there. A glyph would need a CPU-side alpha plane before the
|
||||
hit test could agree with the shader, and a standalone image needs a
|
||||
bind-group switch the fragment stage cannot make (`masks_layout`'s own
|
||||
comment on why an image's bind group must not name the masks buffer).
|
||||
So **the texture-mask pass condition is not met and no texture mask
|
||||
exists** — the point of the reference design is that adding one is a
|
||||
binding check and a sampled alpha, with no new shader path, and the
|
||||
shader's `mask_coverage` already has the branch where it would go.
|
||||
- **The shape is a primitive of its own, not always a drawn one.** A
|
||||
plain `.masked()` writes an undrawn `RectPrimitive` at its region
|
||||
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
|
||||
box" and "clip to that widget's rounded background" are one mechanism
|
||||
and square-cornered clipping did not become a special case.
|
||||
`.masked_by(shape)` draws `shape` behind the content — in its own
|
||||
layer, the way `Stack` puts a background under its content — and
|
||||
clips to the first primitive it drew.
|
||||
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
|
||||
the only test in the workspace that needs an adapter. It lifts
|
||||
`distance_from_rect` and `rounded_rect_coverage` out of
|
||||
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
|
||||
so the thing under test is the shader itself rather than a copy of it
|
||||
that would be edited alongside.
|
||||
|
||||
## What a widget's *offered* box may and may not be (2026-09-08)
|
||||
|
||||
Two rules that were each true in one place and missing from a sibling,
|
||||
found together by Iris's 2026-09-08 phone report.
|
||||
|
||||
**Padding works in whatever container it is placed in, and is an inset or
|
||||
an outset depending on how tight that container's region is.** Iris's
|
||||
own words, 2026-09-08: "padding should work no matter what container a
|
||||
widget is placed in, and acts as both inset and outset depending on how
|
||||
tight the parent region is." `Pad` offers its child the region it was
|
||||
handed, inset on each side, and reports `used + padding` — so given a
|
||||
generous box it insets the child inside it, and given a box already the
|
||||
size of the content it reports a larger size and the parent grows. What
|
||||
this rules out is any container that offers a padded child a box and then
|
||||
ignores what it reported, and any caller that reshapes its tree to avoid
|
||||
a `Pad` (which `transcript-ui/src/tool.rs` did until 2026-09-08, at the
|
||||
cost of a tool group's 4dp inset).
|
||||
|
||||
**A widget offered a box it does not fit is drawn again at the box its
|
||||
own reported size implies, in the same frame.** Not next frame. The
|
||||
temptation to defer is real — `LazySpan::place` offers a row its *cached*
|
||||
height precisely so that an unchanged row hits `draw_inner`'s cheap
|
||||
skip-or-move path, and `Scroll` sizes its child region from last frame's
|
||||
content length for the same reason. But a `Rect` fills whatever region it
|
||||
is given (`Size::REST`, and `rect.rs`'s `is_size_independent` doc says
|
||||
why it must), and `.background(rect(..))` is the ordinary way to style
|
||||
anything — so a one-frame-stale box is a background drawn at the wrong
|
||||
size while the text inside it is already right. On screen that is a tool
|
||||
card that looks closed while its text is there and open while it is not.
|
||||
A move alone cannot fix a changed size; `Painter::place` redraws in that
|
||||
case.
|
||||
|
||||
The cost is bounded and worth stating, because it is what makes the rule
|
||||
safe to apply everywhere: the settling draw happens only on the frame a
|
||||
widget's own size actually changes, which is a frame that was already
|
||||
redrawing it. `Sized` also requires its final region before retaining its
|
||||
children: its own reported size may be known exactly while a descendant was
|
||||
drawn in the provisional box, so moving only the wrapper is insufficient. A
|
||||
widget whose reported size is a function of the box it was *offered* would
|
||||
disagree every frame and redraw every frame — which is why `LazySpan` requires
|
||||
content-sized rows, and has since long before this.
|
||||
When a widget does not fit its offered box, it is redrawn at the box implied by
|
||||
its reported size in the same frame. Deferring would leave ordinary
|
||||
`.background(rect(..))` surfaces one frame behind their content. The settling
|
||||
draw occurs only when the widget's own size changes. Widgets whose size varies
|
||||
with every offered box are therefore unsuitable as `LazySpan` rows.
|
||||
+47
-334
@@ -1,50 +1,18 @@
|
||||
# Moving the app to Rust
|
||||
|
||||
Working document for the port Iris asked for on 2026-09-04: the phone app
|
||||
in pure Rust, one UI framework shared with a desktop app, at full feature
|
||||
parity and giving up nothing native -- performance especially. Her
|
||||
constraints: no Dioxus and nothing that draws through a WebView; **no UI
|
||||
DSL** (which ruled out Makepad and Slint); the result stays lightweight;
|
||||
platform-specific pieces are fine to maintain; reimplementing a framework
|
||||
piece from scratch where it does not fit is fine; effort and elapsed time
|
||||
do not matter, long-term robustness does.
|
||||
|
||||
**The framework question is closed.** Iris chose her own library,
|
||||
[iris](https://github.com/cat16/iris), over Masonry on 2026-09-05.
|
||||
The bake-off that got there, and the twelve experiments
|
||||
that proved it on a device, are summarised in "What the experiments
|
||||
settled" below rather than kept at length. What is left in this file is
|
||||
the plan for the rest of the app and the findings that outlive the tasks
|
||||
that produced them.
|
||||
|
||||
Decisions get a date and a reason here, the way `PLAN.md` does.
|
||||
Plan for a native Rust phone app with full feature parity and a shared desktop
|
||||
UI. It uses [iris](https://github.com/cat16/iris); platform-specific entry
|
||||
points are acceptable, but shared screens, widgets, and styling are not
|
||||
duplicated. The result must stay lightweight and preserve native behavior and
|
||||
performance.
|
||||
|
||||
## Keep this file current as you work
|
||||
|
||||
**This file is the handoff, and it is meant to let a session be cleared.**
|
||||
Write each result into it *as you get it*, not at the end: the box ticked
|
||||
or the reason it could not be, the measurement with its number, the
|
||||
decision with its date and what it rejected, and anything that cost time to
|
||||
find out. Then a session that has filled its context can be cleared and the
|
||||
next one can pick up from this file alone, which is much cheaper than
|
||||
carrying a long conversation or re-deriving what was already measured.
|
||||
Keep open work, current design, measured constraints, and dead ends that would
|
||||
otherwise be repeated. Delete completed plans and migration narratives. Name
|
||||
the command and measured value when evidence matters.
|
||||
|
||||
Two things that follow. Write for somebody who was not here -- name the
|
||||
command, the file and the number rather than "the fix" or "the earlier
|
||||
run". And write the failures and the dead ends too: "Venus is blocked by
|
||||
the emulator, not by Mesa" and "the present mode was not the cause" are
|
||||
worth as much as the successes, because they are what stops the next
|
||||
session spending an afternoon on them again.
|
||||
|
||||
**And delete a plan once it has been carried out** (Iris, 2026-09-08:
|
||||
*"remove everything that's already done and decided... many with checkboxes
|
||||
already ticked off that just fill up context"*). A ticked box has done its
|
||||
job; a finished experiment is worth one line saying what it settled, not
|
||||
the log of settling it. Currency means this file says where things *are*,
|
||||
not how they got here. What survives a prune is what cannot be cheaply
|
||||
re-derived: measurements, dead ends, invariants and their reasons.
|
||||
|
||||
## Where things stand (2026-09-09)
|
||||
## Current status
|
||||
|
||||
- **The framework is decided and built on.** iris draws the transcript
|
||||
screen on the desktop, on this checkout's emulator and on Iris's phone.
|
||||
@@ -52,14 +20,12 @@ re-derived: measurements, dead ends, invariants and their reasons.
|
||||
phone and the reports are under `docs/bench/`.
|
||||
- **P1 (session screen parity) is the current work**, and is where the
|
||||
next session should start. Its box below has the state.
|
||||
- **The repository was reorganised on 2026-09-08**: the port is one crate,
|
||||
`app-rust/`, and `iris/` is the UI framework alone. See "One app crate"
|
||||
at the end -- it is the layout everything else here assumes.
|
||||
- The port is one crate under `app-rust/`; `iris/` is only the UI framework.
|
||||
- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own
|
||||
list (colour-space correctness is the live one), `docs/TODO.md` is the
|
||||
Compose app's.
|
||||
|
||||
## Desktop and phone share the code (Iris, 2026-09-07)
|
||||
## Desktop and phone share the code
|
||||
|
||||
Iris plans to develop a desktop app as well, and asked that most code be
|
||||
sharable between desktop and phone. The tree already has that
|
||||
@@ -332,76 +298,9 @@ rather than what it happens to look like:
|
||||
7. **Measurable frames**: the debug render report, and a way to attribute
|
||||
a frame's cost to a widget on the real phone.
|
||||
|
||||
## What the experiments settled
|
||||
## Measurements and constraints
|
||||
|
||||
Twelve boxes, all closed between 2026-09-04 and 2026-09-05, and all
|
||||
deleted on 2026-09-08 now that their conclusions live in the code. One
|
||||
line each for what a later session must not re-derive; where a decision
|
||||
needs its reasoning, the reasoning is at the thing itself.
|
||||
|
||||
**The framework track (E0-E5), against Masonry:**
|
||||
|
||||
- **E0 -- toolchain.** NDK r29 (`29.0.14206865`) under `~/Android/Sdk`,
|
||||
cargo-ndk 4.x. Its API-level flag is `-P`; `-p` now means `--package`.
|
||||
- **E1 -- android-view's Masonry demo ran here**, on the GPU, with an
|
||||
accessibility tree and the phone's real keyboard -- but no autocorrect
|
||||
and no suggestions. The `android-view` rev this was measured against is
|
||||
pinned in `app-rust/Cargo.toml` with that history at the pin;
|
||||
`accesskit_android`'s detach-abort is mitigated in
|
||||
`iris/src/android/view.rs`'s `raise_if_enabled`, and advancing the
|
||||
version is not the fix.
|
||||
- **E2 -- a transcript in Masonry** found the framework-wide gap that
|
||||
blocked the comparison. It lived in `~/src/android-view/e2-transcript`
|
||||
and was never committed here.
|
||||
- **E3/E5 -- the Kotlin shell and the packaging xtask.** Both hold:
|
||||
`app/shellApp` plus the JNI bridge (now `app-rust`'s `shell` feature)
|
||||
posts a real notification and receives a real share, and `cargo xtask
|
||||
apk` packages an installable APK with `javac`/`d8`/`aapt2`/`zipalign`/
|
||||
`apksigner` and one disclosed Gradle call, documented at
|
||||
`scripts/xtask/src/apk.rs`'s module doc.
|
||||
- **E4 -- the same screen on the desktop**, which is now
|
||||
`app-rust`'s `src/desktop` and the `ai-app-desktop` binary.
|
||||
|
||||
**The iris track (I0-I5):**
|
||||
|
||||
- **I0a -- iris is vendored at `iris/`**, history not carried, consumed by
|
||||
path, from `iris/iris` on gitea at `7b54aaf`. It goes back to its own
|
||||
repository once it has proved itself.
|
||||
- **I0b -- the nightly pin is dated, not rolling** (`rust-toolchain.toml`,
|
||||
one copy in `iris/` and one in `app-rust/`, because a pin applies per
|
||||
directory). Dated because a rolling channel moved `impl const Trait` to
|
||||
`const impl Trait` underneath the vendored tree and broke it unattended.
|
||||
- **I1 -- parley, plus a glyph atlas.** Both Iris's call. Parley addresses
|
||||
text by byte offset into one string, which is why the editing model
|
||||
looks the way it does.
|
||||
- **I2 -- iris runs on android-view**: the backend, the Gradle shell,
|
||||
insets, the back gesture and the full `InputConnection` bridge, with
|
||||
real Gboard suggestions.
|
||||
- **I3 -- the virtualised list.** Since renamed `LazySpan`, and scrolling
|
||||
has moved out of it into `ScrollController` -- `docs/SCROLL.md` is the
|
||||
current design, not this box.
|
||||
- **I4 -- accessibility names through AccessKit**, one flat tree with a
|
||||
synthetic `Role::Window` root and every *named* widget a direct child.
|
||||
Flat deliberately: nothing upstream of a named leaf needs a node. This
|
||||
is what lets `ui-trace` tap by label.
|
||||
- **I5 -- the transcript screen in iris**, with `FrameReport` for
|
||||
frame timing. Its descendants are `app-rust/src/ui` and every
|
||||
measurement rig in AGENTS.md.
|
||||
|
||||
**Two findings from that period that are still load-bearing, kept where
|
||||
they belong rather than here:** iris's binding array does not survive real
|
||||
Android hardware (the measurement and the fix are `docs/TEXTURES.md`'s
|
||||
"Implemented, 2026-09-04"), and the emulator has no hardware Vulkan while
|
||||
its GLES *is* the host's real GPU through virgl (moved to the
|
||||
`this-machine-android` skill on 2026-09-08, with the `gpu-probe` output
|
||||
that established it).
|
||||
|
||||
## Findings that outlive the task that produced them
|
||||
|
||||
Kept because the number or the constraint is what stops it being
|
||||
re-derived; the tasks themselves are done and deleted.
|
||||
|
||||
### The Android release profile, and where the APK's size went (2026-09-07)
|
||||
### The Android release profile and APK size
|
||||
|
||||
Iris asked why the iris bench APK was double the Compose one (20.6 MB vs
|
||||
10.1 MB). It was almost all `libmain.so`, built with `panic = "abort"` and
|
||||
@@ -423,7 +322,7 @@ vectorisation on a renderer. Everything else is
|
||||
own rather than `release`, so the desktop build is not also optimised for
|
||||
size.
|
||||
|
||||
### The fling stutter, and what a frame report could not say (2026-09-09)
|
||||
### The fling stutter and what a frame report cannot say
|
||||
|
||||
Iris, from her phone: *"I'm noticing some stuttering when flinging in
|
||||
particular. Harder to notice with my finger directly moving the scroll."*
|
||||
@@ -515,7 +414,7 @@ The signature of the fixed loop, from that run: `build p50 0.4ms,
|
||||
acquire p50 5.7ms, submit p50 1.7ms` -- four tenths of a millisecond of
|
||||
work and the rest of the refresh period spent waiting its turn.
|
||||
|
||||
### Streaming is where the frame time is now (2026-09-09)
|
||||
### Streaming frame time
|
||||
|
||||
Measured after the fling was fixed, and it is not where it looks.
|
||||
`frame_profile.rs`'s stream run: folding an arriving event is 0.35ms and
|
||||
@@ -530,7 +429,7 @@ every delta.
|
||||
exact shape of the Compose lesson in AGENTS.md's "Things that have
|
||||
bitten" -- and measuring it is what ruled it out.
|
||||
|
||||
### Incremental text: parley cannot, and it turns out not to matter (2026-09-09)
|
||||
### Incremental text shaping
|
||||
|
||||
Iris asked to investigate incremental text rendering and hoped parley
|
||||
supported it. **It does not, by design.** The crate's own docs: a
|
||||
@@ -635,7 +534,7 @@ the reply into blocks was still right -- it is what makes the fixture
|
||||
representative, and it halved the CPU half -- but it was never going to
|
||||
move this, and it slightly increases the primitive count.
|
||||
|
||||
### The arenas upload deltas, and stopped being 11x too big (2026-09-09)
|
||||
### Arena delta uploads
|
||||
|
||||
Done, and measured by `scripts/rigs/ui-profile`'s `arena_churn` -- see
|
||||
AGENTS.md's entry for the rig and the numbers. The arithmetic above was
|
||||
@@ -697,7 +596,7 @@ instance bytes per frame are **1,488**, from 176,496. This is framework
|
||||
layout/rendering behaviour and the transcript screen contains no special
|
||||
case for it.
|
||||
|
||||
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
|
||||
### The Android release profile uses `opt-level = 3`
|
||||
|
||||
The table above was measured in bytes only. `"s"` costs the loop
|
||||
vectorisation and inlining a renderer runs on: over the same warm fling
|
||||
@@ -708,7 +607,7 @@ refused for `"z"`, one level further up. Iris raised it herself
|
||||
(*"I'd make sure it's in release mode"*); the build always was, and this
|
||||
was the part of "release" that was not about speed.
|
||||
|
||||
### Platform fonts, not bundled ones (2026-09-07)
|
||||
### Platform fonts
|
||||
|
||||
Iris: *"remove the font for now; just match what compose does."* The
|
||||
Compose app takes body text from `FontFamily.Default` and code from
|
||||
@@ -733,26 +632,9 @@ desktop cannot answer it. Before the next phone build, look at a bold run
|
||||
and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on Iris's own
|
||||
device; the emulator's font set is not evidence for hers.
|
||||
|
||||
### Hit-testing does not consult the mask chain (review R2, 2026-09-07)
|
||||
## The port, in order
|
||||
|
||||
Masks are applied in the fragment shader
|
||||
(`iris/core/src/render/shader.wgsl`); the CPU hit path
|
||||
(`UiRenderState::resolved_region`) does not look at `masks` at all. So a
|
||||
straddling row's clipped-away top is invisible and still tappable -- a tap
|
||||
on "Run benchmark" can land on an invisible link in the row behind it.
|
||||
Left deliberately: `docs/LAYOUT.md`'s mask redesign ("masks reference a
|
||||
drawn primitive instead of copying a shape") is where hit-testing gets the
|
||||
shape, and intersecting a chain in `resolved_region` now would be a second
|
||||
mechanism to unpick.
|
||||
|
||||
## The port, in order (decided 2026-09-05)
|
||||
|
||||
The ordered plan for the rest of the app, decided here per Iris's standing
|
||||
"decide technical questions yourself" instruction -- no serious
|
||||
user-facing tradeoff is in play in the ordering itself.
|
||||
|
||||
**Where the screens live** was settled by the 2026-09-08 reorganisation
|
||||
("One app crate", below): every screen is a module under
|
||||
Every screen is a module under
|
||||
`app-rust/src/ui`, which holds a `Screen` enum and a back stack -- the
|
||||
direct equivalent of `AppRoot.kt`'s `when` and `MainScreen.kt`'s tab
|
||||
`enum` -- with each Compose screen becoming one `iris::widget` subtree.
|
||||
@@ -776,55 +658,15 @@ AVD, `ui-trace` by accessibility name, GrapheneOS phone quirks, the
|
||||
running any pass condition below that touches an emulator or a real
|
||||
device.
|
||||
|
||||
- [x] **P0 -- the phone benchmark gate. Passed.** Asked for 2026-09-05,
|
||||
delivered and run on Iris's own phone; the reports are under
|
||||
`docs/bench/`. Both halves are still in the tree and are how a
|
||||
frame-time comparison is taken: the Compose `bench` build type
|
||||
(`app/`, `BenchFixture.kt`/`BenchRun.kt`) and the Rust `bench`
|
||||
feature (`app-rust`, `src/android/bench_client.rs`), opening the
|
||||
same checked-in synthetic transcript
|
||||
(`app/bench-fixture/assets/transcript.jsonl`, never a real one) with
|
||||
no server, driving the same scroll loop and streaming phase, and
|
||||
printing the same report fields. AGENTS.md's "The rigs" is the
|
||||
current description; `app-rust/build-apk.sh` and `run-bench.sh` are
|
||||
how it is run. The build source is this repository, but the Dev Updater
|
||||
publication is the separate `~/repos/ai-app-bench` repository, whose
|
||||
`iris-bench` component serves a committed APK with no build step. After
|
||||
an arm64 release build, replace its
|
||||
`iris/build/outputs/apk/release/iris-bench-arm64.apk` and push that
|
||||
repository; adding the component here or pushing only `ai-app-2` is the
|
||||
wrong delivery path.
|
||||
**Bench-only cleanup still open**: the diagnostics report pane draws over
|
||||
transcript rows. `REPORT_MAX_HEIGHT_DP` constrains its claimed height, but the
|
||||
pane is neither masked nor scrollable despite its construction comment saying
|
||||
it is both. This is an `app-rust` defect, not an iris framework item.
|
||||
|
||||
**Bench-only cleanup still open**: the diagnostics report pane draws
|
||||
over transcript rows. Reproduced on the emulator on 2026-09-09 by
|
||||
opening the named `Diagnostics` control. `REPORT_MAX_HEIGHT_DP`
|
||||
constrains its claimed height, but the pane is neither masked nor
|
||||
scrollable despite its construction comment saying it is both. This is
|
||||
an `app-rust` bench-screen defect, not an iris framework item.
|
||||
|
||||
- [ ] **P1 — session screen parity.** **Started 2026-09-06, on Iris's
|
||||
word**: "just continue with the plan for now; try to move towards
|
||||
feature parity for the transcript screen so that the test can be
|
||||
more fair." So P0's "must pass before P1 starts" is lifted — the
|
||||
phone bench continues alongside, and parity is what makes its
|
||||
comparison fair. **Sub-order, by what the bench fixture exercises
|
||||
and Compose already draws** (tick and date each in place):
|
||||
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
|
||||
Each top-level block is drawn in one of three frames
|
||||
(`ui::markdown::BlockFrame`) — plain, verbatim, quote — with
|
||||
fences and tables verbatim, headings scaled, and inline
|
||||
styling per span. `app-rust/src/ui/markdown.rs` is the code
|
||||
and its module doc the design.
|
||||
- [x] **P1b — tool-call cards and grouping.** Done 2026-09-06.
|
||||
`ToolRows.kt`/`ToolInput.kt` ported to
|
||||
`app-rust/src/ui/tool.rs`: a run of calls is one collapsible
|
||||
group, each card carries its state and summary, and the five
|
||||
`ToolState` values each have their own appearance.
|
||||
`tool.rs`'s module doc has what was chosen.
|
||||
- [ ] **P1 — session screen parity.** Continue in this order:
|
||||
- [ ] **Before the next parity slice — make iris's colour pipeline
|
||||
correct.** Raised by Iris on 2026-09-09 as something to settle
|
||||
sooner rather than later. Both backends currently prefer an
|
||||
sRGB surface while the shader returns palette/image bytes as
|
||||
correct.** Both backends currently prefer an sRGB surface while
|
||||
the shader returns palette/image bytes as
|
||||
linear values; `IRIS_TODO.md` has the measured mismatch and
|
||||
pass condition. Do this before judging or centralising the
|
||||
app's styling. It is correctness, not cosmetic polish.
|
||||
@@ -1038,169 +880,40 @@ device.
|
||||
once more against a real `ai-server` (not the sandbox) on a real
|
||||
phone, side by side with the Compose build until it holds.
|
||||
|
||||
## For the next session
|
||||
## One app crate
|
||||
|
||||
What to do when you pick this up, in order, so nothing here has to be
|
||||
re-derived. **The work is done inline, not handed to subagents** — Iris
|
||||
said so on 2026-09-08 ("I'm no longer using subagents for this. Please do
|
||||
the work yourself"), so read the code, make the change, run the tests and
|
||||
push, in the session that picked the task up.
|
||||
|
||||
1. Read this file, then `AGENTS.md` and `PLAN.md`. The rules there
|
||||
(measure, do not read; fix the rig before accepting its limits; the
|
||||
emulator is this checkout's own) all apply.
|
||||
2. Work on the **`rustify`** branch of this clone (`ai-app-2`), not on
|
||||
`main` and not in `ai-app`. Nothing on this branch is production until
|
||||
Iris says so. Commit and push as you go.
|
||||
3. The E- and I-steps (the framework decision) are done — iris won,
|
||||
decided 2026-09-05. **Fix iris's colour-space pipeline first**, as Iris
|
||||
requested on 2026-09-09; then take P1c, history paging and
|
||||
jump-to-latest. The client-side paging pieces it needs are already
|
||||
ported.
|
||||
4. Every step ends with its measurement written into this file beside the
|
||||
box, and the box ticked or the reason it could not be written in its
|
||||
place. A step that is blocked says by what, not "later". Write it as you
|
||||
go rather than at the end — see "Keep this file current as you work".
|
||||
5. Run the existing rigs rather than inventing new ones: `ui-sandbox.sh`
|
||||
for a server with fixtures, `transcript-bench.sh` for the scroll
|
||||
baseline, `ui-trace` for anything positional, `emu up` for the
|
||||
emulator, `iris/run-headless.sh EXAMPLE --shot PNG` for an iris
|
||||
example on this displayless machine, and `scripts/rigs/gpu-probe` to ask a
|
||||
device (this VM, the emulator, or a real phone over `adb push`) what
|
||||
`wgpu` features and limits it actually has before building anything on
|
||||
the assumption it does. The Vulkan section below says how to get a
|
||||
Vulkan path in the emulator when a `wgpu` backend needs one.
|
||||
6. **Bound anything heavy at the moment you start it.** An emulator or a
|
||||
long build gets a deadline — `timeout`, or a watchdog scoped to the pid
|
||||
you just started — rather than a plan to stop it later. Scope it to
|
||||
that pid: a watchdog written as `sleep N; emu down` fired into a later
|
||||
experiment here and made a working Vulkan build look like a crash. And
|
||||
stop the emulator when the work needing it is done rather than between
|
||||
tasks.
|
||||
7. Decisions belong here with a date and what was rejected, the way
|
||||
`PLAN.md` does it. Do not put design into commit messages alone.
|
||||
|
||||
## Things a Rust app changes elsewhere
|
||||
|
||||
- **`wg-app-link`'s `:link`** (pinned TLS, enrollment store, QR activity)
|
||||
is Kotlin shared with Dev Updater. The certificate code already exists on
|
||||
the Rust side of the submodule; the pinned-CA build step
|
||||
(`generatePinnedCert`) becomes a `build.rs` reading the same path. The QR
|
||||
scanner stays a Kotlin activity, since the camera is a platform feature.
|
||||
- **Tooling** becomes `cargo` for everything but packaging: `cargo test`,
|
||||
`clippy`, `fmt` cover the whole client, which is the motivation. Gradle
|
||||
remains for the APK, signing (`~/.config/ai-app/release.jks`) and Dev
|
||||
Updater's build modes; `build-apk.sh` would call `cargo ndk` first.
|
||||
- **The bench scripts** (`ui-trace` by accessibility label) keep working
|
||||
only if the framework exposes names through AccessKit on Android; that is
|
||||
part of E2's pass condition, not a nicety.
|
||||
- **Icons** stay Nerd Font glyphs from the committed subset; Parley/Fontique
|
||||
loads a font file directly, so `build-icon-font.sh` is unchanged.
|
||||
|
||||
|
||||
## One app crate, 2026-09-08 (the repository reorganised)
|
||||
|
||||
Iris, reading the tree: *"the organization of the rust rewrite is a mess
|
||||
right now… there shouldn't be anything related to the app inside of iris.
|
||||
Iris is supposed to be the UI framework alone."* Then, on the crate count:
|
||||
*"I'm confused why the app only code needs more than one crate though."*
|
||||
|
||||
### What it was
|
||||
|
||||
Nine cargo workspaces, each with its own `Cargo.lock` and `target/`, and
|
||||
the port's project code in five places — `iris/transcript-ui`,
|
||||
`iris/transcript-fixture`, `iris/desktop-app`, `iris/android-app` (all
|
||||
*inside* the framework), plus `client-core` and `android-shell` at the
|
||||
root. Two root markdown files sat outside
|
||||
`docs/`.
|
||||
|
||||
### What it is
|
||||
|
||||
**One crate, `ai-app`, in `app-rust/`.** Modules, not crates:
|
||||
|
||||
| was | is |
|
||||
|----------------------------------------|-----------------------------------|
|
||||
| `client-core` | `src/client` |
|
||||
| `iris/transcript-ui` | `src/ui` |
|
||||
| `iris/transcript-fixture` | `src/ui/fixture.rs` + `tests/`, `touch/` |
|
||||
| `iris/desktop-app` | `src/desktop` + `src/bin_desktop.rs` |
|
||||
| `iris/android-app` | `src/android` + `android-project/` |
|
||||
| `android-shell` | `src/shell` |
|
||||
The Rust client is one `ai-app` crate in `app-rust/`: platform-free code
|
||||
is under `src/client` and `src/ui`, while `src/desktop`, `src/android`, and
|
||||
`src/shell` contain the platform entry points. The fixture is behind its
|
||||
own feature so its 1.9 MB `include_str!` does not enter ordinary phone
|
||||
builds.
|
||||
|
||||
`iris/` now holds `core`, `macro`, the `iris` crate, `tabs-ui` and
|
||||
`rig-input` — framework only, with no mention of a session, a transcript,
|
||||
a setup or a server anywhere in it.
|
||||
|
||||
### Why one crate really is enough
|
||||
`src/client` must not depend on iris. Features select the crate's face:
|
||||
`screens` for UI builds, `shell` for the Compose shell bridge, and `bench`
|
||||
for the fixture. The Android faces both produce `libai_app.so`.
|
||||
|
||||
Each split had a stated reason at the time; on inspection only two
|
||||
survived, and one of those is not in `app-rust` at all.
|
||||
`event-model` remains separate because both the server and client depend
|
||||
on that wire contract. Iris remains a separate UI-framework workspace and
|
||||
must contain no product concepts.
|
||||
|
||||
- **`client-core` separate from the UI** was "pure logic with no framework
|
||||
dependency". That property is worth keeping and does not need a crate:
|
||||
`iris` is behind the `screens` feature and `src/client/` may not reach
|
||||
it. An invariant on a module instead of on a manifest, stated in
|
||||
docs/CLIENT_CORE.md.
|
||||
- **`transcript-fixture` separate from `transcript-ui`** was so the
|
||||
headless harness and a desktop window opened the same bytes. Both are
|
||||
now the same crate, so it is `src/ui/fixture.rs` behind a `fixture`
|
||||
feature (1.9 MB of `include_str!` must not reach a phone build) with the
|
||||
six harness suites in `tests/`.
|
||||
- **Two Android `.so` names**, `libmain.so` for the iris app and
|
||||
`libandroid_shell.so` for the Kotlin shell's JNI bridge, looked like the
|
||||
one hard constraint: a package produces exactly one library artifact.
|
||||
It dissolves because **P2 already plans to merge those two Android apps
|
||||
into one**. So both faces come out of one package as `libai_app.so`,
|
||||
picked apart by features (`--no-default-features --features shell` keeps
|
||||
wgpu, parley and iris out of the Compose app's APK), which is the
|
||||
direction of travel rather than a workaround. `xtask apk` and
|
||||
`app/shellApp`'s `System.loadLibrary` were updated to match.
|
||||
- **A desktop binary and an Android cdylib in one package** is not a
|
||||
problem: `iris` itself already target-gates winit against android-view
|
||||
in one manifest, and the same table does it here. `build-apk.sh` passes
|
||||
`--lib` so `cargo ndk` never tries to build the desktop binary.
|
||||
- **`event-model` stays a crate**, and is the one split that was never
|
||||
optional: `server/` depends on it too, so a crate is what makes the
|
||||
backend and the app agree by construction. Iris chose to leave it at the
|
||||
repo root rather than inside `app-rust/`, since it is the contract
|
||||
between the two rather than app code.
|
||||
### Build constraints
|
||||
|
||||
So: three workspaces where there were nine — `event-model`, `server`,
|
||||
`app-rust` — plus `iris` and `xtask`.
|
||||
|
||||
### Things that moved with it, worth knowing
|
||||
|
||||
- **The toolchain pin is per directory.** `app-rust/rust-toolchain.toml` is
|
||||
a copy of `iris/`'s, because `client-core` used to build on stable and
|
||||
now shares iris's dated nightly. Two consequences appeared immediately:
|
||||
two `needless_range_loop` warnings in the markdown highlighter (fixed),
|
||||
and four `AtomicBool::fetch_update` deprecations from inside `jni`
|
||||
0.22's `native_method!` macro. The last are not ours to migrate — the
|
||||
fix is a `jni` release — so `src/lib.rs` carries an `#[allow(deprecated)]`
|
||||
scoped to `mod shell` with that reason written at it.
|
||||
- **The rolling nightly setting is per directory.** The toolchain files in
|
||||
`app-rust`, `iris`, and `scripts/rigs/ui-profile` must stay synchronized.
|
||||
- **The Android release profile is `android-release`, not `release`.** The
|
||||
aggressive settings `iris/android-app` had (`panic = "abort"`,
|
||||
`opt-level = "s"`, fat LTO) would otherwise apply to the desktop build
|
||||
too, which is a testing surface. `build-apk.sh` passes
|
||||
`--profile android-release` / `--profile android-dev`.
|
||||
- **`iris/run-headless.sh` grew `--dir DIR`**, defaulting to `iris/`. The
|
||||
rig belongs to the framework; the examples it usually runs no longer do.
|
||||
`replay-touch` is still built from `iris/`.
|
||||
- **The log target changed** from `client_core` to `ai_app`
|
||||
(`src/client/log_ring.rs`'s `is_own_target`).
|
||||
- **`iris/run-headless.sh --dir DIR`** selects the workspace containing the
|
||||
example; it defaults to `iris/`.
|
||||
- **Not renamed, deliberately:** the Android application id and Java
|
||||
package are still `dev.iris.android.demo` and the label is still "iris
|
||||
android-view demo", both now misleading. Changing them changes the app's
|
||||
identity on Iris's phone (a side-by-side install rather than an upgrade)
|
||||
and the `DevLogProvider` authority Dev Updater reads, so it is hers to
|
||||
decide rather than a tidy-up to make quietly.
|
||||
|
||||
### Verified
|
||||
|
||||
`./scripts/run-tests.sh` (event-model, server, app-rust) and `cd iris && cargo
|
||||
test` green; `cargo clippy --all-targets` and `cargo fmt` clean in every
|
||||
workspace. `cargo ndk -t x86_64` links `libai_app.so`; `./build-apk.sh
|
||||
debug --abi x86_64` produces an installable APK; installed and launched on
|
||||
this checkout's emulator, drawing through `Gl … virgl` as expected. The
|
||||
phone-sized headless screenshot (`run-headless.sh phone --phone --dir
|
||||
../app-rust --shot …`) renders the transcript unchanged.
|
||||
and the `DevLogProvider` authority Dev Updater reads, so changing them
|
||||
requires an explicit migration decision.
|
||||
+19
-81
@@ -1,8 +1,6 @@
|
||||
# Scrolling in iris
|
||||
|
||||
How anything in iris scrolls, as of 2026-09-09. This is the current
|
||||
design, not a history — the git log has the account of
|
||||
how it got here, and `docs/IRIS_TODO.md` has what is still open.
|
||||
This is the current scrolling design; `docs/IRIS_TODO.md` holds open work.
|
||||
|
||||
Read this before touching `iris/src/widget/position/scrollable.rs`,
|
||||
`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays
|
||||
@@ -29,13 +27,8 @@ Two widgets have one, and they differ only in how they spend a delta:
|
||||
`.scrollable()` registers the same two senses against the controller it
|
||||
already has.
|
||||
|
||||
Do not give a widget its own fling, its own scroll amount, or a
|
||||
`RequestRedraw` handle. And do not add a scrolling method to the `Widget`
|
||||
trait: the three that used to be there (`scrolls_itself`, `apply_scroll`,
|
||||
`scroll_offset`) existed only so a `Scroll` could drive a `LazySpan` it
|
||||
had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't
|
||||
like adding methods to widget, it seems like we can structure things
|
||||
better instead").
|
||||
Do not give a widget its own fling, scroll amount, or `RequestRedraw`
|
||||
handle, and do not add scrolling methods to the general `Widget` trait.
|
||||
|
||||
## One convention for a delta
|
||||
|
||||
@@ -45,25 +38,9 @@ positive delta — the finger's direction — and that is `Scroll::scroll`'s
|
||||
sign, `Scroll::fling`'s, and `Widget::apply_scroll`'s, from the gesture
|
||||
all the way down to a row's anchor.
|
||||
|
||||
**It is a screen direction, not a logical one** (Iris, 2026-09-08:
|
||||
"positive should always scroll up / left, and negative down / right ...
|
||||
that way it always works as the user would expect"). The earlier wording
|
||||
— "positive brings *earlier* content into view" — is true only of a span
|
||||
laid out forwards: a `Dir::UP` list's earlier content is *below*, so the
|
||||
same delta panned it the opposite way from every other scrollable in
|
||||
iris. `LazySpan::flip_delta` is the conversion into the walk's own
|
||||
direction-relative space, the exact counterpart of `flip_pos` for
|
||||
positions, and its `scroll` (private) is the only thing that speaks that
|
||||
space.
|
||||
|
||||
There used to be two public conventions under the same name, and every
|
||||
call site had to know which widget it was talking to. If you add a third
|
||||
scrolling thing, it takes this one. Two tests pin it, and neither is
|
||||
redundant: `a_negative_delta_moves_toward_the_end` follows the sign
|
||||
across the whole handoff, and `a_delta_moves_both_directions_the_same_
|
||||
way_on_screen` checks the two `dir`s against **where rows were drawn** —
|
||||
an assertion written in the walk's own space passes with the flip
|
||||
deleted, because it checks the bookkeeping against itself.
|
||||
It is a screen direction, not a logical content direction. A `Dir::UP`
|
||||
span's earlier content is below, so `LazySpan::flip_delta` converts public
|
||||
screen-space deltas into the walk's direction-relative space.
|
||||
|
||||
## The contract between a controller and its owner
|
||||
|
||||
@@ -98,8 +75,7 @@ on the clock its own velocity was measured on. Frames are presented on an
|
||||
even cadence whatever clock they are computed on, so sampling the spline
|
||||
at "whenever the callback got to run" moves the content unevenly between
|
||||
frames that are shown evenly -- a shimmer that no frame-time percentile
|
||||
can see, since no frame was late. Found 2026-09-09; docs/RUST.md's
|
||||
"The fling stutter" has the rest.
|
||||
can see, since no frame was late. `docs/RUST.md` records the measurements.
|
||||
|
||||
### Why a remainder was not enough
|
||||
|
||||
@@ -152,8 +128,6 @@ can say how far it may go, so nothing above it is in a position to.
|
||||
|
||||
### Why it is not a `Span` inside a `ScrollArea`
|
||||
|
||||
Measured 2026-09-08, and worth not re-deriving:
|
||||
|
||||
- A `Span` is skipped entirely in the steady state. When redrawn, it uses
|
||||
exact hints first, draws unknown fixed children forward from the cursor,
|
||||
and places retained drawings after flexible allocation. A child is
|
||||
@@ -179,14 +153,9 @@ A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with
|
||||
`Pin::End` (the view sits at the bottom). Conflating the two would stand
|
||||
it on its head.
|
||||
|
||||
**`Pin` says it either way round**, because there are two questions and
|
||||
they are not the same one (Iris, 2026-09-08). `Start`/`End` are
|
||||
content-relative — the first row or the newest one, wherever the layout
|
||||
puts it — and `Neg`/`Pos` are axis-absolute: the top/left edge and the
|
||||
bottom/right one, whichever end of the content is there. They coincide for
|
||||
everything except a reversed `LazySpan`, where they are exact opposites,
|
||||
which is the whole reason both exist. The one question a scrollable acts
|
||||
on is `pinned_to_end`, and `dir` is what resolves a `Pin` into it.
|
||||
`Start`/`End` are content-relative; `Neg`/`Pos` are axis-absolute. They
|
||||
diverge for a reversed `LazySpan`. A scrollable acts on `pinned_to_end`,
|
||||
with `dir` resolving the chosen `Pin`.
|
||||
|
||||
### Two coordinate spaces, two conversion points
|
||||
|
||||
@@ -217,8 +186,8 @@ framework:
|
||||
old-children diff calls `remove_rec`, and the `ActiveData` — with its
|
||||
`size` — is freed. The framework's copy is gone for precisely the rows
|
||||
the walk has to pass through without drawing.
|
||||
2. **A widget may one day render in two places at once** (Iris,
|
||||
2026-09-08), so anything keyed by `WidgetId` alone that describes where
|
||||
2. **A widget may render in two places at once**, so anything keyed by
|
||||
`WidgetId` alone that describes where
|
||||
or how big a widget was drawn will be wrong then. Where and how big
|
||||
belongs to the owner that placed it.
|
||||
|
||||
@@ -242,8 +211,8 @@ inside the same frame**. `moved_by` counts that correction along with the
|
||||
move that caused it, which is why `amt` stays equal to what is on screen
|
||||
rather than drifting by every overshoot.
|
||||
|
||||
Layout is a pure function of the state, not of how many frames have been
|
||||
drawn (Iris, 2026-09-08). A correction that lands next frame is a frame
|
||||
Layout is a pure function of state, not of how many frames have been
|
||||
drawn. A correction that lands next frame is a frame
|
||||
drawn wrong, and there may be no next frame — a fling that stopped is not
|
||||
asking for one.
|
||||
|
||||
@@ -305,40 +274,9 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
|
||||
rebased after 65,536 pixels to preserve `f32` precision, a rare O(visible)
|
||||
move-slot pass rather than steady-state work.
|
||||
|
||||
## Tests that pin the behaviour
|
||||
## Verification
|
||||
|
||||
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
|
||||
|
||||
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
|
||||
- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a
|
||||
screen direction, checked against where rows were *drawn*.
|
||||
- `amt_counts_only_what_the_child_could_take` — why the owner reports what
|
||||
it did rather than the caller adding up what it asked for.
|
||||
- `a_fling_stops_at_the_first_row`,
|
||||
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
|
||||
with no settling frame drawn on purpose.
|
||||
- `a_dir_up_span_grows_upward_from_item_zero`,
|
||||
`a_reversed_span_hit_tests_in_screen_space` — the position conversions
|
||||
(`flip_pos`), as `a_delta_moves_both_directions_the_same_way_on_screen`
|
||||
is the delta one (`flip_delta`).
|
||||
- `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters` —
|
||||
a fling that nothing registers never moves, whatever its velocity.
|
||||
|
||||
In `app-rust/tests/` (layer 1, no window or GPU):
|
||||
|
||||
- `top_edge.rs`'s `scrolling_past_the_first_row_settles_on_it` /
|
||||
`scrolling_past_the_last_row_settles_on_it` — both ends, no settling
|
||||
frame.
|
||||
- `phone_screen.rs`'s `a_recorded_flick_releases_with_a_velocity_and_
|
||||
flings_the_list` — the velocity against
|
||||
`benches/velocity_reference.py`'s number, and the fling's travel against
|
||||
`benches/fling_spline_reference.py`'s.
|
||||
- `phone_screen.rs`'s `a_long_press_and_drag_selects_text` — what caught
|
||||
two `DragGesture`s fighting over the transcript.
|
||||
- `catch_a_fling.rs`, `gesture_cancel.rs`, `fence_fling.rs` — press-catches
|
||||
a coasting area, cancels, and a code fence panning sideways
|
||||
independently of the transcript.
|
||||
|
||||
`docs/RUST.md`'s "Three test layers" says which layer answers what. Test
|
||||
at the cheapest one that can answer the question; the emulator is for JNI,
|
||||
the IME, insets and one verification run, not for iterating on layout.
|
||||
The unit and headless integration tests exercise direction, both walls,
|
||||
reversed hit-testing, fling registration, cancellation, nested horizontal
|
||||
pans, and transcript selection. `docs/RUST.md` defines the three test layers;
|
||||
use the cheapest layer that can observe the behavior under test.
|
||||
+38
-228
@@ -1,240 +1,50 @@
|
||||
# How iris renders an unbounded number of images
|
||||
|
||||
**Built 2026-09-04**, in `iris/core` and `iris/src/default/render.rs`.
|
||||
This file is the design and the measurements behind it; the deliberation
|
||||
that produced it -- the prior-art survey, the proposal and its review --
|
||||
was deleted on 2026-09-08, having been carried out. What is kept is why
|
||||
the old approach could not stay (it is the reason the current one looks
|
||||
as it does), the numbers, and what actually landed.
|
||||
Iris cannot require Vulkan descriptor indexing. The Android Vulkan Profile
|
||||
2025, covering 80.1% of active Vulkan-capable Android devices as of October
|
||||
2025, does not require `VK_EXT_descriptor_indexing` or its bindless texture
|
||||
features ([Android Vulkan profiles](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)).
|
||||
Arm guarantees the extension only on Valhall and fifth-generation GPUs
|
||||
([Arm Vulkan guidance](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)).
|
||||
|
||||
Iris (the person) asked whether iris's (the library's) approach to "draw
|
||||
however many images happen to be on screen" -- relevant here because a
|
||||
transcript can hold an unbounded number of attached screenshots -- works
|
||||
on mobile, her recollection being that it does not. It did not, and this
|
||||
is what replaced it.
|
||||
The emulator also rejects wgpu requests for `TEXTURE_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
||||
`PARTIALLY_BOUND_BINDING_ARRAY`. Those features therefore must not enter
|
||||
Iris's required device feature set.
|
||||
|
||||
## The problem
|
||||
## Current design
|
||||
|
||||
Every texture iris ever creates — every `Image` widget
|
||||
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
||||
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
||||
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
||||
array by index: `core/src/render/shader.wgsl:56` declares
|
||||
`var views: binding_array<texture_2d<f32>>`, sized by
|
||||
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
||||
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
||||
features — `TEXTURE_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
||||
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
||||
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
||||
Glyph atlas pages are layers of one `texture_2d_array`. `GpuTextures` doubles
|
||||
the array when it runs out of layers, copies the old layers on the GPU, and
|
||||
rebuilds every bind group that referenced the old view. Page numbers are
|
||||
assigned synchronously by `Textures::add_page` because glyph insertion needs
|
||||
the layer before the renderer processes queued texture updates.
|
||||
|
||||
A transcript with an unbounded number of image attachments is exactly the
|
||||
case that grows this array without bound: each attachment becomes its own
|
||||
`Image` widget, which takes its own permanent array slot until dropped.
|
||||
Standalone images each own a bind group and are not placed in the glyph
|
||||
array. Each render layer keeps ordinary rect/glyph instances separately from
|
||||
image instances. It draws the ordinary batch once, then binds and draws each
|
||||
standalone image. This removes any fixed image count at the cost of one bind
|
||||
and draw call per visible image, which is the appropriate tradeoff for phone
|
||||
transcripts containing a modest number of screenshots.
|
||||
|
||||
## What was measured
|
||||
The masks storage buffer appears in every image bind group. If that buffer or
|
||||
the atlas array is reallocated, all affected bind groups must be rebuilt;
|
||||
retaining a bind group across either reallocation would leave it pointing at
|
||||
the old GPU resource.
|
||||
|
||||
**A new rig, `scripts/rigs/gpu-probe`**, asks a device for exactly iris's features
|
||||
and limits with no window and no APK — a plain executable pushed with
|
||||
`adb push` and run from `/data/local/tmp`. It has two parts:
|
||||
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
||||
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
||||
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
||||
detect it."
|
||||
Texture updates accumulate their rebuild requirement with OR. A patch must
|
||||
never clear a rebuild requested by an earlier push in the same batch.
|
||||
|
||||
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
||||
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
||||
discrete desktop GPU — but it is why the design was never checked before
|
||||
now: it always worked in the one place it was tried.
|
||||
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
||||
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
||||
`request_device` **fails** —
|
||||
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
||||
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
||||
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
||||
driver itself reporting all seven descriptor-indexing sub-features as
|
||||
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
||||
being more conservative than the driver here, for a reason not chased
|
||||
further (a likely instance-version negotiation gap, since the extension
|
||||
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
||||
not the finding that matters, and is **not** why this design is rejected.
|
||||
Within a render layer, images are drawn after rects and glyphs. Both primitive
|
||||
lists use `swap_remove`, so no code may infer draw adjacency from arena
|
||||
adjacency after a free.
|
||||
|
||||
**The finding that matters is about real phones, sourced rather than
|
||||
recalled:**
|
||||
Standalone images currently use `NonFiltering` sampling. Thumbnail scaling
|
||||
and filtering remain image-widget decisions, not texture-storage decisions.
|
||||
|
||||
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
||||
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
||||
of October 2025
|
||||
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
||||
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
||||
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
||||
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
||||
unrelated to bindless) and stops there; true of the 2021 and 2022
|
||||
profiles as well.
|
||||
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
||||
is supported on all Valhall and 5th Gen GPUs"**
|
||||
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
||||
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
||||
for Bifrost, Midgard or Utgard, which are still common in budget and
|
||||
older Android phones that are still in daily use.
|
||||
- A search engine's summarized claim of "1% support on Android" for this
|
||||
extension was checked against its cited source (an Arm blog post from
|
||||
2021) and **was not actually there** — that number does not appear in
|
||||
any primary source found and should not be repeated. The baseline-
|
||||
profile finding above is the one with an attributable source; use it
|
||||
instead.
|
||||
## Verification rig
|
||||
|
||||
So this is not a software-renderer artifact. A real, currently-shipping
|
||||
share of the Android fleet lacks the feature iris's texture pipeline asks
|
||||
for unconditionally, and neither the emulator's failure nor the current
|
||||
official hardware baseline gives any reason to expect that to change soon.
|
||||
|
||||
## Implemented, 2026-09-04
|
||||
|
||||
The shape above, built as proposed with one structural addition the proposal
|
||||
didn't need to spell out and one bug it predicted made moot rather than
|
||||
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
||||
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
||||
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
||||
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
||||
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
||||
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
||||
`scripts/rigs/gpu-probe/src/main.rs`.
|
||||
|
||||
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
||||
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
||||
(`grow_array`): a new texture is created at twice the layer capacity, the
|
||||
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
||||
readback), and every bind group that referenced the old view — the main
|
||||
one and every live standalone image's — is rebuilt, since the view's
|
||||
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
||||
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
||||
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
||||
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
||||
any GPU sync happens — the renderer only finds out later, when it
|
||||
processes the queued `Push`.
|
||||
|
||||
**2. Standalone images, one bind group each.** `TextureKind` on
|
||||
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
||||
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
||||
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
||||
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
||||
than through the generic `Primitive` trait, since an image has nowhere in
|
||||
`PrimitiveData` to put a per-instance entry once the bind group already
|
||||
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
||||
once as before, then walks `image_instance` one entry at a time, binding
|
||||
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
||||
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
||||
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
||||
1x1 null view in the image slot.
|
||||
|
||||
**The one addition beyond the proposal**: the masks storage buffer lives
|
||||
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
||||
recreates its buffer whenever the mask count changes size
|
||||
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
||||
A resize invalidates every bind group holding the old buffer, not just the
|
||||
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
||||
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
||||
array-growth path already needed. This wasn't a design question the
|
||||
proposal had to answer (it treated bind-group construction as a given),
|
||||
but it's exactly the shape of trap layer growth already had, so it uses
|
||||
the same fix.
|
||||
|
||||
**3. No thumbnail atlas.** Not built, as proposed.
|
||||
|
||||
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
||||
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
||||
itself, not just its binding-array methods — once its two fields were
|
||||
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
||||
a limits parameter). `binding_array` no longer appears anywhere in
|
||||
`shader.wgsl`.
|
||||
|
||||
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
||||
note that this is a separate decision for whenever the image widget itself
|
||||
is touched.
|
||||
|
||||
**The `changed = false` bug is structurally gone, not patched.** The old
|
||||
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
||||
unconditionally, which could erase an earlier `Push` in the same batch (a
|
||||
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
||||
`Patch`, both queued before the renderer ever runs). The new `update`
|
||||
computes the rebuild signal by OR-ing each event's own answer
|
||||
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
||||
contributes to it — there is no shared mutable flag left for a `Patch` to
|
||||
stomp on. Documented at the call site
|
||||
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
||||
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
||||
the mechanism that could go wrong no longer exists.
|
||||
|
||||
**In-layer draw order is an explicit invariant now, not just a fact about
|
||||
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
||||
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
||||
directly that both of a layer's lists (`instances` and `images`) free with
|
||||
`swap_remove` and that nothing may assume adjacency survives a free —
|
||||
recorded there because `apply_free` is the one place a change to either
|
||||
list's ordering would have to be reconciled.
|
||||
|
||||
**Verified:**
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
||||
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
||||
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
||||
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
||||
test to — it's all GPU resource wiring).
|
||||
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
||||
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
||||
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
||||
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
||||
- The standalone-image path specifically: a throwaway example (not
|
||||
committed) with an `image(...)` widget as part of the root, run the same
|
||||
way, rendered the image next to glyph-atlas text in one frame —
|
||||
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
||||
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
||||
"image span" tab exercises the same widget but needs a click to reach,
|
||||
which the headless compositor can't deliver (no seat devices, per I1's
|
||||
own note on this file) — the throwaway example is what stood in for it.
|
||||
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
||||
Rather than building a purpose-made glyph flood, `PAGE`
|
||||
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
||||
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
||||
exotic: a handful of `Text` widgets at a few sizes, one at
|
||||
`Family::Monospace`) already exceeds one page's worth of distinct
|
||||
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
||||
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
||||
glyphs landed on at least a third layer), and
|
||||
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
||||
correctly with no corruption or missing glyphs — confirming the
|
||||
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
||||
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
||||
both work. Command:
|
||||
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
||||
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
||||
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
||||
throwaway diagnostic value, never a committed change, since a real
|
||||
1024px page holding only a handful of glyphs at a time would be mostly
|
||||
wasted space in normal use. Confirmed the revert left `tabs` and
|
||||
`minimal` byte-identical to the pre-check screenshots afterward.
|
||||
- **The decisive check**, `scripts/rigs/gpu-probe` rewritten to request iris's new
|
||||
(empty) feature/limit set and run on this checkout's own emulator
|
||||
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
||||
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
||||
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
||||
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
||||
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
||||
at all, which reads exactly like the old bindless failure if you don't
|
||||
know to ask for `EMU_GPU=software`):
|
||||
|
||||
cd scripts/rigs/gpu-probe
|
||||
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
||||
cargo ndk -t arm64-v8a -P 26 build --release
|
||||
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
||||
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
||||
adb shell chmod 755 /data/local/tmp/gpu-probe
|
||||
adb shell /data/local/tmp/gpu-probe
|
||||
|
||||
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
||||
`features iris requires:` (none listed — the set is empty),
|
||||
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
||||
measured working, on the exact rig that first measured it failing.
|
||||
Emulator stopped afterward (`emu down`); nothing was left running.
|
||||
`scripts/rigs/gpu-probe` requests Iris's exact feature and limit set without a
|
||||
window. Run it on the target device when changing renderer requirements. A
|
||||
successful desktop adapter is not evidence that the same feature is available
|
||||
on Android hardware.
|
||||
+9
-36
@@ -1,46 +1,19 @@
|
||||
# TODO
|
||||
|
||||
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
|
||||
one in place when it turns out to need a decision.
|
||||
Only open product work lives here. Remove an entry when it lands.
|
||||
|
||||
## App — transcript
|
||||
|
||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
||||
measured: a live Claude session only learns of a peer message from the
|
||||
`origin` object on a turn's `result`
|
||||
(`session/claude/translate.rs`), which the CLI attaches to a turn the
|
||||
message *started*. So a message that arrives mid-turn, or a second one
|
||||
within one turn, has nowhere to be reported — while an imported session,
|
||||
which syncs from the CLI's own file, picks up every one of them. That
|
||||
would show exactly as "sometimes". Confirming it means driving a real
|
||||
stream-json session and sending it messages in both states.
|
||||
appear, sometimes they don't. **Needs a rig.** The live driver only sees
|
||||
the `origin` attached to a turn result, so a mid-turn or second message
|
||||
may have nowhere to appear; imports read every message from the CLI file.
|
||||
Drive a real stream-json session and send messages in both states.
|
||||
|
||||
## Session settings
|
||||
|
||||
- [ ] Autocompact belongs in session settings; empty disables it, which is the
|
||||
default. Iris chose "hand it to the driver" — only where a driver has
|
||||
auto-compaction of its own. **That option was offered on a false premise
|
||||
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
|
||||
was never built as a driver here: `session/llama.rs` talks to
|
||||
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
|
||||
refuses outright. Claude Code's auto-compaction is the CLI's own and
|
||||
nothing in the stream-json control protocol this app uses configures it.
|
||||
So the setting would be stored, passed to a driver, refused by every one
|
||||
of them, and the field would never appear on any session. What is needed
|
||||
first is either a driver that can take it, or a different rule — the
|
||||
server watching `contextTokens` and running `/compact` itself is the one
|
||||
that would work today, for Claude sessions, and it is the option that was
|
||||
not chosen.
|
||||
|
||||
|
||||
## From Iris's phone log export, 2026-09-07 (Compose app)
|
||||
|
||||
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
|
||||
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
|
||||
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
|
||||
range was built with end before start while highlighting a tool
|
||||
input. Found in the per-package system log she exported; the tool
|
||||
input that triggered it is not in the log. Reproduce by fuzzing
|
||||
`highlighted` with inputs whose token boundaries collapse, and guard
|
||||
the range construction.
|
||||
default. No current driver accepts that setting: llama refuses compact,
|
||||
and Claude's stream-json protocol cannot configure the CLI's own
|
||||
autocompaction. This needs either a capable driver or a new rule such as
|
||||
watching `contextTokens` and issuing `/compact` from the server.
|
||||
+2
-11
@@ -3,18 +3,9 @@ name = "event-model"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The common event model, extracted from `server/session/driver.rs` and
|
||||
# `session/transcript.rs` so a Rust client (`client-core`) can share one
|
||||
# definition with the server instead of hand-mirroring it the way
|
||||
# `app/.../Events.kt` used to. Nothing here talks to a process, a file, or a
|
||||
# socket -- it is exactly the wire shape in PLAN.md's "common event model",
|
||||
# plus the transcript envelope and the context-token rule three different
|
||||
# readers (the pump, the transcript, and a phone folding the same events)
|
||||
# have to agree on.
|
||||
# Wire contract shared by the server and Rust client.
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# `Event::ToolStart.input` is a tool's raw call arguments, whatever shape the
|
||||
# dialect gave them -- typing it further would mean this crate knowing every
|
||||
# driver's tool schema.
|
||||
# Tool input stays untyped because its schema belongs to the driver.
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
+15
-238
@@ -1,41 +1,17 @@
|
||||
//! The common event model: what a driver's process turns into before it
|
||||
//! touches the transcript or the phone (see `PLAN.md`'s "The common event
|
||||
//! model"). Extracted from `server/src/session/driver.rs` and
|
||||
//! `session/transcript.rs` on 2026-09-04 so `client-core` shares this
|
||||
//! definition instead of hand-mirroring it, which is what
|
||||
//! `app/.../Events.kt` used to do. `server/`'s `session::driver` module
|
||||
//! re-exports everything here, so nothing downstream of it had to change.
|
||||
//!
|
||||
//! What stayed behind in `server/`: the `Driver` trait, `SessionCommand`,
|
||||
//! `Unqueued` and `EventSink`. Those are how *this* server runs a session,
|
||||
//! not part of what a client reads off the wire.
|
||||
//! The event contract shared by session drivers, transcripts, and clients.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The name a session's image is stored and served under -- minted for an
|
||||
/// upload or for one a tool produced, and fetched back from
|
||||
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
|
||||
/// renders them identically.
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// The name an upload is stored and served under: an image is
|
||||
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||
/// the reader attached and what the session is told. Told apart by
|
||||
/// `crate::media::media_type_for`.
|
||||
pub type AttachmentRef = String;
|
||||
|
||||
/// One choice offered in answer to a [`Event::Question`]. More than a label
|
||||
/// because the reader is deciding rather than confirming: what an option
|
||||
/// means, and what picking it would produce, are what decide it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
/// A sentence about what this option means.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// A block to show as written -- a mockup, a diff, a config file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
@@ -50,91 +26,39 @@ impl QuestionOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a session can tell the outside world. Every event is appended
|
||||
/// to the transcript with a sequence number, then fanned out to SSE
|
||||
/// subscribers, so reconnecting is just "events after seq N" -- no separate
|
||||
/// history path to drift from the live one.
|
||||
/// Everything a session can append to its transcript.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||
// inside them. Both are needed and only the first is obvious: every field
|
||||
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
|
||||
// field went out as snake_case, the app looked for camelCase and found
|
||||
// nothing, and the event still rendered -- as the "no counts reported" case,
|
||||
// which is a state it is allowed to be in.
|
||||
// `rename_all` does not rename fields inside enum variants.
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum Event {
|
||||
/// What the user sent, written into the transcript by the manager (not by
|
||||
/// drivers) so every device renders the conversation from one stream.
|
||||
/// Recorded when the session reads it, which is what `MessageTaken` reports.
|
||||
UserMessage {
|
||||
/// The [`Event::MessageQueued`] this resolves, when it waited. The
|
||||
/// phone has a bubble on screen for the waiting message and needs to
|
||||
/// know *which* one this is, rather than matching on the text and
|
||||
/// clearing the wrong one when the same thing was sent twice.
|
||||
/// The queued message this resolves, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// What was attached, by the ref the files route serves. On the
|
||||
/// message rather than beside it: these used to be their own `Image`
|
||||
/// events just before, which left the phone deciding from adjacency
|
||||
/// which message an image belonged to. `images` on disk until
|
||||
/// 2026-09-03, when files joined them; the alias reads the older rows.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message accepted from the phone that the session cannot read yet.
|
||||
///
|
||||
/// Recorded, unlike the message itself, and that difference is the point:
|
||||
/// the message belongs in the transcript where the session read it, but
|
||||
/// something has to say it is waiting, and it has to be the server. The
|
||||
/// phone used to remember its own outgoing messages, so leaving the
|
||||
/// screen showed nothing pending when something was.
|
||||
///
|
||||
/// Carries no row of its own; resolved by the `UserMessage` bearing the
|
||||
/// same id, as `CommandQueued` is resolved by `CommandSent`.
|
||||
MessageQueued {
|
||||
id: String,
|
||||
text: String,
|
||||
/// Carried for the same reason [`Event::UserMessage`] carries it,
|
||||
/// and it matters more here: a waiting message is on screen for as
|
||||
/// long as the turn runs, so its attachment has nowhere else to be.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message taken out of the queue before the session read it.
|
||||
///
|
||||
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||
/// server's, so what is waiting has to be answerable from the transcript
|
||||
/// alone. Without it a phone that reconnects replays the `MessageQueued`
|
||||
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
|
||||
/// that normally does is exactly what is not coming.
|
||||
///
|
||||
/// Only ever sent for a message that had not been handed over; see
|
||||
/// [`Unqueued::AlreadySent`].
|
||||
MessageDropped {
|
||||
id: String,
|
||||
},
|
||||
/// A driver has taken one of the user's messages and started reading it.
|
||||
/// The manager turns this into the `UserMessage` above, so it never
|
||||
/// reaches a phone itself.
|
||||
///
|
||||
/// It exists because sending and being read are not the same moment. A
|
||||
/// message sent into a running turn waits, and recording it among things
|
||||
/// already read puts it in the transcript above output that predates it.
|
||||
/// Driver-internal acknowledgement; the manager records `UserMessage`.
|
||||
MessageTaken {
|
||||
/// The `MessageQueued` this answers, or `None` when it never waited.
|
||||
/// Carried through onto the `UserMessage`.
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// Streaming assistant text; the phone renders the concatenation as
|
||||
/// markdown.
|
||||
AssistantText {
|
||||
delta: String,
|
||||
},
|
||||
@@ -150,86 +74,34 @@ pub enum Event {
|
||||
ToolEnd {
|
||||
id: String,
|
||||
output: String,
|
||||
/// Whether the tool reported that the call *failed*, from the
|
||||
/// CLI's own `is_error` on the `tool_result`.
|
||||
///
|
||||
/// Added 2026-09-06 with the tool-call cards (RUST.md's P1b),
|
||||
/// because without it a result is the only thing a card has and a
|
||||
/// failed call is drawn as confidently as a successful one -- the
|
||||
/// missing state, not a wrong one. `#[serde(default)]` so a
|
||||
/// transcript written before this field, or a peer on an older
|
||||
/// build, reads back as "not reported to have failed" rather than
|
||||
/// failing to parse; that is the same claim the field's absence
|
||||
/// used to make implicitly.
|
||||
#[serde(default)]
|
||||
is_error: bool,
|
||||
},
|
||||
/// An image the session produced or was sent, saved under the session
|
||||
/// dir and referenced by id; the phone fetches it by URL.
|
||||
Image {
|
||||
#[serde(rename = "ref")]
|
||||
image: ImageRef,
|
||||
/// The tool call whose result carried it, when one did. A screenshot
|
||||
/// belongs under the call that took it, not floating beside it -- the
|
||||
/// reader has to pair them by position otherwise, and position is
|
||||
/// exactly what a page boundary breaks.
|
||||
/// The tool call whose result carried the image.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// Anything the session needs a human for: AskUserQuestion, and
|
||||
/// permission requests, are the same shape with different options.
|
||||
Question {
|
||||
id: String,
|
||||
prompt: String,
|
||||
/// A few words naming what the question is about, when the asker
|
||||
/// offered one. `None` for a permission, which is about the call
|
||||
/// above it.
|
||||
header: Option<String>,
|
||||
options: Vec<QuestionOption>,
|
||||
/// Whether several options may be chosen at once. Here rather than
|
||||
/// left for a phone to work out from the dialect underneath: how many
|
||||
/// answers a question takes is a fact about the question, and the
|
||||
/// alternative was Claude Code's tool-input schema written out a
|
||||
/// second time in Kotlin, where no other dialect could reach it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
multi_select: bool,
|
||||
/// The tool call this is permission for, when it is one, so a phone
|
||||
/// can draw the ask on the tool's own row rather than as a second
|
||||
/// card repeating its input. `None` for anything not about a tool.
|
||||
/// The related tool call, for permission questions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// A message another agent sent this session.
|
||||
///
|
||||
/// Its own kind rather than a `UserMessage`, because it is not something
|
||||
/// the reader said and a transcript that renders it in their voice is
|
||||
/// claiming they did. It also explains what would otherwise be
|
||||
/// inexplicable: a session working on something nobody here asked for.
|
||||
PeerMessage {
|
||||
/// The sending session's own name, which is what the reader
|
||||
/// recognises it by -- the socket path it came from is not.
|
||||
from: String,
|
||||
text: String,
|
||||
/// The seq of the `Status::Running` that opened the turn this message
|
||||
/// started, so a reader can draw it above that turn.
|
||||
///
|
||||
/// The CLI says nothing about a peer message until the turn's
|
||||
/// `result`, so the event is appended after everything it caused, and
|
||||
/// an append-only transcript cannot go back and insert it. Carrying
|
||||
/// the position instead keeps one order on the wire and one on screen.
|
||||
///
|
||||
/// Filled in by the pump, the only place that knows a seq, and only
|
||||
/// where a turn was open: `None` for a message replayed by `import`,
|
||||
/// which already has it in the right place.
|
||||
/// Seq of the `Running` event that opened the turn it belongs above.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
turn_start: Option<u64>,
|
||||
},
|
||||
/// The manager's record of a question being answered, so a rendered
|
||||
/// question card resolves on every device rather than only the one that
|
||||
/// answered.
|
||||
///
|
||||
/// A list because a question can take several answers, and one that took
|
||||
/// one is the list of length one rather than a different shape.
|
||||
Answered {
|
||||
id: String,
|
||||
answers: Vec<String>,
|
||||
@@ -237,103 +109,39 @@ pub enum Event {
|
||||
Status {
|
||||
state: SessionStatus,
|
||||
},
|
||||
/// What the session is set to, as the session itself reports it.
|
||||
///
|
||||
/// Asking for a change and having one are different things, and only this
|
||||
/// is a measurement: a model name the dialect does not know, a mode it
|
||||
/// refuses, or a driver whose model is fixed at startup all leave a
|
||||
/// request that was sent and nothing that changed. Reporting from the
|
||||
/// request put the answer on the phone before the question was answered.
|
||||
///
|
||||
/// Either field alone, because the two are confirmed separately.
|
||||
/// Settings confirmed by the session, not merely requested.
|
||||
Settings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
permission_mode: Option<String>,
|
||||
},
|
||||
/// Per-turn token counts, where the dialect reports them.
|
||||
UsageDelta {
|
||||
/// What this turn cost: the tokens it was charged for.
|
||||
tokens: u64,
|
||||
/// What the model was holding when the turn ended -- see
|
||||
/// [`context_tokens`].
|
||||
///
|
||||
/// Carried rather than summed by whoever is reading, because it is
|
||||
/// not a sum: context goes *down* at a compaction and a clear, so
|
||||
/// adding turns up would report a figure the session stopped being
|
||||
/// true of long ago.
|
||||
///
|
||||
/// `None` where the dialect did not say, which every reader has to be
|
||||
/// able to draw.
|
||||
/// Context held when the turn ended; this is not cumulative usage.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<u64>,
|
||||
},
|
||||
/// A compaction that finished, and how much context it recovered.
|
||||
///
|
||||
/// The counts are the point, and a spinner is not. They are optional
|
||||
/// because the record has shipped without them, and "the compaction
|
||||
/// happened, we don't know by how much" is a state this has to be able to
|
||||
/// say -- a plausible number would be indistinguishable from a counted one.
|
||||
Compacted {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pre_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
post_tokens: Option<u64>,
|
||||
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||
/// session compacted on its own. Carried rather than reduced to a bool
|
||||
/// so an unrecognised trigger stays unrecognised: an automatic
|
||||
/// compaction is the one worth naming, because it explains a wait
|
||||
/// nobody asked for.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
trigger: Option<String>,
|
||||
},
|
||||
/// A command the session was asked to run on itself, held because it
|
||||
/// cannot run yet. These are not messages: `/compact` and `/rename` are
|
||||
/// instructions about the session, and a session mid-turn reads a line
|
||||
/// written to it as something the model should see. So they wait, and
|
||||
/// this is what a phone draws while they do.
|
||||
CommandQueued {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||
/// stops being pending when this arrives, matched by `id`; a command
|
||||
/// that ran immediately has only this.
|
||||
CommandSent {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
/// The conversation was cleared: everything above this is still in the
|
||||
/// record but is no longer in the session's context.
|
||||
///
|
||||
/// Nothing is deleted. A transcript is the thing a person scrolls back
|
||||
/// through, so this is a divider, not a truncation.
|
||||
///
|
||||
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||
/// conversation from the transcript, this marker decides what the model
|
||||
/// is given -- dropping it, or treating it as something only the phone
|
||||
/// draws, silently puts a cleared conversation back in front of the model
|
||||
/// at full cost. Today `llama::conversation` is the only fold that reads
|
||||
/// it, which is why this is written down rather than left to be inferred
|
||||
/// from a second example that does not exist.
|
||||
/// Clears model context without truncating transcript history.
|
||||
Cleared,
|
||||
/// The account behind this session has no quota left, so the turn stopped
|
||||
/// without finishing.
|
||||
///
|
||||
/// Its own event rather than an [`Event::Error`] carrying the dialect's
|
||||
/// sentence, because two things act on it that cannot read English: the
|
||||
/// transcript draws it as a state the session is in rather than as a
|
||||
/// failure of something it did, and `crate::resume` schedules the message
|
||||
/// that picks the work back up. Recognising it belongs to the driver, which
|
||||
/// is the only layer that knows its dialect's wording -- above here nothing
|
||||
/// matches on strings.
|
||||
///
|
||||
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
|
||||
/// said the limit was hit without saying when it lifts. Nothing here
|
||||
/// invents one -- what the wait is actually decided against is the usage
|
||||
/// endpoint, and this is the hint that starts the waiting.
|
||||
LimitReached {
|
||||
/// Epoch seconds; absent when the dialect did not report a reset.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
resets_at: Option<f64>,
|
||||
},
|
||||
@@ -342,36 +150,14 @@ pub enum Event {
|
||||
},
|
||||
}
|
||||
|
||||
/// How much the model was holding, from the three figures a turn reports:
|
||||
/// the input side only, prompt plus both cache figures. A cached token is
|
||||
/// cheaper but it is still one the model was given; output is what the turn
|
||||
/// produced rather than what continuing has to carry.
|
||||
///
|
||||
/// One function so the definition cannot drift, because it is extracted two
|
||||
/// quite different ways -- the live translators have the usage object parsed,
|
||||
/// and `import::context_tokens` scans it out of a raw line without parsing.
|
||||
/// Input context is prompt plus both cache figures, never output tokens.
|
||||
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||
input + cache_creation + cache_read
|
||||
}
|
||||
|
||||
/// The context after `event`, given what it was before.
|
||||
///
|
||||
/// The whole rule in one place, because three readers need the same answer:
|
||||
/// the pump keeping a live session's figure, the transcript seeding it at
|
||||
/// startup, and the phone folding the same events into what it draws.
|
||||
///
|
||||
/// The two that *lower* it are the point. A clear takes the conversation away
|
||||
/// and a compaction replaces it with a summary, so a figure measured before
|
||||
/// either stopped being true at that moment -- and carrying it forward is how
|
||||
/// a session that had just been cleared went on reporting the context it no
|
||||
/// longer had.
|
||||
///
|
||||
/// `None` is "we don't know", which each of them can reach.
|
||||
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||
match event {
|
||||
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||
// measurement standing: stale by a turn, which every context figure
|
||||
// is, rather than wrong.
|
||||
// Missing usage preserves the last measurement; clear does not.
|
||||
Event::UsageDelta { context, .. } => context.or(current),
|
||||
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||
Event::Cleared => None,
|
||||
@@ -387,22 +173,13 @@ pub enum SessionStatus {
|
||||
AwaitingInput,
|
||||
Compacting,
|
||||
Exited,
|
||||
/// There is a process recorded for this session and the machine will not
|
||||
/// say whether it is still running.
|
||||
///
|
||||
/// Its own state rather than the nearest of the others, because both
|
||||
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||
/// second process against a conversation that may already have one, and
|
||||
/// `Idle` claims a session is waiting for you when nobody has checked.
|
||||
/// A process is recorded but liveness could not be determined.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||
/// is flattened so the wire shape stays one flat object.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeqEvent {
|
||||
pub seq: u64,
|
||||
/// Epoch seconds.
|
||||
pub ts: f64,
|
||||
#[serde(flatten)]
|
||||
pub event: Event,
|
||||
|
||||
+8
-82
@@ -3,8 +3,6 @@ name = "iris"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
iris-core = { workspace = true }
|
||||
iris-macro = { workspace = true }
|
||||
@@ -15,83 +13,32 @@ wgpu = { workspace = true }
|
||||
image = { workspace = true }
|
||||
accesskit = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||
# For diagnostics visible through android_logger (or whatever logger the
|
||||
# app crate installs) -- this crate never installs one itself. Not in the
|
||||
# android-only block below any more: the lines that matter most are in
|
||||
# shared widget code, which the host backend compiles too.
|
||||
# The embedding app installs the logger.
|
||||
log = "0.4.34"
|
||||
|
||||
# winit everywhere except Android; android-view (below) is what stands in
|
||||
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||
# doc comment) but are never compiled together: winit's own Android support
|
||||
# pulls in `android-activity`, which panics at compile time unless one of
|
||||
# its own backend features is picked, and picking one is exactly what
|
||||
# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it
|
||||
# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside
|
||||
# `android-activity` itself with "Either game-activity or native-activity
|
||||
# must be enabled" before this split existed.
|
||||
# winit's Android backend conflicts with android-view, which owns that platform here.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
winit = { workspace = true }
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own
|
||||
# adapter over `accesskit`. No pin needed the way android-view's rev is
|
||||
# pinned -- this is an ordinary crates.io release with no local abort to
|
||||
# track (that finding is Android-only, see below).
|
||||
accesskit_winit = "0.34.0"
|
||||
|
||||
# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this
|
||||
# emulator -- real Vulkan rendering, a working `InputConnection`, and the
|
||||
# accesskit-detach abort, all against this rev specifically. Advancing it
|
||||
# wants re-running E1's checks, the same reason the nightly toolchain pin
|
||||
# is dated rather than floating.
|
||||
# Advancing this measured revision requires rechecking rendering, IME, and detach.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||
# I4 (RUST.md): the Android half of the AccessKit push, over android-view's
|
||||
# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1
|
||||
# found on 0.4.0** (the `State` enum still never returns to `Inactive`,
|
||||
# and `send_completed_event` still unwraps a Java exception) -- advancing
|
||||
# the version is not the fix, so pinning to a specific rev buys nothing
|
||||
# here the way it does for android-view itself. `android/view.rs`'s
|
||||
# `raise_if_enabled` is the mitigation, carried from E1.
|
||||
# 0.8.0 still aborts on detach; `view.rs::raise_if_enabled` mitigates it.
|
||||
accesskit_android = "0.8.0"
|
||||
# Not re-exported by android-view (only `jni` and `ndk` are), and needed
|
||||
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||
# android-view's own `PEER_MAP` carries one.
|
||||
send_wrapper = "0.6.0"
|
||||
|
||||
[features]
|
||||
# RUST.md's I5 "Where iris's frame time goes" diagnosis: pins the
|
||||
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one
|
||||
# build can be measured on either backend. A compile-time feature rather
|
||||
# than an env var because nothing on this machine can hand an env var to an
|
||||
# already-launched Android process (there is no `am start` environment and
|
||||
# no system-property reader here to add one).
|
||||
#
|
||||
# **Not needed to get GLES in the emulator**, whatever the history here
|
||||
# says: the emulator's guest has no hardware Vulkan at all, so an ordinary
|
||||
# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the
|
||||
# emulator gives a GPU app"). Keeping the emulator on the same binary the
|
||||
# phone runs is the point. What this feature is still for is forcing GLES
|
||||
# on a machine that *does* have Vulkan -- the desktop -- which is why
|
||||
# `default/render.rs` reads it too:
|
||||
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||
# --features iris/force-gles
|
||||
# Forces GL on Vulkan-capable hosts for comparisons. The emulator already falls back
|
||||
# to hardware GLES; enabling this there would make its build unlike the phone's.
|
||||
force-gles = []
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||
# The tabs example's widget tree. A dev-dependency cycle back to this
|
||||
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||
# to build the library itself, so this only matters for `--examples`.
|
||||
tabs-ui = { path = "tabs-ui" }
|
||||
# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it
|
||||
# reads back. wgpu and pollster are ordinary dependencies already.
|
||||
bytemuck = { workspace = true }
|
||||
|
||||
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||
# test-crate harness cargo would otherwise want, in favour of an ordinary
|
||||
# `fn main()`.
|
||||
[[bench]]
|
||||
name = "message_list"
|
||||
harness = false
|
||||
@@ -108,32 +55,11 @@ members = [
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# Debug info is the reason a `cargo test --workspace` here was taking half
|
||||
# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's
|
||||
# default `debug = true`, linking this workspace's test binaries wrote
|
||||
# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an
|
||||
# **88 GB** `target/`. Eight test binaries each statically link the whole
|
||||
# wgpu + naga + winit + parley graph, and at the default every one of them
|
||||
# gets a full copy of that graph's DWARF written into it. On a btrfs at 83%
|
||||
# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible,
|
||||
# waiting on space reservation -- at about 20 MB/s between them, which is
|
||||
# what "the tests are slow" actually was. Not CPU: the machine was 87% idle
|
||||
# throughout.
|
||||
#
|
||||
# `line-tables-only` keeps what is actually read from a backtrace -- the
|
||||
# file and line of every frame, which is what a panicking test prints and
|
||||
# what gdb needs to name the frames of a segfault. What it gives up is
|
||||
# inspecting variables in a debugger; when that is wanted, ask for it on
|
||||
# the command line for that one run rather than paying for it on every
|
||||
# build:
|
||||
#
|
||||
# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever
|
||||
# Full DWARF once produced 54 GB of writes and an 88 GB target because every test
|
||||
# statically links the renderer stack. Use `RUSTFLAGS="-C debuginfo=2"` when needed.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# The tests are what this is really for; `cargo test` uses `dev` for
|
||||
# dependencies and `test` for the test targets themselves, so setting only
|
||||
# `dev` leaves the eight big binaries at the default.
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ END_TENSION = 1.0
|
||||
P1 = START_TENSION * INFLEXION
|
||||
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
||||
|
||||
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
|
||||
# "look and feel tuning" constant -- a different number in a different place
|
||||
# of the same formula, which is the pair iris got the wrong way round once.
|
||||
SCROLL_FRICTION = 0.015
|
||||
TUNING = 0.84
|
||||
GRAVITY_EARTH = 9.80665
|
||||
@@ -54,7 +51,6 @@ def spline_positions():
|
||||
while True:
|
||||
x = x_min + (x_max - x_min) / 2.0
|
||||
coef = 3.0 * x * (1.0 - x)
|
||||
# Solved on the P1/P2 curve...
|
||||
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
||||
if abs(tx - alpha) < 1e-5:
|
||||
break
|
||||
@@ -62,7 +58,6 @@ def spline_positions():
|
||||
x_max = x
|
||||
else:
|
||||
x_min = x
|
||||
# ...and sampled on the tension curve.
|
||||
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
||||
position[NB_SAMPLES] = 1.0
|
||||
return position
|
||||
|
||||
@@ -1,75 +1,6 @@
|
||||
//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's
|
||||
//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run
|
||||
//! explicitly with `cargo bench --bench message_list --release` or
|
||||
//! `./run-bench.sh`.
|
||||
//!
|
||||
//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario
|
||||
//! here is really "how many `Widget::draw` calls and primitive rewrites did
|
||||
//! this frame cost," which `UiRenderState::take_counters` already answers
|
||||
//! exactly (see `iris/src/layout_tests.rs`, which this file's harness
|
||||
//! mirrors). A short loop that times itself and prints the counters
|
||||
//! alongside the wall time says everything criterion's warm-up/sampling/
|
||||
//! outlier-removal machinery would add on top, for scenarios that are
|
||||
//! fundamentally about a *count*, not a noisy microbenchmark distribution
|
||||
//! -- and it avoids a new dependency this crate does not otherwise need.
|
||||
//! Per the code rules, the plain option is also the one shorter to explain.
|
||||
//!
|
||||
//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
|
||||
//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this
|
||||
//! file built their own giant `Span` and wrapped it in `ScrollArea`, which
|
||||
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
|
||||
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
|
||||
//! still needs every row's *widget* built up front by the caller (its
|
||||
//! module doc explains why: it only ever sees `&dyn Widget` through
|
||||
//! `Painter`, so it cannot construct a row lazily on its own) -- what
|
||||
//! virtualisation buys is that only the rows currently on screen are ever
|
||||
//! *drawn*, which is what the draw/rewrite/move counters below are
|
||||
//! measuring, not construction time.
|
||||
//!
|
||||
//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and
|
||||
//! IRIS_TODO.md's "Benchmarks" wording):
|
||||
//!
|
||||
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
|
||||
//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list
|
||||
//! this is expected to stop scaling with N once N exceeds a screenful --
|
||||
//! the draw/rewrite counters below are the number that used to grow 10x
|
||||
//! per 10x N and should not any more.
|
||||
//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not
|
||||
//! re-layout.
|
||||
//! - (c) the input-box case: growing a fixed-height field at the bottom of
|
||||
//! the screen must move the message list above it, not re-lay its rows.
|
||||
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
|
||||
//! section 8 defines.
|
||||
//! - (d) insert-above-anchor: paging older history onto the front of an
|
||||
//! already-scrolled list. `LazySpan::push_front` is an O(1) index update
|
||||
//! (lazy_span.rs's module doc); this measures that none of the rows already
|
||||
//! on screen are touched by it.
|
||||
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
|
||||
//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move
|
||||
//! only the rows on the far side of it, never redraw the ones already
|
||||
//! correctly placed.
|
||||
//!
|
||||
//! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in
|
||||
//! place, which is what a tool card rebuilt on a tap costs. Every one of
|
||||
//! its primitives is freed and rewritten, and so renumbered in the
|
||||
//! layer's draw order -- the pass that used to be O(N^2) there
|
||||
//! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch
|
||||
//! is per-glyph: it must stay flat as N grows, not grow with it.
|
||||
//!
|
||||
//! (f), many images with zero steady-state bind-group creation, needs a
|
||||
//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead,
|
||||
//! driven through `run-headless.sh` -- see that file's header.
|
||||
//!
|
||||
//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs`
|
||||
//! notes), so everything here runs as an ordinary `--release` binary with
|
||||
//! no compositor. Numbers are recorded in RUST.md's I3 box, not here --
|
||||
//! this file is the rig, not the result.
|
||||
|
||||
use iris::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
/// The minimal `UiRsc` a benchmark needs -- identical in shape to
|
||||
/// `layout_tests.rs`'s `TestRsc`.
|
||||
struct BenchRsc {
|
||||
ui: UiData,
|
||||
}
|
||||
@@ -83,18 +14,12 @@ impl UiRsc for BenchRsc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Long enough to force real wrapping at a phone-plausible column width, and
|
||||
/// varied enough (no two rows byte-identical) that nothing can special-case
|
||||
/// on repeated content.
|
||||
const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \
|
||||
out wrapped text by shaping once per width and caching the result, so a \
|
||||
row that is offered the same width twice does not reshape. This sentence \
|
||||
exists only to give a row enough text to wrap across several lines at a \
|
||||
typical phone column width.";
|
||||
|
||||
/// One message row: a wrapped `Text`, and every `image_every`th row also an
|
||||
/// `Image` beneath it -- a small in-memory RGBA square rather than a file,
|
||||
/// so N=10,000 rows costs no disk I/O.
|
||||
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
|
||||
let mut text = Text::new(format!("Message {i}: {BODY}"));
|
||||
text.wrap = true;
|
||||
@@ -113,9 +38,6 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them
|
||||
/// carrying an image (0 disables images entirely). Returns the list widget
|
||||
/// (weak, so the caller can drive it) and the erased root to render.
|
||||
fn build_message_list(
|
||||
rsc: &mut BenchRsc,
|
||||
n: usize,
|
||||
@@ -127,9 +49,6 @@ fn build_message_list(
|
||||
list.push_back(LazyItem::new(i as u64, row));
|
||||
}
|
||||
let list = rsc.ui.widgets.add_strong(list);
|
||||
// Driven through the span's own `ScrollController`, like every other
|
||||
// scroll area in iris: what this measures has to be the path the app
|
||||
// actually takes.
|
||||
(list.weak(), list.any())
|
||||
}
|
||||
|
||||
@@ -140,7 +59,6 @@ fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64,
|
||||
);
|
||||
}
|
||||
|
||||
/// (a) First-frame cost of a message list of N rows.
|
||||
fn bench_first_frame(n: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -162,10 +80,6 @@ fn bench_first_frame(n: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (b) Per-frame cost of scrolling an already-laid-out list of N rows.
|
||||
/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an
|
||||
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
|
||||
/// rather than a resize), then times a run of individual scroll ticks.
|
||||
fn bench_scroll(n: usize, ticks: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -205,14 +119,6 @@ fn bench_scroll(n: usize, ticks: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (c) The input-box case: a fixed-height field at the bottom of the screen
|
||||
/// growing by a line at a time, with a message list of N rows filling the
|
||||
/// rest of the screen above it. Growing the input shrinks the *offered*
|
||||
/// height of the list container (a single widget, from the outer `Span`'s
|
||||
/// point of view) without changing the width it offers its content -- so
|
||||
/// the rows underneath, which only care about width, must not redraw; the
|
||||
/// list's own re-registration of where its content sits is the one O(1)
|
||||
/// move this is checking for.
|
||||
fn bench_input_grows(n: usize, lines: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -276,13 +182,6 @@ fn bench_input_grows(n: usize, lines: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (d) Insert-above-anchor: the list is scrolled to its very first loaded
|
||||
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
|
||||
/// bottom, so a row prepended above it is genuinely "inserted above the
|
||||
/// anchor" rather than merely far off-screen at the far end. Each
|
||||
/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an
|
||||
/// index, bumped by one) and, since the prepended rows never enter the
|
||||
/// viewport, none of them should cost a draw either.
|
||||
fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -300,9 +199,6 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
let mut total_rewrites = 0u64;
|
||||
let mut total_moves = 0u64;
|
||||
for i in 0..inserts {
|
||||
// Older-history rows: distinct keys below every existing one, so a
|
||||
// real caller's paging code (prepending an older page) is exactly
|
||||
// what this loop does.
|
||||
let row = build_row(&mut rsc, usize::MAX - i, 20);
|
||||
rsc.ui
|
||||
.widgets
|
||||
@@ -333,21 +229,11 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
|
||||
/// directly controllable) is grown a little at a time, each time preceded
|
||||
/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's
|
||||
/// module doc describes and its unit tests check for correctness. This
|
||||
/// measures its *cost*: only the rows on the far side of the grown one
|
||||
/// (below it, since the top edge is held) should ever move, and nothing
|
||||
/// should be redrawn purely because the list overall got taller.
|
||||
fn bench_expand_holds_edge(n: usize, growths: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||
// Near the end (not the very last row) so it is already on screen
|
||||
// under the list's default bottom-anchored placement, for every N --
|
||||
// no scrolling needed to bring it into view before measuring.
|
||||
let growable_index = n.saturating_sub(3);
|
||||
let mut growable = None;
|
||||
for i in 0..n {
|
||||
@@ -415,23 +301,10 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (g) One text widget of `chars` characters, redrawn in place `redraws`
|
||||
/// times -- an open tool card whose content is rebuilt, or any widget
|
||||
/// holding a lot of text that a tap changes.
|
||||
///
|
||||
/// A redraw frees every primitive the widget owned and writes fresh ones,
|
||||
/// so every glyph is renumbered in its layer's draw order. Finding the
|
||||
/// handle to renumber used to be a scan of everything the same widget
|
||||
/// drew, which made one redraw quadratic in its own glyph count: 1.37s for
|
||||
/// 51,200 glyphs on this machine, against 20ms to shape and rasterise the
|
||||
/// same text. Print per-glyph rather than per-redraw, since flat is the
|
||||
/// pass condition and a total says nothing without dividing it.
|
||||
fn bench_redraw_big_text(chars: usize, redraws: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
// One character per glyph, and varied so nothing can collapse the
|
||||
// string into a repeat.
|
||||
let content: String = (0..chars)
|
||||
.map(|i| char::from(b'a' + (i % 26) as u8))
|
||||
.collect();
|
||||
@@ -448,8 +321,6 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) {
|
||||
|
||||
let mut total = std::time::Duration::ZERO;
|
||||
for _ in 0..redraws {
|
||||
// Asking for the widget mutably is what marks it for redraw --
|
||||
// the same path a caller changing its content takes.
|
||||
rsc.ui.widgets.get_mut(&handle).unwrap();
|
||||
let start = Instant::now();
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
@@ -38,8 +38,6 @@ LINE_RE = re.compile(
|
||||
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
|
||||
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
|
||||
)
|
||||
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
|
||||
# -- see `log_input_event`'s own doc for why order matters.
|
||||
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
|
||||
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ HORIZON_MILLISECONDS = 100.0
|
||||
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
|
||||
MIN_SAMPLE_SIZE_LSQ2 = 3
|
||||
|
||||
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
|
||||
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
|
||||
# DefaultFlingBehavior.performFling's own threshold, in the units of the
|
||||
# positions fed to the tracker -- pixels per second here.
|
||||
@@ -103,7 +102,6 @@ def poly_fit_least_squares(x, y, sample_count, degree):
|
||||
m = sample_count
|
||||
n = truncated_degree + 1
|
||||
|
||||
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
|
||||
a = [[0.0] * m for _ in range(n)]
|
||||
for h in range(m):
|
||||
a[0][h] = 1.0
|
||||
@@ -219,7 +217,6 @@ def average(samples):
|
||||
return (samples[-1][1] - samples[0][1]) / span
|
||||
|
||||
|
||||
# --- The three recorded sample sets the Rust tests assert on. ----------------
|
||||
|
||||
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
|
||||
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
|
||||
@@ -243,19 +240,12 @@ ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (5
|
||||
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
|
||||
# drag: the burst must not leak into the estimate.
|
||||
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
|
||||
# (b) The finger stops for 48ms and then lifts. The gap exceeds
|
||||
# AssumePointerMoveStopped, so the walk breaks after one sample and
|
||||
# there is no fling -- what stops a "park it and let go" from
|
||||
# flinging at whatever speed the finger arrived with.
|
||||
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
||||
|
||||
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
|
||||
# press and two move frames, which is the fewest a fit can use.
|
||||
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
|
||||
# ... and one move frame, which Compose cannot fit either.
|
||||
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
|
||||
|
||||
# The phone: 1080x2424 at content_scale 2.55.
|
||||
PHONE_DENSITY = 2.55
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wgpu = { workspace = true }
|
||||
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
|
||||
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
|
||||
# `block_on` turns that one async pop into the same synchronous call shape
|
||||
# `device_limits()`'s two callers already use for `request_adapter`/
|
||||
# `request_device`, rather than making this crate's one entry point async.
|
||||
# Keeps renderer creation synchronous while retrieving wgpu's async error scope.
|
||||
pollster = { workspace = true }
|
||||
bytemuck ={ workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
# point of subsetting is to ship only the codepoints one app draws.
|
||||
set -euo pipefail
|
||||
|
||||
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
|
||||
# Icons, as in the Compose app.
|
||||
GLYPHS=(
|
||||
U+F035D # md-menu_down -- a card that is open
|
||||
U+F035F # md-menu_right -- a card that opens
|
||||
|
||||
@@ -79,7 +79,6 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
|
||||
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||
// TODO: reduce visiblity!!
|
||||
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
||||
/// This event's own input-wide state -- see [`Event::Global`].
|
||||
pub global: E::Global,
|
||||
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
||||
}
|
||||
|
||||
@@ -9,19 +9,6 @@ pub use rsc::*;
|
||||
pub trait Event: Sized + 'static + Clone {
|
||||
type Data<'a>: Clone = ();
|
||||
type State: Default = ();
|
||||
/// State this event owns that belongs to no single widget -- what the
|
||||
/// thing dispatching the event knows about the *input*, rather than
|
||||
/// about a listener. `()` for almost every event; the cursor's is
|
||||
/// `iris::sense::PointerInput` (which widget holds pointer capture,
|
||||
/// and who is tracking the press in flight).
|
||||
///
|
||||
/// It lives here so that such state has one owner, reached by `&mut`
|
||||
/// through the event manager, instead of being parked on whatever
|
||||
/// structure a handler happens to be able to reach and guarded with a
|
||||
/// lock. Iris asked for that on 2026-09-08, of the pointer capture
|
||||
/// that used to sit in a `Mutex` on `UiRenderState`: "everything
|
||||
/// global should be stored in the general input handler, not in
|
||||
/// specific senses with locking stuff."
|
||||
type Global: Default = ();
|
||||
#[allow(unused_variables)]
|
||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||
|
||||
@@ -1,39 +1,5 @@
|
||||
//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships.
|
||||
//!
|
||||
//! **Why a bundled font rather than ordinary Unicode**: the disclosure
|
||||
//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform
|
||||
//! resolved, and once iris stopped bundling fonts (decided
|
||||
//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a
|
||||
//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is
|
||||
//! also what the Compose app has always done for its icons
|
||||
//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design
|
||||
//! family, so an icon means the same thing in both apps.
|
||||
//!
|
||||
//! **Why not vector assets or drawn shapes**: an icon beside a line of
|
||||
//! text wants that line's size, colour and baseline, and text gets all
|
||||
//! three for free. This replaced `iris::widget::mark`, which drew the
|
||||
//! triangle into a texture: correct, but one shape, and every further icon
|
||||
//! would have been another bespoke rasteriser.
|
||||
//!
|
||||
//! Each constant here has to have a matching codepoint in
|
||||
//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the
|
||||
//! script did not subset is a glyph that silently isn't there. The subset
|
||||
//! is the font's **Mono** face, where every glyph is one em wide and one
|
||||
//! em tall, so two icons at one font size are one size without either
|
||||
//! being given one -- and why an icon looks smaller than text at the same
|
||||
//! size, since the glyph is drawn inside that em rather than filling it.
|
||||
//!
|
||||
//! Draw one with [`crate::Family::Icons`]:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons)
|
||||
//! ```
|
||||
|
||||
/// `md-menu_down` -- a filled triangle pointing down: this card is open.
|
||||
pub const OPEN: &str = "\u{F035D}";
|
||||
|
||||
/// `md-menu_right` -- pointing right: this card opens.
|
||||
pub const CLOSED: &str = "\u{F035F}";
|
||||
|
||||
/// `md-menu_up` -- pointing up: fold this group of cards away again.
|
||||
pub const COLLAPSE: &str = "\u{F0360}";
|
||||
@@ -88,6 +88,10 @@ impl RegionAlign {
|
||||
pub const fn rel(&self) -> Vec2 {
|
||||
vec2(self.x.rel(), self.y.rel())
|
||||
}
|
||||
|
||||
pub const fn pos(self) -> UiVec2 {
|
||||
UiVec2::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl UiVec2 {
|
||||
@@ -192,9 +196,3 @@ const impl From<RegionAlign> for UiVec2 {
|
||||
Self::rel(align.rel())
|
||||
}
|
||||
}
|
||||
|
||||
impl RegionAlign {
|
||||
pub const fn pos(self) -> UiVec2 {
|
||||
UiVec2::from(self)
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,6 @@ pub struct Len {
|
||||
/// the two are kept separate rather than one field a caller has to
|
||||
/// remember to pre-multiply.
|
||||
pub abs: f32,
|
||||
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
|
||||
/// (1 unit = 1/160in), resolved against the display's density at
|
||||
/// layout time (`apply_rest`'s `density` parameter) rather than at the
|
||||
/// point a widget is built, since density is a property of the device
|
||||
/// this ends up running on, not of the widget tree. This is the unit
|
||||
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
|
||||
/// 2026-09-06: before it existed, every size in the tree was `abs`
|
||||
/// (physical pixels), and the only way to make a 16px design draw at
|
||||
/// the right *size* on a denser display was a single global multiply
|
||||
/// applied to the whole rendered scene after layout -- which is also
|
||||
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
|
||||
/// at logical size and stretched by the scale"): a glyph rasterised at
|
||||
/// 16 physical px and then stretched 3x by that global multiply is a
|
||||
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
|
||||
/// layout time instead means the font size handed to the text shaper
|
||||
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
|
||||
/// atlas rasterises at the display's real resolution and nothing
|
||||
/// downstream needs to stretch anything.
|
||||
pub dp: f32,
|
||||
pub rel: f32,
|
||||
pub rest: f32,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::marker::Destruct;
|
||||
|
||||
/// stored in linear for sane manipulation
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
|
||||
pub struct Color<T> {
|
||||
|
||||
@@ -14,19 +14,13 @@ struct LayerNode<T> {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Ptr {
|
||||
/// continue on same level
|
||||
Next(usize),
|
||||
/// go back to parent
|
||||
Parent(usize),
|
||||
/// end
|
||||
None,
|
||||
}
|
||||
|
||||
/// TODO: currently this does not ever free layers
|
||||
/// is that realistically desired?
|
||||
pub struct Layers<T> {
|
||||
vec: Vec<LayerNode<T>>,
|
||||
/// index of last layer at top level (start at first = 0)
|
||||
last: usize,
|
||||
}
|
||||
|
||||
@@ -36,9 +30,6 @@ struct Child {
|
||||
tail: usize,
|
||||
}
|
||||
|
||||
/// The draw order of every layer. The primitives themselves live in one
|
||||
/// arena beside this (`UiRenderState::primitives`); a layer names the
|
||||
/// slots it draws, which is what its vertex buffer is.
|
||||
pub type PrimitiveLayers = Layers<LayerOrder>;
|
||||
|
||||
impl<T: Default> Layers<T> {
|
||||
|
||||
+244
-409
@@ -12,40 +12,13 @@ use swash::{
|
||||
zeno::{Format, Vector},
|
||||
};
|
||||
|
||||
/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
|
||||
/// by `iris/core/build-icon-font.sh`, holding only the codepoints
|
||||
/// `crate::icon` names (992 bytes for three glyphs today).
|
||||
///
|
||||
/// This is the one font bundled here, and it is not a text font: body and
|
||||
/// monospace text still come from the platform's own collection
|
||||
/// (decided 2026-09-07). An icon is the opposite case -- a small,
|
||||
/// closed set of codepoints no system font is guaranteed to have -- which
|
||||
/// is the same division the Compose app makes.
|
||||
const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf");
|
||||
|
||||
/// What starting up found about text rendering, for the on-screen
|
||||
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
|
||||
/// once at startup ... the number of font families found, the default
|
||||
/// family resolved"). Built once by `TextData::font_diagnostics` --
|
||||
/// `Default::default` still exists for callers (tests, examples) that
|
||||
/// don't need the report.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FontDiagnostics {
|
||||
/// `Collection::family_names().count()` after registering the bundled
|
||||
/// fonts -- system families plus the two bundled ones.
|
||||
pub families_found: usize,
|
||||
/// The family `GenericFamily::SansSerif` resolves to first -- the
|
||||
/// bundled "Noto Sans" unless registration itself failed.
|
||||
pub default_family: Option<String>,
|
||||
/// The family `GenericFamily::Monospace` resolves to first.
|
||||
pub default_mono_family: Option<String>,
|
||||
/// One resolved family name per style axis this crate actually uses
|
||||
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
|
||||
/// bold/italic request is landing on a real face rather than being
|
||||
/// silently absorbed by whatever the sans-serif default resolves to
|
||||
/// for every weight (RUST.md's P0 box, "bold words render as blank
|
||||
/// gaps" -- a family that resolves but has no distinct bold face is
|
||||
/// exactly what produced that).
|
||||
pub regular_resolved: Option<String>,
|
||||
pub bold_resolved: Option<String>,
|
||||
pub italic_resolved: Option<String>,
|
||||
@@ -57,8 +30,6 @@ pub struct FontDiagnostics {
|
||||
pub icon_family: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything text needs that outlives one string: the font collection, the
|
||||
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
||||
pub struct TextData {
|
||||
pub font_cx: FontContext,
|
||||
pub layout_cx: LayoutContext<UiColor>,
|
||||
@@ -84,19 +55,6 @@ pub struct TextData {
|
||||
}
|
||||
|
||||
impl Default for TextData {
|
||||
/// Text comes entirely from the platform's own font collection --
|
||||
/// `FontContext::new()` builds a `fontique::Collection` with
|
||||
/// `CollectionOptions::system_fonts` on by default, which is real
|
||||
/// discovery on both targets this crate ships on: Android's backend
|
||||
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
|
||||
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
|
||||
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
|
||||
/// build's backend is fontconfig. No font is bundled or registered
|
||||
/// here -- see the 2026-09-07 decision for why (matching what
|
||||
/// the Compose app does: it takes body/monospace text from
|
||||
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
|
||||
/// and its platform monospace face, and ships no text font of its own,
|
||||
/// only its committed Nerd Fonts icon subset for fixed glyphs).
|
||||
fn default() -> Self {
|
||||
let mut font_cx = FontContext::new();
|
||||
patch_android_monospace(&mut font_cx);
|
||||
@@ -112,16 +70,6 @@ impl Default for TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the bundled icon font as an ordinary named family and
|
||||
/// answers the name it registered under -- read back from the collection
|
||||
/// rather than written down here, so the name cannot drift from the file
|
||||
/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release
|
||||
/// ships).
|
||||
///
|
||||
/// A *named* family rather than a generic one: nothing should fall back
|
||||
/// to it for ordinary text, and nothing should fall back out of it for an
|
||||
/// icon -- a system face that happens to have one of these codepoints
|
||||
/// would draw somebody else's picture.
|
||||
fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
|
||||
let blob = Blob::new(Arc::new(NERD_ICONS));
|
||||
let id = font_cx
|
||||
@@ -133,24 +81,6 @@ fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
|
||||
font_cx.collection.family_name(id).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Works around `fontique` 0.11.1's Android backend never resolving
|
||||
/// `GenericFamily::Monospace` (confirmed against
|
||||
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
|
||||
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
|
||||
/// released fix to bump to yet -- see the 2026-09-07 decision,
|
||||
/// "Platform fonts," for the full account). Two bugs stack, not one:
|
||||
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
|
||||
/// `fonts.xml` is parsed into that same name map, and even after parsing,
|
||||
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
|
||||
/// (not an `<alias>`) whose `<font>` children the backend's own parser
|
||||
/// does not read (a `TODO` in that match arm) -- so the name gets a
|
||||
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
|
||||
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
|
||||
/// /system/etc/fonts.xml` shows
|
||||
/// `<family name="monospace"><font weight="400"
|
||||
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
|
||||
/// alias.
|
||||
///
|
||||
/// So this reads `fonts.xml` itself (already on-device, already the
|
||||
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
|
||||
/// filename that declaration names, then finds which of fontique's
|
||||
@@ -204,13 +134,6 @@ fn patch_android_monospace(font_cx: &mut FontContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
|
||||
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
|
||||
/// real XML parser -- a new dependency for one well-known, stable AOSP file
|
||||
/// whose structure fontique itself already parses with a full parser one
|
||||
/// module over. Not a general XML reader; assumes the file has exactly one
|
||||
/// `<family name="monospace">` element with at least one `<font>` child,
|
||||
/// which is the format on every AOSP `fonts.xml` this was checked against.
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_monospace_font_filename() -> Option<String> {
|
||||
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
|
||||
@@ -231,9 +154,6 @@ fn android_monospace_font_filename() -> Option<String> {
|
||||
fn patch_android_monospace(_font_cx: &mut FontContext) {}
|
||||
|
||||
impl TextData {
|
||||
/// [`Family::Icons`] as the name the bundled font actually registered
|
||||
/// under; everything else unchanged.
|
||||
///
|
||||
/// Cloned rather than borrowed because the caller needs it while the
|
||||
/// layout builder holds `&mut self` -- a `String` per shaped icon run,
|
||||
/// paid only when the layout is rebuilt.
|
||||
@@ -247,9 +167,6 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the startup report -- see `FontDiagnostics`. Queries the
|
||||
/// collection directly (`fontique::Query`) rather than shaping a real
|
||||
/// string, since all that's needed is which family each axis lands on.
|
||||
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
|
||||
use parley::fontique::{Attributes, FontWidth, QueryStatus};
|
||||
let families_found = self.font_cx.collection.family_names().count();
|
||||
@@ -268,11 +185,6 @@ impl TextData {
|
||||
let default_mono_family = default_mono_family_id
|
||||
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||
|
||||
// Resolves the family a (generic family, weight, style) query lands
|
||||
// on, without holding the `Query`'s borrow of `collection` across
|
||||
// the `family_name` lookup that needs it back -- the `FamilyId` is
|
||||
// captured out of the closure first, then looked up once `query`
|
||||
// (and its borrow) has been dropped.
|
||||
let mut resolve_family =
|
||||
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
|
||||
let mut family_id = None;
|
||||
@@ -327,285 +239,7 @@ impl TextData {
|
||||
icon_family: self.icon_family.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which family to ask for. Kept as an owned name rather than parley's
|
||||
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Family {
|
||||
SansSerif,
|
||||
Serif,
|
||||
Monospace,
|
||||
/// The bundled icon font -- see [`crate::icon`] for what is in it.
|
||||
/// Named as an intention rather than as a font name because only
|
||||
/// [`TextData`] knows what the file registered as; it resolves this
|
||||
/// during shaping ([`TextData::resolve_family`]).
|
||||
Icons,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl Family {
|
||||
fn family(&self) -> FontFamily<'_> {
|
||||
let name = match self {
|
||||
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
||||
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
||||
// Only reachable if `resolve_family` did not run, which no
|
||||
// shaping path allows -- and sans-serif is the honest answer
|
||||
// for a build whose icon font failed to register: the reader
|
||||
// gets the platform's own tofu rather than a wrong picture.
|
||||
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
||||
};
|
||||
FontFamily::Single(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style
|
||||
/// over `range` (a byte range into the buffer's text). Every field is
|
||||
/// optional so a span only says what it changes -- e.g. a link span sets
|
||||
/// `color` and `underline` and leaves weight/family at the paragraph's own
|
||||
/// default. This is I5's answer to RUST.md's inline-rich-text ceiling
|
||||
/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the
|
||||
/// whole editor, with `// TODO: RichTextInput` beside it): parley's own
|
||||
/// `RangedBuilder::push` already takes a style and a range, so per-span
|
||||
/// bold/italic/monospace/colour/underline only needed plumbing this struct
|
||||
/// through to it and giving each glyph its own colour at draw time (see
|
||||
/// `PlacedGlyph::color` and `TextData::place` below) instead of the one
|
||||
/// `RenderedText::color` every glyph used to share.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SpanStyle {
|
||||
pub range: Range<usize>,
|
||||
pub color: Option<UiColor>,
|
||||
pub family: Option<Family>,
|
||||
/// Overrides `TextAttrs::font_size` for just this range -- what lets a
|
||||
/// heading inside a transcript row's single `TextEdit` be bigger than
|
||||
/// the paragraph text around it, so a whole markdown-folded row (block
|
||||
/// and inline styling both) can stay one selectable text buffer instead
|
||||
/// of one widget per block.
|
||||
pub font_size: Option<f32>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl SpanStyle {
|
||||
pub fn new(range: Range<usize>) -> Self {
|
||||
Self {
|
||||
range,
|
||||
color: None,
|
||||
family: None,
|
||||
font_size: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
}
|
||||
}
|
||||
pub fn color(mut self, color: UiColor) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
pub fn family(mut self, family: Family) -> Self {
|
||||
self.family = Some(family);
|
||||
self
|
||||
}
|
||||
pub fn font_size(mut self, size: f32) -> Self {
|
||||
self.font_size = Some(size);
|
||||
self
|
||||
}
|
||||
pub fn bold(mut self) -> Self {
|
||||
self.bold = true;
|
||||
self
|
||||
}
|
||||
pub fn italic(mut self) -> Self {
|
||||
self.italic = true;
|
||||
self
|
||||
}
|
||||
pub fn underline(mut self) -> Self {
|
||||
self.underline = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct TextAttrs {
|
||||
pub color: UiColor,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub family: Family,
|
||||
pub wrap: bool,
|
||||
/// inner alignment of text region (within where it's drawn)
|
||||
pub align: RegionAlign,
|
||||
}
|
||||
|
||||
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
||||
|
||||
impl Default for TextAttrs {
|
||||
fn default() -> Self {
|
||||
let size = 16.0;
|
||||
Self {
|
||||
color: UiColor::WHITE,
|
||||
font_size: size,
|
||||
line_height: size * LINE_HEIGHT_MULT,
|
||||
family: Family::SansSerif,
|
||||
wrap: false,
|
||||
align: Align::CENTER_LEFT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A string together with its laid-out form.
|
||||
///
|
||||
/// The text and the layout live in one place because parley's `Layout` borrows
|
||||
/// nothing but is only meaningful against the string it was built from: keeping
|
||||
/// them apart is how they get out of step.
|
||||
pub struct TextBuffer {
|
||||
text: String,
|
||||
layout: Layout<UiColor>,
|
||||
spans: Vec<SpanStyle>,
|
||||
/// What the current layout was built for, so `shape` can decline to redo
|
||||
/// work that would come out the same. Spans are not part of this key --
|
||||
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||
/// does, since spans change far less often than a naive equality check
|
||||
/// on the whole `Vec` would cost to compute every frame.
|
||||
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
layout: Layout::new(),
|
||||
spans: Vec::new(),
|
||||
shaped: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace this buffer's per-range style overrides (I5's rich text --
|
||||
/// see `SpanStyle`). Invalidates the layout unconditionally, mirroring
|
||||
/// `set_text`.
|
||||
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
|
||||
self.spans = spans;
|
||||
self.shaped = None;
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self::new("")
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> &Layout<UiColor> {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
if text != self.text {
|
||||
self.text = text;
|
||||
self.shaped = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit the string in place; invalidates the layout unconditionally, since
|
||||
/// the caller is assumed to have changed something.
|
||||
pub fn edit(&mut self) -> &mut String {
|
||||
self.shaped = None;
|
||||
&mut self.text
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
|
||||
/// Lay the text out, unless it is already laid out for these
|
||||
/// attributes, this width and this density.
|
||||
///
|
||||
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
|
||||
/// are density-independent (dp) units, multiplied by `density` here --
|
||||
/// the one place text crosses from the widget tree's dp sizes into the
|
||||
/// physical pixels the shaper and rasteriser (`TextData::place`) both
|
||||
/// then work in.** This is what makes glyphs sharp on a dense display:
|
||||
/// before this existed, `font_size` was already a physical-pixel value
|
||||
/// (RUST.md's P0 box's global-scale stopgap resolved density by
|
||||
/// stretching the whole rendered frame afterward instead), so a glyph
|
||||
/// was rasterised small and then upscaled by whatever the display's
|
||||
/// scale factor was -- exactly the blur Iris's report described.
|
||||
/// Multiplying here instead means the font size hitting `ScaleContext`
|
||||
/// in `place` below is already the display's real physical size, so
|
||||
/// the atlas holds a bitmap at the resolution it is actually shown at.
|
||||
/// `GlyphKey.size` already keys on that resolved `font_size`
|
||||
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
|
||||
/// physical size with no change needed there.
|
||||
pub fn shape(
|
||||
&mut self,
|
||||
data: &mut TextData,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
density: f32,
|
||||
) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||
return;
|
||||
}
|
||||
// Resolved before the builder borrows `data`: `Family::Icons`
|
||||
// names an intention, and the name behind it lives on `TextData`.
|
||||
let base_family = data.resolve_family(&attrs.family);
|
||||
let span_families: Vec<Option<Family>> = self
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
|
||||
.collect();
|
||||
let mut builder = data
|
||||
.layout_cx
|
||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||
builder.push_default(StyleProperty::FontFamily(base_family.family()));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||
attrs.line_height * density,
|
||||
)));
|
||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||
for (span, family) in self.spans.iter().zip(&span_families) {
|
||||
let range = span.range.clone();
|
||||
if let Some(color) = span.color {
|
||||
builder.push(StyleProperty::Brush(color), range.clone());
|
||||
}
|
||||
if let Some(family) = family {
|
||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||
}
|
||||
if let Some(size) = span.font_size {
|
||||
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||
}
|
||||
if span.bold {
|
||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||
}
|
||||
if span.italic {
|
||||
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
|
||||
}
|
||||
if span.underline {
|
||||
builder.push(StyleProperty::Underline(true), range.clone());
|
||||
}
|
||||
}
|
||||
builder.build_into(&mut self.layout, &self.text);
|
||||
self.layout.break_all_lines(width);
|
||||
self.layout
|
||||
.align(Alignment::Start, AlignmentOptions::default());
|
||||
self.shaped = Some((attrs.clone(), width, density));
|
||||
}
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
/// Rasterise whatever of `buffer` is not in the atlas yet, and return where
|
||||
/// each glyph goes relative to the text's top-left.
|
||||
///
|
||||
/// Nothing is uploaded for a glyph already in the atlas, which is the point
|
||||
/// of having one: a resize re-runs this and touches the GPU only if the new
|
||||
/// width brought genuinely new glyphs into view.
|
||||
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
|
||||
let mut placed = Vec::new();
|
||||
for line in buffer.layout.lines() {
|
||||
@@ -622,8 +256,6 @@ impl TextData {
|
||||
continue;
|
||||
};
|
||||
let coords_hash = hash_coords(coords);
|
||||
// `font.data.id()` rather than the pointer, so the same font
|
||||
// loaded twice is still one set of entries.
|
||||
let font_id = font.data.id();
|
||||
|
||||
for glyph in run.positioned_glyphs() {
|
||||
@@ -676,41 +308,7 @@ impl TextData {
|
||||
}
|
||||
placed
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_coords(coords: &[i16]) -> u64 {
|
||||
// FxHash over the coordinates; they are short and change rarely.
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for c in coords {
|
||||
h ^= *c as u16 as u64;
|
||||
h = h.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A laid-out string, ready to draw: where each glyph goes and how big the
|
||||
/// whole thing is.
|
||||
///
|
||||
/// Cheap to clone and to keep, which is the point -- a widget holds one across
|
||||
/// frames and re-emits its quads without going near the rasteriser. `color`
|
||||
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
|
||||
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
|
||||
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
|
||||
/// override per range.
|
||||
#[derive(Clone)]
|
||||
pub struct RenderedText {
|
||||
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||
pub size: Vec2,
|
||||
pub color: UiColor,
|
||||
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
|
||||
/// A holder must re-render rather than re-emit these quads once the
|
||||
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
|
||||
/// otherwise); `Painter::glyphs` debug-asserts it.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
/// Lay out and place in one step, which is what a widget wants.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
buffer: &mut TextBuffer,
|
||||
@@ -730,16 +328,255 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which family to ask for. Kept as an owned name rather than parley's
|
||||
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Family {
|
||||
SansSerif,
|
||||
Serif,
|
||||
Monospace,
|
||||
/// The bundled icon font -- see [`crate::icon`] for what is in it.
|
||||
/// Named as an intention rather than as a font name because only
|
||||
/// [`TextData`] knows what the file registered as; it resolves this
|
||||
/// during shaping ([`TextData::resolve_family`]).
|
||||
Icons,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl Family {
|
||||
fn family(&self) -> FontFamily<'_> {
|
||||
let name = match self {
|
||||
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
||||
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
||||
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
||||
};
|
||||
FontFamily::Single(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SpanStyle {
|
||||
pub range: Range<usize>,
|
||||
pub color: Option<UiColor>,
|
||||
pub family: Option<Family>,
|
||||
pub font_size: Option<f32>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl SpanStyle {
|
||||
pub fn new(range: Range<usize>) -> Self {
|
||||
Self {
|
||||
range,
|
||||
color: None,
|
||||
family: None,
|
||||
font_size: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
}
|
||||
}
|
||||
pub fn color(mut self, color: UiColor) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
pub fn family(mut self, family: Family) -> Self {
|
||||
self.family = Some(family);
|
||||
self
|
||||
}
|
||||
pub fn font_size(mut self, size: f32) -> Self {
|
||||
self.font_size = Some(size);
|
||||
self
|
||||
}
|
||||
pub fn bold(mut self) -> Self {
|
||||
self.bold = true;
|
||||
self
|
||||
}
|
||||
pub fn italic(mut self) -> Self {
|
||||
self.italic = true;
|
||||
self
|
||||
}
|
||||
pub fn underline(mut self) -> Self {
|
||||
self.underline = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct TextAttrs {
|
||||
pub color: UiColor,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub family: Family,
|
||||
pub wrap: bool,
|
||||
pub align: RegionAlign,
|
||||
}
|
||||
|
||||
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
||||
|
||||
impl Default for TextAttrs {
|
||||
fn default() -> Self {
|
||||
let size = 16.0;
|
||||
Self {
|
||||
color: UiColor::WHITE,
|
||||
font_size: size,
|
||||
line_height: size * LINE_HEIGHT_MULT,
|
||||
family: Family::SansSerif,
|
||||
wrap: false,
|
||||
align: Align::CENTER_LEFT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text and the layout live in one place because parley's `Layout` borrows
|
||||
/// nothing but is only meaningful against the string it was built from: keeping
|
||||
/// them apart is how they get out of step.
|
||||
pub struct TextBuffer {
|
||||
text: String,
|
||||
layout: Layout<UiColor>,
|
||||
spans: Vec<SpanStyle>,
|
||||
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
layout: Layout::new(),
|
||||
spans: Vec::new(),
|
||||
shaped: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
|
||||
self.spans = spans;
|
||||
self.shaped = None;
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self::new("")
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> &Layout<UiColor> {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, text: impl Into<String>) {
|
||||
let text = text.into();
|
||||
if text != self.text {
|
||||
self.text = text;
|
||||
self.shaped = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit the string in place; invalidates the layout unconditionally, since
|
||||
/// the caller is assumed to have changed something.
|
||||
pub fn edit(&mut self) -> &mut String {
|
||||
self.shaped = None;
|
||||
&mut self.text
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
|
||||
pub fn shape(
|
||||
&mut self,
|
||||
data: &mut TextData,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
density: f32,
|
||||
) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||
return;
|
||||
}
|
||||
let base_family = data.resolve_family(&attrs.family);
|
||||
let span_families: Vec<Option<Family>> = self
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
|
||||
.collect();
|
||||
let mut builder = data
|
||||
.layout_cx
|
||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||
builder.push_default(StyleProperty::FontFamily(base_family.family()));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||
attrs.line_height * density,
|
||||
)));
|
||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||
for (span, family) in self.spans.iter().zip(&span_families) {
|
||||
let range = span.range.clone();
|
||||
if let Some(color) = span.color {
|
||||
builder.push(StyleProperty::Brush(color), range.clone());
|
||||
}
|
||||
if let Some(family) = family {
|
||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||
}
|
||||
if let Some(size) = span.font_size {
|
||||
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||
}
|
||||
if span.bold {
|
||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||
}
|
||||
if span.italic {
|
||||
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
|
||||
}
|
||||
if span.underline {
|
||||
builder.push(StyleProperty::Underline(true), range.clone());
|
||||
}
|
||||
}
|
||||
builder.build_into(&mut self.layout, &self.text);
|
||||
self.layout.break_all_lines(width);
|
||||
self.layout
|
||||
.align(Alignment::Start, AlignmentOptions::default());
|
||||
self.shaped = Some((attrs.clone(), width, density));
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_coords(coords: &[i16]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for c in coords {
|
||||
h ^= *c as u16 as u64;
|
||||
h = h.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Cheap to clone and to keep, which is the point -- a widget holds one across
|
||||
/// frames and re-emits its quads without going near the rasteriser. `color`
|
||||
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
|
||||
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
|
||||
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
|
||||
/// override per range.
|
||||
#[derive(Clone)]
|
||||
pub struct RenderedText {
|
||||
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||
pub size: Vec2,
|
||||
pub color: UiColor,
|
||||
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
|
||||
/// A holder must re-render rather than re-emit these quads once the
|
||||
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
|
||||
/// otherwise); `Painter::glyphs` debug-asserts it.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::icon;
|
||||
|
||||
/// Every codepoint `icon` names is actually in the subset the script
|
||||
/// built. This is the failure `build-icon-font.sh`'s own comment warns
|
||||
/// about -- a constant added on one side and not the other is a glyph
|
||||
/// that silently isn't there -- and it is invisible at runtime,
|
||||
/// because a missing glyph draws as nothing rather than as an error.
|
||||
#[test]
|
||||
fn every_icon_is_in_the_bundled_font() {
|
||||
let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses");
|
||||
@@ -762,8 +599,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The font registers, so `Family::Icons` resolves to a real family
|
||||
/// rather than falling through to sans-serif and drawing tofu.
|
||||
#[test]
|
||||
fn the_icon_family_registers_and_resolves() {
|
||||
let data = TextData::default();
|
||||
|
||||
@@ -22,10 +22,6 @@ pub enum TextureKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
|
||||
/// hash: `owner` names the widget kind whose description it is, and `id`
|
||||
/// packs that description's own fields, so two owners cannot collide and
|
||||
/// a debugger shows which picture a slot holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SharedTextureKey {
|
||||
pub owner: &'static str,
|
||||
@@ -46,8 +42,6 @@ pub struct TextureHandle {
|
||||
pub struct Textures {
|
||||
free: Vec<u32>,
|
||||
images: Vec<Option<DynamicImage>>,
|
||||
/// What each slot is, kept beside the image so a slot can be pushed
|
||||
/// again without the handle that knows -- see [`Textures::reupload`].
|
||||
kinds: Vec<TextureKind>,
|
||||
/// Textures built from a description rather than from a file, one per
|
||||
/// distinct description: see [`Textures::shared`]. The map holds a
|
||||
@@ -119,8 +113,6 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a page of the shared glyph atlas array. Only `atlas.rs` should
|
||||
/// call this -- everything else wants `add`.
|
||||
pub fn add_page(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
|
||||
let image = image.into();
|
||||
let size = image.dimensions().into();
|
||||
@@ -152,18 +144,6 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one texture for `key`, building it on the first ask and handing
|
||||
/// out a further reference to it every time after.
|
||||
///
|
||||
/// **Why this exists**: a texture rasterised from a *description* --
|
||||
/// `widget::mark`'s triangle, from a direction and a colour -- has as
|
||||
/// many copies as there are widgets asking for it, and each copy is
|
||||
/// its own GPU texture, its own bind group and its own draw call. A
|
||||
/// transcript screen with a folded card per tool call built one per
|
||||
/// card: hundreds of 48x48 textures of three distinct pictures,
|
||||
/// created and freed again as rows recycled. `make` is not called when
|
||||
/// the key is already known, so the rasterising is paid once too.
|
||||
///
|
||||
/// The map keeps its own reference for the life of the `Textures`, so
|
||||
/// a shared slot is never freed and never reused for something else --
|
||||
/// which is what makes a handle held by a long-lived widget safe.
|
||||
@@ -180,21 +160,16 @@ impl Textures {
|
||||
handle
|
||||
}
|
||||
|
||||
/// The stored image for a handle, to be written into before `patch`.
|
||||
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
||||
self.images[handle.slot as usize]
|
||||
.as_mut()
|
||||
.expect("texture was freed while still held")
|
||||
}
|
||||
|
||||
/// Queue an upload of just `rect`, after writing it with `image_mut`.
|
||||
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
|
||||
self.updates.push(Update::Patch(handle.slot, rect));
|
||||
}
|
||||
|
||||
/// Queue every live slot for upload again, in slot order -- what a
|
||||
/// genuinely new GPU device needs, in place of forgetting everything.
|
||||
///
|
||||
/// A new device starts with no textures, and the renderer-side mirror
|
||||
/// of these slots (`render::texture::GpuTextures`) starts empty with
|
||||
/// it. What it must not do is start empty while the handles widgets
|
||||
@@ -208,15 +183,6 @@ impl Textures {
|
||||
/// still holds the images: the slot list is rebuilt identically,
|
||||
/// including the empty slots, which go across as `PushFree` so the
|
||||
/// ones after them still land where they were.
|
||||
///
|
||||
/// The glyph atlas comes back with it and is deliberately *not*
|
||||
/// cleared any more: its pages are slots here, this side holds their
|
||||
/// pixels, and re-uploading them restores exactly the atlas that was
|
||||
/// there -- so an app switch no longer costs a re-rasterisation of
|
||||
/// every glyph on screen either.
|
||||
///
|
||||
/// Pending updates are dropped rather than kept: each is either a push
|
||||
/// or a patch of a slot this replays in full.
|
||||
pub fn reupload(&mut self) {
|
||||
self.updates.clear();
|
||||
self.updates
|
||||
@@ -275,8 +241,6 @@ impl TextureHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer this page occupies in the shared atlas array texture.
|
||||
/// Only valid for a page handle; see `image_index`'s note.
|
||||
pub fn layer(&self) -> u32 {
|
||||
match self.kind {
|
||||
TextureKind::Page { layer } => layer,
|
||||
@@ -320,9 +284,6 @@ mod tests {
|
||||
SharedTextureKey { owner: "test", id }
|
||||
}
|
||||
|
||||
/// What `widget::mark` needs: one texture per description, however
|
||||
/// many widgets ask for it, and a different description is a
|
||||
/// different texture.
|
||||
#[test]
|
||||
fn a_shared_texture_is_built_once_and_handed_out_again() {
|
||||
let mut textures = Textures::new();
|
||||
@@ -341,9 +302,6 @@ mod tests {
|
||||
assert_ne!(first.image_index(), other.image_index());
|
||||
}
|
||||
|
||||
/// The map's own reference is what keeps a shared slot alive: every
|
||||
/// widget holding one can go away and the slot must not be recycled,
|
||||
/// because the next widget to ask gets that same index back.
|
||||
#[test]
|
||||
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
|
||||
let mut textures = Textures::new();
|
||||
@@ -357,9 +315,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A new GPU device gets the same slot numbering back, so a handle a
|
||||
/// widget has been holding all along still names its own texture --
|
||||
/// the crash `reupload` replaced `reset` to fix.
|
||||
#[test]
|
||||
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
|
||||
let mut textures = Textures::new();
|
||||
@@ -373,7 +328,6 @@ mod tests {
|
||||
);
|
||||
drop(dropped);
|
||||
textures.free();
|
||||
// Drain the updates so far, the way a frame does.
|
||||
assert!(textures.updates().count() > 0);
|
||||
|
||||
textures.reupload();
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text
|
||||
//! is a quad per glyph rather than a texture per string.
|
||||
//!
|
||||
//! What this replaces is why it exists. Text used to be rasterised into its own
|
||||
//! `RgbaImage` and uploaded as a whole texture, per text widget, every time
|
||||
//! anything about it changed -- so every window resize re-rasterised and
|
||||
//! re-uploaded every visible string, which is what the TODO meant by "resizing
|
||||
//! (per frame) is really slow". Here a glyph is rasterised once for a given
|
||||
//! font, size and subpixel offset and then reused by every string that contains
|
||||
//! it, and a resize re-emits quads without touching the GPU's copy at all.
|
||||
|
||||
use crate::{
|
||||
PatchRect, TextureHandle, Textures, UiColor,
|
||||
util::{HashMap, Vec2},
|
||||
@@ -16,30 +5,16 @@ use crate::{
|
||||
use image::RgbaImage;
|
||||
use swash::scale::image::{Content, Image};
|
||||
|
||||
/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a
|
||||
/// few thousand glyphs at UI sizes, and small enough that a page nobody fills
|
||||
/// is not a big waste. Also the fixed width/height of every layer of the
|
||||
/// shared array texture in `render::texture` -- `pub(crate)` so that module
|
||||
/// can size it without a second constant to keep in sync.
|
||||
pub(crate) const PAGE: u32 = 1024;
|
||||
|
||||
/// Transparent margin kept around every glyph, so that sampling one cannot
|
||||
/// pick up its neighbour along a shared edge.
|
||||
const PAD: u32 = 1;
|
||||
|
||||
/// Identifies a rasterised glyph. Anything that changes the pixels has to be in
|
||||
/// here, or two different glyphs share one entry and the wrong one is drawn.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct GlyphKey {
|
||||
pub font: u64,
|
||||
pub glyph: u32,
|
||||
/// Font size in 1/16 px, so sizes that round to the same pixels share a
|
||||
/// raster instead of filling the atlas with near-duplicates.
|
||||
pub size: u32,
|
||||
/// Horizontal subpixel phase, in 1/4 px.
|
||||
pub subpixel: u8,
|
||||
/// Hash of the variation coordinates; a variable font at two weights is two
|
||||
/// different sets of pixels from one glyph id.
|
||||
pub coords: u64,
|
||||
}
|
||||
|
||||
@@ -47,13 +22,11 @@ pub struct GlyphKey {
|
||||
pub struct GlyphEntry {
|
||||
pub uv_min: [f32; 2],
|
||||
pub uv_max: [f32; 2],
|
||||
/// Offset from the glyph's pen position to the top-left of its pixels.
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub is_color: bool,
|
||||
/// The atlas array layer this glyph's page occupies.
|
||||
pub layer: u32,
|
||||
}
|
||||
|
||||
@@ -71,12 +44,7 @@ struct Page {
|
||||
#[derive(Default)]
|
||||
pub struct GlyphAtlas {
|
||||
pages: Vec<Page>,
|
||||
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
|
||||
/// from an earlier atlas can tell that its coordinates are stale --
|
||||
/// see that method's doc for what goes wrong without it.
|
||||
generation: u64,
|
||||
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
||||
/// too, so it is not re-rasterised on every layout.
|
||||
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
||||
}
|
||||
|
||||
@@ -138,7 +106,6 @@ impl GlyphAtlas {
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// A free `w`x`h` spot, opening a shelf or a page as needed.
|
||||
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
|
||||
let need_w = w + PAD;
|
||||
let need_h = h + PAD;
|
||||
@@ -165,14 +132,10 @@ impl GlyphAtlas {
|
||||
(self.pages.len() - 1, PAD, PAD)
|
||||
}
|
||||
|
||||
/// Record that a glyph has no pixels, so it is not re-rasterised.
|
||||
pub fn insert_empty(&mut self, key: GlyphKey) {
|
||||
self.entries.insert(key, None);
|
||||
}
|
||||
|
||||
/// Which atlas the entries handed out right now belong to. A
|
||||
/// [`crate::RenderedText`] records this when it is built and is only
|
||||
/// reusable while it still matches.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
@@ -185,30 +148,6 @@ impl GlyphAtlas {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Forget every page and every rasterised entry -- what a genuinely new
|
||||
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
|
||||
/// "not already live" branch, e.g. after backgrounding): the pages this
|
||||
/// atlas remembers are `TextureHandle`s into the *old* device's
|
||||
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
|
||||
/// `uv_max`/`layer` point into them. Without this, a glyph already
|
||||
/// cached here is treated as "already placed" and never re-inserted
|
||||
/// into the fresh (empty) atlas the new renderer actually has --
|
||||
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
|
||||
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
|
||||
/// this is its counterpart for the case where the renderer really is
|
||||
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||
/// doc for why that is harmless here.
|
||||
/// Bumping `generation` here is the other half of the same
|
||||
/// invalidation: emptying this atlas does nothing about the
|
||||
/// `RenderedText`s widgets are *already holding*
|
||||
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
|
||||
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
|
||||
/// away. Those redraw perfectly happily and sample whatever now sits at
|
||||
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
|
||||
/// after resuming the app on 2026-09-06. One counter, checked where the
|
||||
/// cache is read, is what makes a cached render un-reusable across a
|
||||
/// renderer rebuild.
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.entries.clear();
|
||||
@@ -217,15 +156,10 @@ impl GlyphAtlas {
|
||||
}
|
||||
|
||||
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
|
||||
// On the current shelf, or on a new one above it.
|
||||
(page.x + need_w <= PAGE && page.y + need_h <= PAGE)
|
||||
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE)
|
||||
}
|
||||
|
||||
/// Copy one rasterised glyph into the page image at `(x, y)`.
|
||||
///
|
||||
/// A mask glyph keeps its coverage in alpha with the colour left to the shader,
|
||||
/// so one raster serves text of any colour; a colour glyph carries its own.
|
||||
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
|
||||
let w = image.placement.width;
|
||||
let h = image.placement.height;
|
||||
@@ -253,10 +187,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
|
||||
}
|
||||
}
|
||||
Content::SubpixelMask => {
|
||||
// Not asked for: `Format::Alpha` is what the renderer requests, so
|
||||
// reaching here means the request changed and this needs writing.
|
||||
// Drawn as a plain mask from the green channel rather than dropped,
|
||||
// so the text is readable rather than absent.
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
let i = ((row * w + col) * 4) as usize;
|
||||
@@ -268,14 +198,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a glyph goes on screen, in pixels relative to the text's origin.
|
||||
///
|
||||
/// `color` is per-glyph (read from the parley run's own `Brush`, since
|
||||
/// `UiColor` is parley's brush type here) rather than a single colour for
|
||||
/// the whole `RenderedText`, so that a span pushed with its own
|
||||
/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour
|
||||
/// inside one wrapped paragraph) actually renders in that colour instead of
|
||||
/// the buffer's base one.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PlacedGlyph {
|
||||
pub entry: GlyphEntry,
|
||||
|
||||
@@ -8,15 +8,6 @@ pub struct WindowUniform {
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// One primitive's placement and what to draw there, in the one arena
|
||||
/// every layer shares (`Primitives`). Read from a storage buffer by
|
||||
/// **both** shader stages: the vertex stage for the corners of the
|
||||
/// primitive it is drawing, the fragment stage for the corners of a
|
||||
/// *mask's* primitive, which is generally a different one and often in
|
||||
/// another layer. A layer's vertex buffer carries only the slot
|
||||
/// ([`instance_slot_layout`]), so there is exactly one copy of a
|
||||
/// placement and a mask cannot disagree with what was drawn. See
|
||||
/// LAYOUT.md's "Masks with a shape".
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PrimitiveInstance {
|
||||
@@ -27,11 +18,6 @@ pub struct PrimitiveInstance {
|
||||
pub move_idx: MoveIdx,
|
||||
}
|
||||
|
||||
/// The vertex layout of a layer's draw order: one `u32` slot into the
|
||||
/// global instance arena per instance, stepped per instance. Everything a
|
||||
/// primitive is made of used to be here as eight vertex attributes; it
|
||||
/// moved into the storage buffer above so the fragment stage can read it
|
||||
/// too.
|
||||
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
|
||||
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
|
||||
VertexBufferLayout {
|
||||
@@ -49,38 +35,9 @@ impl MaskIdx {
|
||||
|
||||
pub type MoveIdx = Id<u32>;
|
||||
|
||||
/// A clip, as a reference to a primitive already written plus the mask it
|
||||
/// nests inside. The fragment stage evaluates that primitive's coverage
|
||||
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
|
||||
/// from the same SDF the rect itself is drawn with -- and multiplies it
|
||||
/// into the pixel's alpha, so a rounded container's corner and its
|
||||
/// children's clipped corner are the same arithmetic and cannot disagree.
|
||||
/// See LAYOUT.md's "Masks with a shape".
|
||||
///
|
||||
/// **No `kind` and no `flags`**, which the design sketched: the referenced
|
||||
/// instance already carries its own `binding`, and a copy of it here is a
|
||||
/// second thing to keep in step; alpha-only is the only mode there is, so
|
||||
/// there is nothing to select. Both are a field away if a second mode
|
||||
/// appears.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct Mask {
|
||||
/// The slot in `UiRenderState::primitives` of the primitive whose
|
||||
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
|
||||
/// a standalone image would need, respectively, a CPU-side alpha
|
||||
/// plane for the hit test to agree with the shader, and a bind-group
|
||||
/// switch the fragment stage cannot make -- `Painter::set_mask`
|
||||
/// rejects both by name rather than leaving the shader to read a rect
|
||||
/// that is not there.
|
||||
///
|
||||
/// Who owns it depends on which way the mask was set. A plain
|
||||
/// `.masked()` writes its own undrawn rect, so the primitive is in
|
||||
/// the masking widget's `ActiveData::primitives` and lives exactly as
|
||||
/// long as the mask. `.masked_by(shape)` points at a *child's*
|
||||
/// primitive, which that child can free on any redraw of its own --
|
||||
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
|
||||
/// redraw whenever a referenced slot is freed, since that widget's
|
||||
/// own `set_mask` is the only thing that resolves the slot again.
|
||||
pub primitive: u32,
|
||||
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
|
||||
/// clipping nests: the fragment stage walks the chain and multiplies
|
||||
@@ -90,20 +47,9 @@ pub struct Mask {
|
||||
/// fence inside a transcript row carries the row's scroll, the list's
|
||||
/// own box does not, and one region resolved when the fence was last
|
||||
/// drawn gets the second of those wrong as soon as the row moves.
|
||||
///
|
||||
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
|
||||
/// released when the child's own slot goes
|
||||
/// (`UiRenderState::remove`), so the chain cannot outlive what it
|
||||
/// points at.
|
||||
pub parent: MaskIdx,
|
||||
}
|
||||
|
||||
/// One widget's cumulative on-screen translation, and the slot of the
|
||||
/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A
|
||||
/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for
|
||||
/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are
|
||||
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
|
||||
///
|
||||
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
|
||||
/// a `vec2<f32>`, which gives the struct an 8-byte alignment and rounds its
|
||||
/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 --
|
||||
|
||||
@@ -9,58 +9,20 @@ use std::time::{Duration, Instant};
|
||||
/// "late" that merely met its own, faster budget.
|
||||
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
|
||||
|
||||
/// Enough frames for several minutes of scrolling before the oldest ones
|
||||
/// start being overwritten -- the same "diagnostic, not a log" sizing
|
||||
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
|
||||
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
|
||||
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
|
||||
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
|
||||
/// this so `phase_stats` never has to report a phase as partially evicted.
|
||||
const RING_CAPACITY: usize = 16384;
|
||||
|
||||
/// How many measurable frame-to-frame gaps [`FrameReport::
|
||||
/// sustained_frame_hz`] needs before it will answer at all. A tenth of a
|
||||
/// second's worth at any plausible rate -- enough for a rate to mean
|
||||
/// something, and little enough that any real phase has it.
|
||||
const MIN_CADENCE_SAMPLES: usize = 12;
|
||||
|
||||
/// One `mark_phase` call: the wall-clock instant and the (0-based,
|
||||
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
|
||||
/// which a phase began -- `phase_stats` slices `index_ring` against this to
|
||||
/// find which recorded samples belong to which phase, since the ring
|
||||
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
|
||||
/// not which phase they were in.
|
||||
struct PhaseMark {
|
||||
name: String,
|
||||
start_index: u64,
|
||||
start_at: Instant,
|
||||
}
|
||||
|
||||
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
|
||||
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
|
||||
/// p50/p90/p99, worst, duration". `Display` matches the shape
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
|
||||
/// the two apps' reports read the same way side by side.
|
||||
pub struct PhaseStats {
|
||||
pub name: String,
|
||||
/// How many frames were recorded during this phase in total -- may
|
||||
/// exceed `late + (samples counted)` if some of this phase's frames
|
||||
/// have since been evicted from the ring by a very long run; that
|
||||
/// case is named in the `Display` rather than silently under-counted.
|
||||
pub frames: u64,
|
||||
pub duration: Duration,
|
||||
/// Frames whose **work** exceeded the budget -- `total` minus the
|
||||
/// swapchain wait, since a frame held back by the display was ready
|
||||
/// on time and the display was not.
|
||||
///
|
||||
/// Judging the total instead is what this did until 2026-09-09, and
|
||||
/// it does not survive the app being *well* paced: a loop that draws
|
||||
/// in 0.4ms and then waits its turn measures one whole refresh period
|
||||
/// per frame, so every frame sits exactly on the budget and `late`
|
||||
/// becomes a coin toss on noise. See [`Self::missed`] for the
|
||||
/// question "did a frame fail to arrive", which is the one a reader
|
||||
/// actually sees.
|
||||
///
|
||||
/// On a backend that blocks in `present()` rather than in the
|
||||
/// acquire -- GLES, and so this repo's emulator -- the wait lands in
|
||||
/// `submit` instead and this over-counts. Named rather than
|
||||
@@ -81,25 +43,10 @@ pub struct PhaseStats {
|
||||
/// say that.
|
||||
/// Vsyncs that went by with no frame produced for them, counted from
|
||||
/// the gap between consecutive frames rather than from their cost.
|
||||
///
|
||||
/// **`late` and this are different questions and the second is the
|
||||
/// one a reader sees.** A frame can be over budget and still be shown
|
||||
/// on the next vsync; a frame that is never produced leaves the
|
||||
/// previous one on screen for two refreshes, which is the stutter.
|
||||
/// Nothing in a report could say this before 2026-09-09 -- the two
|
||||
/// were folded together under `late`, so "we drew every frame, some
|
||||
/// slowly" and "we skipped 1 frame in 8" read identically.
|
||||
///
|
||||
/// Zero on the first frame of a run, whose gap is unknowable.
|
||||
pub missed: u64,
|
||||
pub build_p50: Duration,
|
||||
pub acquire_p50: Duration,
|
||||
pub submit_p50: Duration,
|
||||
/// `false` if this phase's frame count exceeds how many samples of it
|
||||
/// are still in the ring -- the percentiles above are then computed
|
||||
/// over whatever survived, not the whole phase. UI_RULES.md: this is
|
||||
/// the "we don't fully know" state, named rather than folded silently
|
||||
/// into a number that looks exact.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
@@ -142,29 +89,10 @@ impl std::fmt::Display for PhaseStats {
|
||||
|
||||
/// The parts one frame's wall time divides into, measured rather than
|
||||
/// inferred: what a caller hands [`FrameReport::record`].
|
||||
///
|
||||
/// The three are consecutive and together they are `total`, so whatever
|
||||
/// is left after `acquire` and `submit` is the frame's own work -- laying
|
||||
/// out, shaping text, building primitives and recording the render pass.
|
||||
/// That leftover is what a report calls `build`.
|
||||
///
|
||||
/// **`acquire` is the one that is not work.** It is the wait inside
|
||||
/// `Surface::get_current_texture` for a swapchain image to come free,
|
||||
/// which is the display pacing the app: an app that draws faster than the
|
||||
/// screen refreshes spends *most* of every frame there, and that is the
|
||||
/// healthy state rather than a slow one. It was inside the CPU half until
|
||||
/// 2026-09-09, which made a fling's frames read as several milliseconds
|
||||
/// of iris being slow when they were milliseconds of iris waiting its
|
||||
/// turn -- UI_RULES.md's rule against presenting an inferred value as a
|
||||
/// measured one, arriving in a diagnostic.
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct FrameParts {
|
||||
/// Redraw start to after `present()` was called -- the span the whole
|
||||
/// report is about.
|
||||
pub total: Duration,
|
||||
/// The wait for a swapchain image (`get_current_texture`).
|
||||
pub acquire: Duration,
|
||||
/// `queue.submit` plus `present()`.
|
||||
pub submit: Duration,
|
||||
}
|
||||
|
||||
@@ -201,24 +129,11 @@ impl FrameParts {
|
||||
.saturating_sub(self.submit)
|
||||
}
|
||||
|
||||
/// Everything that was not waiting for the display's permission to
|
||||
/// draw -- `build` plus `submit`. What a frame had to finish before
|
||||
/// it could be shown, and so what a budget is meaningfully compared
|
||||
/// against; see [`PhaseStats::late`].
|
||||
pub fn work(&self) -> Duration {
|
||||
self.total.saturating_sub(self.acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
|
||||
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
|
||||
/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's
|
||||
/// ordinary Skia/HWUI View-drawing pipeline, which a `wgpu`-rendered
|
||||
/// `SurfaceView` bypasses entirely. `record` is meant to be called once per
|
||||
/// frame, wrapping the same span Compose's own render report and `gfxinfo`
|
||||
/// count -- from the frame's redraw/update start to after the frame is
|
||||
/// handed to the platform to present.
|
||||
///
|
||||
/// **What this does not measure**: wgpu's `present()` call queues the frame
|
||||
/// with the compositor and returns; it is not fenced against the GPU
|
||||
/// actually finishing the frame or the compositor actually showing it, the
|
||||
@@ -228,17 +143,8 @@ impl FrameParts {
|
||||
/// [`FrameStats`]'s own `Display` line rather than presented as the latter,
|
||||
/// per the standing rule against showing an inferred number as a measured
|
||||
/// one where the two differ.
|
||||
///
|
||||
/// Fixed-size ring, no allocation on the hot path -- `report()` is the only
|
||||
/// place that allocates (a sort over the current ring), and it is only
|
||||
/// ever called from a button tap, not once per frame.
|
||||
pub struct FrameReport {
|
||||
ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// The `submit_to_present` half of each sample in `ring`, same index,
|
||||
/// same lifetime -- kept as a second ring rather than a ring of pairs so
|
||||
/// the existing `ring`/percentile code above is untouched (RUST.md's I5
|
||||
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
|
||||
/// See [`FrameParts`] for how the three rings divide a frame up.
|
||||
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// The `acquire` half of each sample in `ring`, same index, same
|
||||
/// lifetime -- see [`FrameParts::acquire`], which is the part that is
|
||||
@@ -247,47 +153,16 @@ pub struct FrameReport {
|
||||
/// How long before each sample the *previous* frame was, same index,
|
||||
/// same lifetime -- the frame's own cadence rather than its cost. See
|
||||
/// [`PhaseStats::missed`] for why a report needs both.
|
||||
///
|
||||
/// **`Duration::ZERO` means "no cadence information", not "no gap".**
|
||||
/// Two frames say nothing about the display's rhythm unless the app
|
||||
/// was actually trying to draw between them: the first frame after a
|
||||
/// `reset` has nothing before it, and a frame that follows an *idle*
|
||||
/// one is separated by however long nobody wanted anything drawn.
|
||||
/// Counting those was this counter's first version, and it reported
|
||||
/// a bench's own deliberate pauses as stutter -- 276 "missed" frames
|
||||
/// for sixteen 300ms rests between flings, and 2410 for twelve
|
||||
/// hundred 50ms gaps between keystrokes (Iris's phone, 2026-09-09).
|
||||
gap_ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// When the last recorded frame was and whether it had asked for
|
||||
/// another -- `None` until the first frame since a `reset`. The flag
|
||||
/// is what makes the next frame's gap a measurement rather than a
|
||||
/// record of how long the app sat idle.
|
||||
last_frame: Option<(Instant, bool)>,
|
||||
/// The absolute (0-based, since the last `reset`) frame index each
|
||||
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
|
||||
/// slices against `PhaseMark::start_index` to tell which recorded
|
||||
/// frames fall in which phase.
|
||||
index_ring: Box<[u64; RING_CAPACITY]>,
|
||||
/// How many of `ring`'s slots hold a real sample -- saturates at
|
||||
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
|
||||
len: usize,
|
||||
pos: usize,
|
||||
/// All frames recorded since the last `reset`, even past `RING_CAPACITY`
|
||||
/// -- what `janky_percent` divides by, so a long run's percentage stays
|
||||
/// correct even once the ring itself only holds the most recent frames.
|
||||
total_frames: u64,
|
||||
janky_frames: u64,
|
||||
/// `mark_phase` calls since the last `reset`, oldest first -- see
|
||||
/// `phase_stats`. Empty on an ordinary run that never calls
|
||||
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
|
||||
/// prints no "per phase:" section at all, matching RUST.md's "empty/
|
||||
/// absent on an ordinary 'Copy' press, which never marks a phase."
|
||||
phases: Vec<PhaseMark>,
|
||||
}
|
||||
|
||||
/// One resolved reading. `Display` is the log line both the "Frame report"
|
||||
/// button and `transcript-bench.sh`-style scripts read, grep-able on
|
||||
/// `"iris frame report"`.
|
||||
pub struct FrameStats {
|
||||
pub total_frames: u64,
|
||||
pub janky_percent: f64,
|
||||
@@ -295,25 +170,8 @@ pub struct FrameStats {
|
||||
pub p90: Duration,
|
||||
pub p99: Duration,
|
||||
pub worst: Duration,
|
||||
/// Median of [`FrameParts::build`] -- iris's own CPU work per frame:
|
||||
/// laying out, shaping text, building primitives and recording the
|
||||
/// render pass. RUST.md's I5 "Where iris's frame time goes" split,
|
||||
/// added 2026-09-05 to answer "CPU or GPU?" with a number rather than
|
||||
/// a guess, and corrected on 2026-09-09 to stop counting the
|
||||
/// swapchain wait below as iris's own work.
|
||||
pub cpu_p50: Duration,
|
||||
/// Median of [`FrameParts::acquire`]: the wait for a swapchain image.
|
||||
/// **Not work** -- see that field's doc. A large number here beside a
|
||||
/// small `cpu_p50` is an app comfortably ahead of the display, which
|
||||
/// is what it should look like.
|
||||
pub acquire_p50: Duration,
|
||||
/// Median of `submit_to_present` -- the `queue.submit` call itself plus
|
||||
/// `present()`, i.e. wherever the driver/GPU/compositor wait actually
|
||||
/// happens. Same caveat as the type's own doc: `present()` is not
|
||||
/// fenced against the GPU actually finishing, so this is "how long the
|
||||
/// CPU was blocked handing the frame off", not the frame's true GPU
|
||||
/// time -- still enough to separate "iris is slow building the frame"
|
||||
/// from "iris is slow handing it to the driver".
|
||||
pub gpu_wait_p50: Duration,
|
||||
}
|
||||
|
||||
@@ -359,8 +217,6 @@ impl FrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame, split into [`FrameParts`]. O(1), no allocation.
|
||||
///
|
||||
/// One entry point rather than one per shape of measurement: a caller
|
||||
/// with nothing but a total passes `FrameParts::whole(total)`, which
|
||||
/// says so in the type instead of leaving the report to guess from a
|
||||
@@ -368,8 +224,6 @@ impl FrameReport {
|
||||
pub fn record(&mut self, at: Instant, parts: FrameParts, animating: bool) {
|
||||
self.gap_ring[self.pos] = match self.last_frame {
|
||||
Some((last, true)) => at.saturating_duration_since(last),
|
||||
// Nothing was moving, so the distance to this frame is idle
|
||||
// time rather than cadence -- see `gap_ring`'s own doc.
|
||||
Some((_, false)) | None => Duration::ZERO,
|
||||
};
|
||||
self.last_frame = Some((at, animating));
|
||||
@@ -385,12 +239,6 @@ impl FrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears every counter and every sample -- what the "Reset frame
|
||||
/// report" control calls, so a report covers only what was scrolled
|
||||
/// after the button was pressed (the same reason `FrameStats.kt`'s
|
||||
/// `reset()` exists on the Compose side). Also clears every phase
|
||||
/// mark, so a fresh run starts with no "per phase:" section until it
|
||||
/// marks one of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.len = 0;
|
||||
self.pos = 0;
|
||||
@@ -400,8 +248,6 @@ impl FrameReport {
|
||||
self.phases.clear();
|
||||
}
|
||||
|
||||
/// One recorded slot's three parts, back as the type they were
|
||||
/// recorded in.
|
||||
fn parts(&self, slot: usize) -> FrameParts {
|
||||
FrameParts {
|
||||
total: self.ring[slot],
|
||||
@@ -410,16 +256,7 @@ impl FrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the start of a named phase at the current moment -- every
|
||||
/// frame recorded from here until the next `mark_phase` (or `reset`)
|
||||
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
|
||||
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||
/// can slice one whole run's frames by what was happening during each.
|
||||
pub fn mark_phase(&mut self, name: &str) {
|
||||
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
|
||||
// end_index`) silently produces an empty or nonsensical slice for
|
||||
// a phase pushed out of order rather than surfacing the misuse
|
||||
// (review, 2026-09-06).
|
||||
debug_assert!(
|
||||
self.phases
|
||||
.last()
|
||||
@@ -432,12 +269,6 @@ impl FrameReport {
|
||||
});
|
||||
}
|
||||
|
||||
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
|
||||
/// oldest first. `now` closes the last phase's wall-clock span (there
|
||||
/// is no "next phase" instant to use for it); `refresh_hz` is what
|
||||
/// each phase's own `late`/`late_percent` is judged against, read from
|
||||
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
|
||||
/// count/% against the display's refresh rate."
|
||||
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
|
||||
if self.phases.is_empty() || refresh_hz <= 0.0 {
|
||||
return Vec::new();
|
||||
@@ -478,18 +309,11 @@ impl FrameReport {
|
||||
complete,
|
||||
};
|
||||
}
|
||||
// Each part gets its own sort: medians do not distribute
|
||||
// over subtraction, so `build`'s median is not `total`'s
|
||||
// minus the other two.
|
||||
let part_p50 = |part: &dyn Fn(usize) -> Duration| {
|
||||
let mut v: Vec<Duration> = slots.iter().map(|&j| part(j)).collect();
|
||||
v.sort_unstable();
|
||||
v[v.len() / 2]
|
||||
};
|
||||
// A gap of more than one and a half budgets means at
|
||||
// least one vsync came and went unanswered; the count is
|
||||
// how many, so a frame arriving three periods late says 2.
|
||||
//
|
||||
// **The phase's own first frame is skipped**: its gap
|
||||
// reaches back into the previous phase, across whatever
|
||||
// the run did between the two -- a bench pausing a second
|
||||
@@ -532,10 +356,6 @@ impl FrameReport {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rate frames were actually **sustained** at, in Hz, over the
|
||||
/// stretches where the app was animating -- measurable gaps divided
|
||||
/// into their own total, so idle time is excluded by construction.
|
||||
///
|
||||
/// **This is a floor on the display's refresh rate, never a reading
|
||||
/// of it.** You cannot observe a cadence faster than you draw, so an
|
||||
/// app that never keeps up says nothing about the panel; a caller
|
||||
@@ -571,9 +391,6 @@ impl FrameReport {
|
||||
samples.sort_unstable();
|
||||
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
|
||||
|
||||
// Separate arrays rather than subtracting the two medians above:
|
||||
// medians do not distribute over subtraction, and each needs its
|
||||
// own sort.
|
||||
let submit_samples: Vec<Duration> = self.submit_ring[..self.len].to_vec();
|
||||
let acquire_samples: Vec<Duration> = self.acquire_ring[..self.len].to_vec();
|
||||
let cpu_samples: Vec<Duration> = (0..self.len).map(|j| self.parts(j).build()).collect();
|
||||
@@ -595,24 +412,11 @@ impl FrameReport {
|
||||
})
|
||||
}
|
||||
|
||||
/// `(late count, late percent)` over every sample still in the ring,
|
||||
/// judged against `refresh_hz`'s own frame budget rather than the
|
||||
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
|
||||
/// count/% against the display's refresh rate... print 'at N Hz (X ms
|
||||
/// budget)' like Compose does." A separate method from `report()`
|
||||
/// rather than a parameter on it, so `report()`'s own `janky_percent`
|
||||
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
|
||||
/// unaffected for every existing caller that never measured a real
|
||||
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
|
||||
/// `refresh_hz`.
|
||||
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
|
||||
if self.len == 0 || refresh_hz <= 0.0 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||
// The frame's work, not its total -- the same rule and the same
|
||||
// reason as `PhaseStats::late`, which this is the run-wide half
|
||||
// of.
|
||||
let late = (0..self.len)
|
||||
.filter(|&j| self.parts(j).work() > budget)
|
||||
.count() as u64;
|
||||
@@ -654,8 +458,6 @@ mod tests {
|
||||
#[test]
|
||||
fn percentiles_and_worst_over_a_known_set() {
|
||||
let mut r = FrameReport::new();
|
||||
// 100 samples, 1ms..=100ms, fed out of order so the ring's own
|
||||
// order is not what gives the right answer -- the sort has to.
|
||||
for ms in (1..=100).rev() {
|
||||
r.record(
|
||||
Instant::now(),
|
||||
@@ -690,8 +492,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn janky_percent_is_over_all_time_frames_not_just_the_ring() {
|
||||
// Fewer than RING_CAPACITY frames, all janky, then a fresh reset --
|
||||
// the percentage must reset to 0, not divide by a stale count.
|
||||
let mut r = FrameReport::new();
|
||||
for _ in 0..10 {
|
||||
r.record(
|
||||
@@ -713,9 +513,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_without_a_split_reports_the_whole_frame_as_cpu() {
|
||||
// A caller that never measured the split (plain `record`) should
|
||||
// not fabricate a GPU-wait number -- it reads as zero, and the CPU
|
||||
// half reads as the whole frame.
|
||||
let mut r = FrameReport::new();
|
||||
r.record(
|
||||
Instant::now(),
|
||||
@@ -730,10 +527,6 @@ mod tests {
|
||||
#[test]
|
||||
fn each_part_reports_its_own_median_and_build_excludes_the_wait() {
|
||||
let mut r = FrameReport::new();
|
||||
// Three frames of the same 30ms total, with the split moving:
|
||||
// each part needs its own sort, and `build` is what is left after
|
||||
// both waits -- not the total, which is the bug this replaced
|
||||
// (the swapchain wait used to be counted as iris's own work).
|
||||
for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] {
|
||||
r.record(
|
||||
Instant::now(),
|
||||
@@ -749,21 +542,15 @@ mod tests {
|
||||
assert_eq!(stats.p50, Duration::from_millis(30));
|
||||
assert_eq!(stats.acquire_p50, Duration::from_millis(10));
|
||||
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(3));
|
||||
// 30-5-2=23, 30-10-3=17, 30-20-4=6 -> median 17.
|
||||
assert_eq!(stats.cpu_p50, Duration::from_millis(17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() {
|
||||
// Cost and cadence are separate questions: every frame here is
|
||||
// well inside its budget, so `late` is zero, and the run still
|
||||
// skipped three vsyncs -- which is what a reader sees as a
|
||||
// stutter and what nothing in a report could say before.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let budget = Duration::from_nanos(16_666_667);
|
||||
r.mark_phase("fling");
|
||||
// Frames at 0, 1, 2, 4 (one skipped), 5, 8 (two skipped) budgets.
|
||||
for step in [0u32, 1, 2, 4, 5, 8] {
|
||||
r.record(
|
||||
base + budget * step,
|
||||
@@ -778,18 +565,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_idle_gap_is_not_a_missed_frame() {
|
||||
// What the first version of this counter got wrong on Iris's
|
||||
// phone: a bench rests 300ms between flings and types one
|
||||
// character per 50ms, and every one of those gaps was reported as
|
||||
// stutter (276 and 2410 "missed" frames, which is exactly the
|
||||
// rests). A frame that did not ask for another one is idle, and
|
||||
// the distance to whatever comes next says nothing.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let budget = Duration::from_nanos(16_666_667);
|
||||
r.mark_phase("fling");
|
||||
// Two frames of real animation, then one that stops animating,
|
||||
// then a long rest before the next burst.
|
||||
r.record(base, FrameParts::whole(Duration::ZERO), true);
|
||||
r.record(base + budget, FrameParts::whole(Duration::ZERO), true);
|
||||
r.record(base + budget * 2, FrameParts::whole(Duration::ZERO), false);
|
||||
@@ -807,9 +586,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_sustained_120hz_run_measures_120_whatever_the_platform_says() {
|
||||
// Iris's phone, 2026-09-09: the display reported 60Hz for a run
|
||||
// that drew at 120, so every phase was judged against twice the
|
||||
// budget it should have been.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let period = Duration::from_nanos(8_333_333);
|
||||
@@ -826,19 +602,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_app_that_cannot_keep_up_does_not_claim_a_faster_display() {
|
||||
// The other direction, and the one the first version of this got
|
||||
// wrong: this repo's emulator draws about 51fps on a 60Hz
|
||||
// display, and taking the fastest tenth of the gaps reported
|
||||
// 88Hz -- a budget no frame there could meet, invented out of the
|
||||
// app's best moments. A sustained rate cannot do that, which is
|
||||
// what makes a caller's `max` against the platform's own answer
|
||||
// safe in both directions.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let mut at = base;
|
||||
for step in 0..120u32 {
|
||||
// Mostly slow with an occasional quick pair -- the shape that
|
||||
// fooled the percentile.
|
||||
at += if step % 10 == 0 {
|
||||
Duration::from_millis(8)
|
||||
} else {
|
||||
@@ -855,10 +622,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_frame_held_back_by_the_display_is_not_late() {
|
||||
// The signature of a well-paced loop: 0.4ms of work and the rest
|
||||
// of the refresh period spent waiting its turn. Judging the total
|
||||
// calls every one of those frames late; judging the work calls
|
||||
// none of them late, which is what they are.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let period = Duration::from_nanos(8_333_333);
|
||||
@@ -881,10 +644,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_phase_does_not_inherit_the_pause_before_it() {
|
||||
// The half the fix above had no reason to touch: a bench rests
|
||||
// between phases, and that rest reaches the next phase's first
|
||||
// frame as its gap. Charging it there would open every phase with
|
||||
// a large invented `missed`.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let budget = Duration::from_nanos(16_666_667);
|
||||
@@ -896,7 +655,6 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// A second of rest, then the next phase starts clean.
|
||||
let after = base + Duration::from_secs(1);
|
||||
r.mark_phase("type");
|
||||
for step in [0u32, 1, 2] {
|
||||
@@ -925,11 +683,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let stats = r.report().unwrap();
|
||||
// total_frames keeps the full count even once the ring has wrapped.
|
||||
assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64);
|
||||
// but every sample the ring can report on is still one of the five
|
||||
// values fed in, since a wrap can only overwrite with more of the
|
||||
// same pattern here.
|
||||
assert!(stats.worst <= Duration::from_millis(5));
|
||||
}
|
||||
|
||||
@@ -1011,7 +765,6 @@ mod tests {
|
||||
#[test]
|
||||
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
|
||||
let mut r = FrameReport::new();
|
||||
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
|
||||
r.record(
|
||||
Instant::now(),
|
||||
FrameParts::whole(Duration::from_millis(10)),
|
||||
|
||||
@@ -28,42 +28,8 @@ pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD};
|
||||
pub use primitive::*;
|
||||
pub use sdf::{distance_from_rect, rounded_rect_coverage};
|
||||
|
||||
/// The one shader every primitive is drawn with. Public so a test can run
|
||||
/// a function out of it against the CPU transliteration in [`sdf`] --
|
||||
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
|
||||
/// on: a masked corner that cannot be tapped and a masked corner that is
|
||||
/// not drawn are only the same corner while the two agree.
|
||||
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
||||
|
||||
/// The `wgpu::Limits` both platform backends (`android::render::
|
||||
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
|
||||
/// `Adapter::request_device` for -- shared so the two copies cannot drift,
|
||||
/// per AGENTS.md's "write the logic once."
|
||||
///
|
||||
/// Built from `Limits::default()`, **not** a downlevel variant: the shader
|
||||
/// (`shader.wgsl`) reads four `var<storage>` buffers (rects, glyphs, masks,
|
||||
/// move_offsets) from the vertex stage, and `Limits::downlevel_webgl2_defaults()`
|
||||
/// zeroes `max_storage_buffers_per_shader_stage` along with the compute
|
||||
/// limits below -- switching to it would trade one `request_device` crash
|
||||
/// for a bind-group-layout one on the same downlevel hardware this is meant
|
||||
/// to support. `max_buffer_size` is raised for the growing instance/atlas
|
||||
/// buffers (`ArrBuf`, `GpuTextures`); everything else is `default()`'s
|
||||
/// desktop-tier value, unchanged.
|
||||
///
|
||||
/// The six `max_compute_*` fields are zeroed because nothing in this crate
|
||||
/// creates a `ComputePipeline` or writes a `@compute` shader stage --
|
||||
/// grepped for both across `iris`/`iris-core` before writing this, found
|
||||
/// none. `Limits::default()` requests desktop-tier compute limits
|
||||
/// unconditionally (`max_compute_workgroups_per_dimension: 65535`) even
|
||||
/// though nothing asks a device to actually support compute, which is what
|
||||
/// crashed `request_device` on the Android emulator's software GL path
|
||||
/// (`EMU_GPU=software`, `force-gles`): SwiftShader's GL reports itself as
|
||||
/// OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit
|
||||
/// is 0 and the unconditional request fails outright
|
||||
/// (`RUST.md`'s "Software mode ... crashes for a third, different reason").
|
||||
/// The same would happen on a real GLES-3.0-only Android device. If a
|
||||
/// future change adds a compute pass, request the specific limits it needs
|
||||
/// here rather than reverting to the desktop-tier default for everything.
|
||||
pub fn device_limits() -> Limits {
|
||||
Limits {
|
||||
max_buffer_size: 1 << 30,
|
||||
@@ -77,31 +43,11 @@ pub fn device_limits() -> Limits {
|
||||
}
|
||||
}
|
||||
|
||||
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
|
||||
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
|
||||
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
|
||||
/// creation: a validation failure during an ordinary frame's `update`/
|
||||
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
|
||||
/// no caller able to intervene -- exactly what aborted the P0 bench APK
|
||||
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
|
||||
/// platform backends install a handler here instead of leaving the default
|
||||
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
|
||||
/// must never panic in release").
|
||||
///
|
||||
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
|
||||
/// process-wide static, so a caller builds one alongside its `Device`,
|
||||
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
|
||||
/// for the Diagnostics page to read -- context passed explicitly, per
|
||||
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
|
||||
/// `OnceLock`.
|
||||
#[derive(Clone)]
|
||||
pub struct WgpuErrorLog {
|
||||
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
|
||||
}
|
||||
|
||||
/// How many uncaptured errors the log keeps -- old ones drop off the front
|
||||
/// rather than being trimmed on read, so a build spraying errors every
|
||||
/// frame doesn't grow this without bound.
|
||||
const WGPU_ERROR_LOG_CAP: usize = 20;
|
||||
|
||||
impl Default for WgpuErrorLog {
|
||||
@@ -131,9 +77,6 @@ impl WgpuErrorLog {
|
||||
pub struct UiRenderNode {
|
||||
uniform_group: BindGroup,
|
||||
primitive_layout: BindGroupLayout,
|
||||
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
|
||||
/// not per layer -- a mask referencing a rect drawn in another layer
|
||||
/// has to be able to read it (see `Primitives`).
|
||||
primitives: PrimitiveBuffers,
|
||||
primitive_group: BindGroup,
|
||||
rsc_layout: BindGroupLayout,
|
||||
@@ -145,28 +88,13 @@ pub struct UiRenderNode {
|
||||
active: Vec<usize>,
|
||||
window_buffer: Buffer,
|
||||
textures: GpuTextures,
|
||||
/// Every primitive's placement, read by the vertex stage for the
|
||||
/// primitive being drawn and by the fragment stage for a mask's.
|
||||
instances: ArrBuf<PrimitiveInstance>,
|
||||
masks: ArrBuf<Mask>,
|
||||
move_offsets: ArrBuf<MoveOffset>,
|
||||
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
||||
/// see IRIS_TODO.md's "Appending one image ... rebuilds every other
|
||||
/// image's bind group". These used to live in group 2 alongside each
|
||||
/// standalone image's own texture view, so an image's bind group named
|
||||
/// the masks/move_offsets buffer directly; the moment either buffer
|
||||
/// resized (which a widget getting its *first* move slot can trigger,
|
||||
/// unrelated to any image), `ArrBuf::update` handed back a new `Buffer`
|
||||
/// identity and every image's bind group -- one per live image -- had
|
||||
/// to be rebuilt to reference it. Pulling both buffers into their own
|
||||
/// group, bound once per frame rather than once per draw call, means a
|
||||
/// buffer resize now rebuilds exactly this one group instead of N.
|
||||
masks_layout: BindGroupLayout,
|
||||
masks_group: BindGroup,
|
||||
}
|
||||
|
||||
/// One layer's vertex buffers: the slots it draws, in order. The
|
||||
/// primitives themselves are in `UiRenderNode::instances`.
|
||||
struct RenderLayer {
|
||||
order: ArrBuf<u32>,
|
||||
/// A standalone image's slots, kept apart from `order` because each
|
||||
@@ -182,14 +110,7 @@ impl UiRenderNode {
|
||||
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_group, &[]);
|
||||
// Group 1 is global now, so it is set here rather than per layer.
|
||||
pass.set_bind_group(1, &self.primitive_group, &[]);
|
||||
// Set once, not per layer or per image: masks/move_offsets are read
|
||||
// by every primitive and every standalone image alike, and living
|
||||
// in their own group (rather than folded into group 2 alongside the
|
||||
// per-image texture view) is what keeps an image's own bind group
|
||||
// from naming a buffer that changes size on an unrelated widget's
|
||||
// first draw -- see the comment on `masks_group` below.
|
||||
pass.set_bind_group(3, &self.masks_group, &[]);
|
||||
for i in &self.active {
|
||||
let layer = &self.layers[i];
|
||||
@@ -307,31 +228,12 @@ impl UiRenderNode {
|
||||
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
|
||||
}
|
||||
|
||||
/// Builds every bind group layout, the pipeline, and the two storage
|
||||
/// buffers this needs -- fallibly, since this is exactly the call that
|
||||
/// aborted the process on Iris's phone in a release build with no
|
||||
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
|
||||
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
|
||||
/// behaviour for an uncaptured error is `panic!` with no caller able to
|
||||
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
|
||||
/// call below runs inside three nested error scopes (one per
|
||||
/// `ErrorFilter`) instead: whichever scope catches something, its
|
||||
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
|
||||
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
|
||||
/// would have printed before Android's crash reporter truncated it) and
|
||||
/// becomes this function's `Err`. Both callers
|
||||
/// (`android::render::AndroidRenderer::new`, `default::render::
|
||||
/// UiRenderer::new`) already call `Device`-creation with
|
||||
/// `pollster::block_on`, so returning a plain `Result` here rather than
|
||||
/// making this `async fn` keeps that same synchronous shape.
|
||||
pub fn new(
|
||||
device: &Device,
|
||||
queue: &Queue,
|
||||
config: &SurfaceConfiguration,
|
||||
window_size: impl Into<Vec2>,
|
||||
) -> Result<Self, String> {
|
||||
// Popped in reverse of this order, once every creation call below
|
||||
// has run -- `Device::push_error_scope`'s own contract.
|
||||
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
|
||||
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
|
||||
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
|
||||
@@ -341,29 +243,6 @@ impl UiRenderNode {
|
||||
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
||||
});
|
||||
|
||||
// Seeded from the caller's own reported size, not
|
||||
// `WindowUniform::default()` (0, 0): the vertex shader divides by
|
||||
// `window.dim` to reach clip space, so a window this buffer
|
||||
// disagrees with means every primitive's position is NaN/Inf and is
|
||||
// dropped before rasterization -- the clear colour still reaches
|
||||
// the screen (the pass runs regardless) while nothing drawn on top
|
||||
// of it ever does. winit's backend gets away with the old default
|
||||
// because winit fires an initial `WindowEvent::Resized` that calls
|
||||
// `resize()` before the first frame; android-view has no such
|
||||
// automatic event, so `AndroidRenderer::new` built a node whose
|
||||
// window buffer was never corrected -- this is I2's "nothing draws"
|
||||
// bug (RUST.md).
|
||||
//
|
||||
// **Deliberately not `config.width`/`config.height`**: those are
|
||||
// the surface's *physical* pixel size, which the swapchain needs,
|
||||
// but everything downstream of this uniform (layout, hit-testing,
|
||||
// glyph/rect positions) works in the caller's own units -- on
|
||||
// Android that's *logical* (physical / density) since RUST.md's P0
|
||||
// box ("text is far too small"), on desktop it's whatever
|
||||
// `default::render::UiRenderer::new` already divides by
|
||||
// `window.scale_factor()`. Passing it in explicitly, rather than
|
||||
// deriving it from `config` here, is what keeps this crate from
|
||||
// needing to know either platform's notion of density at all.
|
||||
let window_uniform = {
|
||||
let size = window_size.into();
|
||||
WindowUniform {
|
||||
@@ -610,12 +489,6 @@ impl UiRenderNode {
|
||||
})
|
||||
}
|
||||
|
||||
/// Group 3: the masks and move_offsets storage buffers, shared by the
|
||||
/// main draw and every standalone image alike (see the field comment on
|
||||
/// `masks_group`). Bound once per frame in `draw()` rather than folded
|
||||
/// into group 2, so a resize of either buffer -- which an unrelated
|
||||
/// widget's first move slot can trigger -- rebuilds this one group
|
||||
/// instead of every image's.
|
||||
fn masks_layout(device: &Device) -> BindGroupLayout {
|
||||
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
||||
entries: &[
|
||||
@@ -685,20 +558,10 @@ impl UiRenderNode {
|
||||
self.textures.view_count()
|
||||
}
|
||||
|
||||
/// Standalone-image bind groups built since the last call -- see
|
||||
/// `GpuTextures::take_bind_group_creates`. Call once per frame before
|
||||
/// `update()` to measure exactly that frame.
|
||||
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
||||
self.textures.take_bind_group_creates()
|
||||
}
|
||||
|
||||
/// Atlas-array `grow_array` calls since the last call -- same calling
|
||||
/// convention as `take_image_bind_group_creates` (call once per frame,
|
||||
/// before `update()`, to read exactly the previous frame's tally). Part
|
||||
/// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
|
||||
/// first input frame" investigation): if a report ever shows a grow
|
||||
/// landing on the same frame the glyphs vanished, that is the
|
||||
/// coincidence to chase first.
|
||||
pub fn take_atlas_pages_grown(&mut self) -> u64 {
|
||||
self.textures.take_pages_grown()
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ pub const IMAGE_BINDING: u32 = 1;
|
||||
pub trait Primitive: Pod {
|
||||
const BINDING: u32;
|
||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
||||
/// The read-only half of [`Self::vec`], for a caller that wants to
|
||||
/// look one entry up rather than write one -- a mask reading the
|
||||
/// radius of the rect it clips to ([`Primitives::data`]).
|
||||
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
|
||||
}
|
||||
|
||||
@@ -63,13 +60,6 @@ macro_rules! primitives {
|
||||
|
||||
impl PrimitiveBuffers {
|
||||
pub const LEN: usize = primitives!(@count $($name)*);
|
||||
/// The group-1 binding number each primitive's storage buffer
|
||||
/// sits at, in declaration order. Not `0..LEN`: a primitive's
|
||||
/// `BINDING` also tags its instances for the shader's dispatch
|
||||
/// switch, and a removed primitive (as `TEXTURE` was, once
|
||||
/// images stopped needing a per-instance storage entry) can
|
||||
/// leave a gap, so the pipeline layout has to ask for these
|
||||
/// exact numbers rather than assuming they are contiguous.
|
||||
pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*];
|
||||
pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] {
|
||||
[
|
||||
@@ -126,59 +116,14 @@ macro_rules! primitives {
|
||||
(@count $t:tt) => { 1 };
|
||||
}
|
||||
|
||||
/// Every primitive instance in the tree, in one arena that all layers
|
||||
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
|
||||
///
|
||||
/// **Why one arena rather than one per layer**, which is what this was:
|
||||
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
|
||||
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
|
||||
/// routinely in a different layer from the content it clips -- a rounded
|
||||
/// container in one layer, a `Stack`'s child content in the layer below.
|
||||
/// A per-layer buffer cannot answer that lookup at all: only one layer's
|
||||
/// group is bound at a time, so the mask would silently read another
|
||||
/// layer's rect. Both buffers are therefore global and bound once per
|
||||
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
|
||||
///
|
||||
/// Slots are stable for a primitive's whole life: nothing here is
|
||||
/// compacted, so a `Mask` can hold a slot across frames.
|
||||
pub struct Primitives {
|
||||
instances: Vec<PrimitiveInstance>,
|
||||
/// The value a slot held before its first rewrite since the last
|
||||
/// upload. Layout may place a widget provisionally and restore it in
|
||||
/// the same frame; remembering the pre-frame value lets `set_instance`
|
||||
/// clear that dirty bit instead of uploading a change the GPU never
|
||||
/// needs to observe. Entries are overwritten on the next clean-to-dirty
|
||||
/// transition, so no separate end-of-frame sweep is needed.
|
||||
original_instances: Vec<Option<PrimitiveInstance>>,
|
||||
assoc: Vec<WidgetId>,
|
||||
/// Where each slot's [`PrimitiveHandle`] sits in its owner's
|
||||
/// `ActiveData::primitives` -- the index that makes
|
||||
/// `UiRenderState::apply_free` O(1) per renumbered primitive instead
|
||||
/// of a scan of everything the owner drew. Written by
|
||||
/// [`Self::set_handle_index`] from the one place a handle is taken
|
||||
/// into that vec (`Painter::own`), and dead alongside its `assoc`
|
||||
/// entry, which is what keeps the two in step.
|
||||
///
|
||||
/// Without it a text widget that is freed and redrawn in one frame
|
||||
/// costs O(glyphs^2): every one of its glyphs is renumbered, and each
|
||||
/// renumbering scanned all of them. Measured 2026-09-08 at 1.37s for a
|
||||
/// 51,200-glyph block on this machine, against 20ms for the shaping
|
||||
/// and rasterising of the same text.
|
||||
handle_idx: Vec<u32>,
|
||||
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
|
||||
/// reusable yet: the layer that drew one still names it in its draw
|
||||
/// order until that call compacts the order, so handing it out again
|
||||
/// first would draw the new primitive twice -- once through the stale
|
||||
/// order entry and once through the new one.
|
||||
freed: Vec<usize>,
|
||||
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
|
||||
/// hands out.
|
||||
reusable: Vec<usize>,
|
||||
data: PrimitiveData,
|
||||
/// Which instance slots have changed since the last upload. Was a
|
||||
/// single `bool` covering the instances **and** the per-primitive
|
||||
/// data until 2026-09-09, so rewriting one rect's region re-uploaded
|
||||
/// every glyph as well; each array carries its own now.
|
||||
pub dirty: Dirty,
|
||||
}
|
||||
|
||||
@@ -198,9 +143,6 @@ impl Default for Primitives {
|
||||
}
|
||||
|
||||
impl Primitives {
|
||||
/// A slot whose handle has not been recorded yet -- see
|
||||
/// [`Self::handle_idx`]. No owner draws four billion primitives, so
|
||||
/// the sentinel cannot collide with a real index.
|
||||
const NO_HANDLE: u32 = u32::MAX;
|
||||
|
||||
/// Writes a primitive into the arena and hands back its slot and its
|
||||
@@ -232,10 +174,6 @@ impl Primitives {
|
||||
(slot, data_idx)
|
||||
}
|
||||
|
||||
/// A standalone image, which has no `PrimitiveData` entry to allocate
|
||||
/// -- its bind group already picks the texture, so `texture_idx` rides
|
||||
/// in the otherwise-unused `idx` field and names the bind group the
|
||||
/// draw call selects.
|
||||
pub fn alloc_image(
|
||||
&mut self,
|
||||
id: WidgetId,
|
||||
@@ -312,9 +250,6 @@ impl Primitives {
|
||||
);
|
||||
}
|
||||
|
||||
/// The image half of [`Self::recycle`] -- no `PrimitiveData` entry, so
|
||||
/// `texture_idx` rides in `idx` exactly as [`Self::alloc_image`] puts
|
||||
/// it there.
|
||||
pub fn recycle_image(
|
||||
&mut self,
|
||||
h: &PrimitiveHandle,
|
||||
@@ -378,23 +313,14 @@ impl Primitives {
|
||||
self.instances[slot].mask_idx
|
||||
}
|
||||
|
||||
/// Hands this frame's freed slots back for reuse. Called once per
|
||||
/// frame from `UiRenderState::update`, **after** every layer has
|
||||
/// compacted its draw order, since that order is the only thing still
|
||||
/// naming them.
|
||||
pub fn release_freed(&mut self) {
|
||||
self.reusable.append(&mut self.freed);
|
||||
}
|
||||
|
||||
/// Which widget drew the primitive in `slot` -- how a draw-order
|
||||
/// change finds the handle it has to renumber.
|
||||
pub fn owner(&self, slot: u32) -> WidgetId {
|
||||
self.assoc[slot as usize]
|
||||
}
|
||||
|
||||
/// Records that `slot`'s handle is `idx` entries into its owner's
|
||||
/// `ActiveData::primitives`. Called once per primitive, by the one
|
||||
/// place that puts a handle into that vec.
|
||||
pub fn set_handle_index(&mut self, slot: u32, idx: u32) {
|
||||
self.handle_idx[slot as usize] = idx;
|
||||
}
|
||||
@@ -420,18 +346,10 @@ impl Primitives {
|
||||
self.data.clear();
|
||||
}
|
||||
|
||||
/// How many instances are still live -- the O(1) half of the orphan
|
||||
/// check, so the O(primitives) walk below only runs on a frame that
|
||||
/// already looks wrong. See
|
||||
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||
pub fn live_count(&self) -> usize {
|
||||
self.instances.len() - self.freed.len() - self.reusable.len()
|
||||
}
|
||||
|
||||
/// Every live instance as `(slot, owner, is_image)` -- everything
|
||||
/// except the freed and the reusable. Only
|
||||
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
|
||||
/// that every live primitive still belongs to a live widget.
|
||||
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
|
||||
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
|
||||
(0..self.instances.len())
|
||||
@@ -453,8 +371,6 @@ impl Primitives {
|
||||
&self.instances
|
||||
}
|
||||
|
||||
/// The instance arena and its dirty set together -- see
|
||||
/// [`PrimitiveVec::for_upload`].
|
||||
pub fn instances_for_upload(&mut self) -> (&[PrimitiveInstance], &mut Dirty) {
|
||||
(&self.instances, &mut self.dirty)
|
||||
}
|
||||
@@ -465,8 +381,6 @@ impl Primitives {
|
||||
&mut self.data
|
||||
}
|
||||
|
||||
/// Whether anything at all needs uploading -- the instances or any of
|
||||
/// the per-primitive arrays.
|
||||
pub fn needs_upload(&self) -> bool {
|
||||
!self.dirty.is_clean() || self.data.needs_upload()
|
||||
}
|
||||
@@ -496,9 +410,6 @@ impl Primitives {
|
||||
}
|
||||
}
|
||||
|
||||
/// One layer's draw order: the slots of the global arena it draws, in the
|
||||
/// order they were written. The vertex buffer of a layer is exactly this.
|
||||
///
|
||||
/// Both lists free with `swap_remove`, so a layer's draw order was already
|
||||
/// undefined before this split: nothing here may assume one primitive
|
||||
/// stays adjacent to another once anything in the layer has been freed.
|
||||
@@ -529,9 +440,6 @@ impl LayerOrder {
|
||||
list.len() - 1
|
||||
}
|
||||
|
||||
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
|
||||
/// the arena's own, so that a position is only renumbered once per
|
||||
/// frame however many were dropped.
|
||||
pub fn free(&mut self, pos: usize, is_image: bool) {
|
||||
self.updated = true;
|
||||
if is_image {
|
||||
@@ -541,8 +449,6 @@ impl LayerOrder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compacts both lists, answering every primitive whose position
|
||||
/// moved so its handle can be corrected.
|
||||
pub fn apply_free(&mut self) -> Vec<OrderChange> {
|
||||
let mut changes = Self::apply_free_list(
|
||||
&mut self.free,
|
||||
@@ -559,8 +465,6 @@ impl LayerOrder {
|
||||
changes
|
||||
}
|
||||
|
||||
/// The draw order and its dirty set together -- see
|
||||
/// [`PrimitiveVec::for_upload`].
|
||||
pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) {
|
||||
(&self.order, &mut self.order_dirty)
|
||||
}
|
||||
@@ -575,8 +479,6 @@ impl LayerOrder {
|
||||
dirty: &mut Dirty,
|
||||
is_image: bool,
|
||||
) -> Vec<OrderChange> {
|
||||
// Descending, so removing a contiguous tail costs no renumbering
|
||||
// at all -- which is what freeing one widget's primitives is.
|
||||
free.sort_by(|a, b| b.cmp(a));
|
||||
free.drain(..)
|
||||
.filter_map(|pos| {
|
||||
@@ -584,9 +486,6 @@ impl LayerOrder {
|
||||
if pos == list.len() {
|
||||
return None;
|
||||
}
|
||||
// `swap_remove` moved the tail entry here; nothing else in
|
||||
// the list changed, which is why compacting an order is
|
||||
// two dirty entries rather than the whole buffer.
|
||||
dirty.mark(pos);
|
||||
Some(OrderChange {
|
||||
slot: list[pos],
|
||||
@@ -606,14 +505,8 @@ impl LayerOrder {
|
||||
}
|
||||
}
|
||||
|
||||
/// A primitive whose position in a layer's draw order moved when
|
||||
/// something before it was freed -- `slot` names which primitive, so its
|
||||
/// owner's handle can be found and pointed at `pos`.
|
||||
pub struct OrderChange {
|
||||
pub slot: u32,
|
||||
/// Which of the layer's two lists moved: their positions are
|
||||
/// independent index spaces, so a handle matching on position alone
|
||||
/// could take an image's renumbering for a rect's.
|
||||
pub is_image: bool,
|
||||
pub pos: usize,
|
||||
}
|
||||
@@ -628,13 +521,8 @@ pub enum Drawn {
|
||||
No,
|
||||
}
|
||||
|
||||
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
|
||||
/// there is no position to renumber or free.
|
||||
pub const NOT_DRAWN: usize = usize::MAX;
|
||||
|
||||
/// Where one primitive lives: its stable slot in the global arena, and
|
||||
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
|
||||
/// only referenced).
|
||||
#[derive(Debug)]
|
||||
pub struct PrimitiveHandle {
|
||||
pub layer: usize,
|
||||
@@ -683,11 +571,6 @@ impl RectPrimitive {
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph, drawn as a sub-rectangle of the glyph atlas array.
|
||||
///
|
||||
/// `color` is the text colour and is multiplied by the atlas's alpha for an
|
||||
/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and
|
||||
/// takes the atlas texel unchanged, which is what `IS_COLOR` selects.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct GlyphPrimitive {
|
||||
@@ -699,10 +582,6 @@ pub struct GlyphPrimitive {
|
||||
pub layer: u32,
|
||||
pub color: Color<u8>,
|
||||
pub flags: u32,
|
||||
/// Pads this struct's Rust size to match WGSL's storage-buffer layout for
|
||||
/// `GlyphInfo`: two `vec2<f32>` members give the struct an 8-byte
|
||||
/// alignment, which rounds the WGSL size up to 32 bytes even though the
|
||||
/// fields above only total 28. `bytemuck` does not check this for us.
|
||||
_pad: u32,
|
||||
}
|
||||
|
||||
@@ -759,20 +638,6 @@ impl<T> PrimitiveVec<T> {
|
||||
self.dirty.mark(i);
|
||||
i
|
||||
}
|
||||
/// Overwrites an entry already allocated -- the recycle path
|
||||
/// ([`Primitives::recycle`]) -- and marks it dirty **only if the
|
||||
/// value actually differs**.
|
||||
///
|
||||
/// That check is not an optimisation of the comparison; it is what
|
||||
/// makes the dirty set mean "changed" rather than "written". A row
|
||||
/// that moves, or is re-laid-out at a new width, rewrites every glyph
|
||||
/// it owns with the same `uv`, `layer`, `colour` and `flags` -- what
|
||||
/// moved is the *instance's* region, which is a different array. Over
|
||||
/// the bench fixture's streamed reply the glyph array was being
|
||||
/// marked at 73% per frame against 0.6% genuinely changed, a 122x
|
||||
/// over-upload, entirely from this (`scripts/rigs/ui-profile`'s
|
||||
/// `arena_churn`, which prints both numbers side by side so the gap
|
||||
/// cannot reopen unnoticed).
|
||||
pub fn set(&mut self, i: usize, t: T)
|
||||
where
|
||||
T: Pod,
|
||||
@@ -845,8 +710,6 @@ mod tests {
|
||||
"the GPU never observes the provisional position"
|
||||
);
|
||||
|
||||
// A subsequent frame takes its baseline from the value currently in
|
||||
// the arena, rather than reusing the now-stale original above.
|
||||
primitives.set_instance(0, moved, owner);
|
||||
assert!(!primitives.dirty.is_clean());
|
||||
primitives.dirty.clear();
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
//! The rounded-rect coverage function, on the CPU.
|
||||
//!
|
||||
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
|
||||
//! transliteration of these two, line for line, and
|
||||
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
|
||||
//! at a grid of points against values the shader itself produced. They are
|
||||
//! kept together here, in the crate both a renderer and a hit test can
|
||||
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
|
||||
//! agreeing: a masked corner that cannot be tapped and a masked corner
|
||||
//! that is not drawn have to be the same corner, and they are only the
|
||||
//! same corner while one function decides both.
|
||||
//!
|
||||
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
|
||||
//! units, which the shader has already resolved by the time it evaluates
|
||||
//! this.
|
||||
|
||||
use crate::util::Vec2;
|
||||
|
||||
/// The signed distance from `pos` to a rounded rect given by its centre,
|
||||
/// its corner offset (half its size) and its corner `radius`. Negative
|
||||
/// inside.
|
||||
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
|
||||
// vec from center to pixel
|
||||
let p = pos - center;
|
||||
// vec from inner rect corner to pixel
|
||||
let q = Vec2::new(
|
||||
p.x.abs() - (corner.x - radius),
|
||||
p.y.abs() - (corner.y - radius),
|
||||
@@ -31,12 +10,6 @@ pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) ->
|
||||
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
|
||||
}
|
||||
|
||||
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
|
||||
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
|
||||
///
|
||||
/// The half-pixel feather is why a hit test asks for **more than a half**
|
||||
/// rather than "any coverage at all": half is where the geometric edge is,
|
||||
/// so the two answer the same question the drawn shape does.
|
||||
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
|
||||
let edge: f32 = 0.5;
|
||||
let corner = (bot_right - top_left) / 2.0;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
const RECT: u32 = 0u;
|
||||
// TEXTURE has no entry in group 1: a standalone image draws with its own
|
||||
// bind group (see UiRenderNode::draw), so there is nothing per-instance left
|
||||
// to look up here -- the bind group already picked the texture.
|
||||
// Standalone images select their texture through their own bind group.
|
||||
const TEXTURE: u32 = 1u;
|
||||
const GLYPH: u32 = 2u;
|
||||
|
||||
@@ -22,24 +20,19 @@ struct Rect {
|
||||
struct GlyphInfo {
|
||||
uv_min: vec2<f32>,
|
||||
uv_max: vec2<f32>,
|
||||
// Layer of the shared atlas array texture, not a view or bind-group
|
||||
// index -- a page never gets its own bind group. See TEXTURES.md's
|
||||
// "Recommended shape".
|
||||
// A layer in the shared atlas array, not a bind-group index.
|
||||
layer: u32,
|
||||
color: u32,
|
||||
flags: u32,
|
||||
}
|
||||
|
||||
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
|
||||
/// clips this mask's subtree, and the mask it nests inside
|
||||
/// (`4294967295u` at the top).
|
||||
/// Mirrors `Mask` in data.rs. `parent` is u32::MAX at the root.
|
||||
struct Mask {
|
||||
primitive: u32,
|
||||
parent: u32,
|
||||
}
|
||||
|
||||
/// One widget's cumulative on-screen translation and the slot of the
|
||||
/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs.
|
||||
/// Mirrors `MoveOffset` in data.rs.
|
||||
struct MoveOffset {
|
||||
delta: vec2<f32>,
|
||||
parent: u32,
|
||||
@@ -55,50 +48,27 @@ struct UiScalar {
|
||||
abs: f32,
|
||||
}
|
||||
|
||||
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
||||
// texture with headroom and copies the old layers across -- see
|
||||
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
|
||||
// this replaced, which needed VK_EXT_descriptor_indexing and does not survive
|
||||
// a real share of Android GPUs (see TEXTURES.md).
|
||||
// One array texture avoids descriptor indexing, which is not universal on Android.
|
||||
@group(2) @binding(0)
|
||||
var atlas: texture_2d_array<f32>;
|
||||
// One standalone image's texture. The main draw (rects and glyphs) binds a
|
||||
// 1x1 null texture here, since neither samples it; each image draw call
|
||||
// binds its own -- see UiRenderNode::draw.
|
||||
// Image draws bind their texture here; other draws bind a 1x1 placeholder.
|
||||
@group(2) @binding(1)
|
||||
var image_texture: texture_2d<f32>;
|
||||
@group(2) @binding(2)
|
||||
var samp: sampler;
|
||||
// Their own group, bound once per frame rather than folded into group 2: see
|
||||
// UiRenderNode::masks_layout for why an image's own bind group must not name
|
||||
// either buffer.
|
||||
// Kept outside group 2 so standalone image bind groups need not name these buffers.
|
||||
@group(3) @binding(0)
|
||||
var<storage> masks: array<Mask>;
|
||||
@group(3) @binding(1)
|
||||
var<storage> move_offsets: array<MoveOffset>;
|
||||
// Every primitive's placement, in one arena all layers share. The vertex
|
||||
// stage reads the primitive it is drawing (its slot arrives as the only
|
||||
// vertex attribute); the fragment stage reads a *mask's* primitive, which
|
||||
// is generally a different one in a different layer. See LAYOUT.md's
|
||||
// "Masks with a shape" and `Primitives` in primitive.rs.
|
||||
// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape.
|
||||
@group(3) @binding(2)
|
||||
var<storage> instances: array<PrimitiveInstance>;
|
||||
|
||||
// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in
|
||||
// render_state.rs, which walks the identical chain on the CPU side for
|
||||
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||
// and that was too small: the transcript screen's composer field sits 17
|
||||
// slots below the root, measured 2026-09-07 on this checkout's emulator
|
||||
// by tapping it (the CPU walk's own debug assert names the chain now).
|
||||
// Past the bound both walks simply stop summing, so the widget draws and
|
||||
// hit-tests short by whatever the outer slots held, with nothing on
|
||||
// screen to say so.
|
||||
// Keep synchronized with render_state.rs. The bound prevents a malformed
|
||||
// parent cycle from hanging the GPU; real widget trees have exceeded 16.
|
||||
const PARENT_CHAIN_LIMIT: u32 = 64u;
|
||||
|
||||
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||
/// mask's corners) so the walk is written once. See LAYOUT.md section 2b.
|
||||
fn resolve_move(idx: u32) -> vec2<f32> {
|
||||
var total = vec2<f32>(0.0, 0.0);
|
||||
var i = idx;
|
||||
@@ -117,8 +87,7 @@ struct WindowUniform {
|
||||
dim: vec2<f32>,
|
||||
};
|
||||
|
||||
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
|
||||
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
|
||||
/// Mirrors `PrimitiveInstance` in data.rs.
|
||||
struct PrimitiveInstance {
|
||||
x: UiSpan,
|
||||
y: UiSpan,
|
||||
@@ -128,7 +97,6 @@ struct PrimitiveInstance {
|
||||
move_idx: u32,
|
||||
}
|
||||
|
||||
/// A layer's draw order: one slot into `instances` per instance drawn.
|
||||
struct InstanceInput {
|
||||
@location(0) slot: u32,
|
||||
}
|
||||
@@ -137,8 +105,7 @@ struct VertexOutput {
|
||||
@location(0) top_left: vec2<f32>,
|
||||
@location(1) bot_right: vec2<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
// `flat` is the only interpolation an integer can have, and naga
|
||||
// (wgpu 30) now requires saying so rather than inferring it.
|
||||
// Naga requires integer varyings to declare flat interpolation.
|
||||
@location(3) @interpolate(flat) binding: u32,
|
||||
@location(4) @interpolate(flat) idx: u32,
|
||||
@location(5) @interpolate(flat) mask_idx: u32,
|
||||
@@ -152,10 +119,7 @@ struct Region {
|
||||
bot_right: vec2<f32>,
|
||||
}
|
||||
|
||||
/// One primitive's on-screen corners in window pixels. Written once and
|
||||
/// used by both stages: the vertex stage for the primitive it is drawing,
|
||||
/// the fragment stage for a mask's -- so the shape a mask clips to and the
|
||||
/// shape that was drawn cannot be computed two different ways.
|
||||
/// Shared by drawing and mask coverage so their geometry cannot diverge.
|
||||
struct Corners {
|
||||
top_left: vec2<f32>,
|
||||
bot_right: vec2<f32>,
|
||||
@@ -224,10 +188,7 @@ fn fs_main(
|
||||
color = vec4(1.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
// Every mask on the chain, not just the innermost: a widget that set
|
||||
// its own mask inside another is clipped by both, and the coverages
|
||||
// multiply -- so a pixel inside two feathered corners is dimmed by
|
||||
// both, which is what a compositor does (`Mask::parent` in data.rs).
|
||||
// Nested masks multiply coverage, matching the CPU hit test.
|
||||
var mask_idx = in.mask_idx;
|
||||
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||
if mask_idx == 4294967295u {
|
||||
@@ -240,17 +201,11 @@ fn fs_main(
|
||||
return color;
|
||||
}
|
||||
|
||||
/// How much of `pos` one mask lets through: the referenced primitive's
|
||||
/// own coverage at that pixel, from the same SDF the primitive is drawn
|
||||
/// with. Nothing about the shape is copied into the mask, so a rounded
|
||||
/// container's corner and its children's clipped corner are the same
|
||||
/// arithmetic.
|
||||
/// Uses the referenced primitive itself so its drawn and clipped edges agree.
|
||||
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
|
||||
let inst = instances[mask.primitive];
|
||||
if inst.binding != RECT {
|
||||
// Unreachable: `Painter::set_mask` rejects a glyph or an image
|
||||
// shape by name (see `Mask::primitive`). Letting the pixel
|
||||
// through rather than reading a `rects` entry that is not there.
|
||||
// Painter::set_mask rejects non-rect shapes; fail open if that invariant breaks.
|
||||
return 1.0;
|
||||
}
|
||||
let c = corners_of(inst);
|
||||
@@ -272,11 +227,7 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
|
||||
return color;
|
||||
}
|
||||
|
||||
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
|
||||
/// function both a drawn rect and a mask go through, and the
|
||||
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
|
||||
/// which the hit test uses so a corner that cannot be tapped and a corner
|
||||
/// that is not drawn are the same corner.
|
||||
/// Keep synchronized with the CPU hit-test implementation in render::sdf.
|
||||
fn rounded_rect_coverage(
|
||||
pos: vec2<f32>,
|
||||
top_left: vec2<f32>,
|
||||
@@ -309,10 +260,7 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
||||
}
|
||||
|
||||
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, radius: f32) -> f32 {
|
||||
// vec from center to pixel
|
||||
let p = pixel_pos - rect_center;
|
||||
// vec from inner rect corner to pixel
|
||||
let q = abs(p) - (rect_corner - radius);
|
||||
return length(max(q, vec2(0.0))) - radius;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,7 @@ use super::atlas::PAGE;
|
||||
/// one, for the GLES reason written on `create_array_texture`.
|
||||
const MIN_ARRAY_LAYERS: u32 = 2;
|
||||
|
||||
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
|
||||
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
|
||||
/// same thing on both sides without a second map to keep in sync.
|
||||
enum Slot {
|
||||
/// A slot that was freed, or pushed and freed within the same batch
|
||||
/// before ever reaching here.
|
||||
Empty,
|
||||
Image(ImageGpu),
|
||||
/// The array layer a page occupies. Pages are never freed (see
|
||||
@@ -23,16 +18,12 @@ enum Slot {
|
||||
}
|
||||
|
||||
struct ImageGpu {
|
||||
/// Kept alive alongside `view`/`bind_group`, which borrow from it only in
|
||||
/// the sense that dropping this drops the GPU resource they point to.
|
||||
#[allow(dead_code)]
|
||||
texture: Texture,
|
||||
view: TextureView,
|
||||
bind_group: BindGroup,
|
||||
}
|
||||
|
||||
/// Owns the two kinds of texture iris draws:
|
||||
///
|
||||
/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages
|
||||
/// (`Slot::Page`), grown by recreating the array with headroom and
|
||||
/// `copy_texture_to_texture`-ing the old layers across. No feature beyond
|
||||
@@ -41,11 +32,6 @@ struct ImageGpu {
|
||||
/// - **Standalone images** (`Slot::Image`), each its own `Texture` and
|
||||
/// `BindGroup`, drawn one `draw()` call at a time with that bind group
|
||||
/// bound -- see `UiRenderNode::draw`.
|
||||
///
|
||||
/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's
|
||||
/// "iris's binding array does not survive real Android hardware" for what
|
||||
/// this replaced (one giant `binding_array<texture_2d<f32>>` needing
|
||||
/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack).
|
||||
pub struct GpuTextures {
|
||||
device: Device,
|
||||
queue: Queue,
|
||||
@@ -55,7 +41,6 @@ pub struct GpuTextures {
|
||||
array_texture: Texture,
|
||||
array_view: TextureView,
|
||||
array_capacity: u32,
|
||||
/// Layers actually written. Only grows -- see `Slot::Page`.
|
||||
page_count: u32,
|
||||
|
||||
sampler: Sampler,
|
||||
@@ -64,18 +49,7 @@ pub struct GpuTextures {
|
||||
/// but the layout requires something bound regardless.
|
||||
null_view: TextureView,
|
||||
|
||||
/// Standalone-image bind groups actually built (`create_image`'s own
|
||||
/// build, or one per slot touched by `rebuild_image_bind_groups`) since
|
||||
/// the last `take_bind_group_creates`. IRIS_TODO.md's "many images"
|
||||
/// benchmark reads this to prove the steady-state cost of an
|
||||
/// unchanging image list is zero, the same way `UiRenderState`'s
|
||||
/// `draw_count`/`region_mut_count` prove the layout side.
|
||||
bind_group_creates: u64,
|
||||
/// `grow_array` calls since the last `take_pages_grown` -- the
|
||||
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
|
||||
/// input frame" investigation) reads this alongside `bind_group_creates`
|
||||
/// to say whether *this* frame's glyph disappearance, if any, coincided
|
||||
/// with the atlas array being recreated.
|
||||
pages_grown: u64,
|
||||
}
|
||||
|
||||
@@ -161,7 +135,6 @@ impl GpuTextures {
|
||||
if let Some(slot) = self.slots.get_mut(i as usize) {
|
||||
*slot = Slot::Empty;
|
||||
}
|
||||
// A page's layer is not reclaimed here either -- see `Slot::Page`.
|
||||
}
|
||||
|
||||
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
|
||||
@@ -171,9 +144,6 @@ impl GpuTextures {
|
||||
if rect.width == 0 || rect.height == 0 {
|
||||
return;
|
||||
}
|
||||
// Cropped rather than written straight from the atlas, because
|
||||
// write_texture wants tightly packed rows and the atlas rows are as
|
||||
// wide as the atlas. A glyph is small, so the copy is too.
|
||||
let sub = image
|
||||
.view(rect.x, rect.y, rect.width, rect.height)
|
||||
.to_image();
|
||||
@@ -231,10 +201,6 @@ impl GpuTextures {
|
||||
);
|
||||
}
|
||||
|
||||
/// Doubles the array's layer capacity (headroom, so this is rare) and
|
||||
/// copies the old layers across GPU-side -- no readback. Recreates the
|
||||
/// array's view, which invalidates every bind group that referenced it,
|
||||
/// so this also rebuilds all of them before returning.
|
||||
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
||||
self.pages_grown += 1;
|
||||
let new_capacity = self.array_capacity * 2;
|
||||
@@ -275,10 +241,6 @@ impl GpuTextures {
|
||||
self.rebuild_image_bind_groups(rsc_layout);
|
||||
}
|
||||
|
||||
/// Called only from `grow_array`: the atlas array's view identity is the
|
||||
/// one thing an image's bind group (group 2) still names that can
|
||||
/// change out from under it. Masks/move_offsets resizing no longer
|
||||
/// reaches here at all -- see `UiRenderNode::masks_group`.
|
||||
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) {
|
||||
for slot in &mut self.slots {
|
||||
if let Slot::Image(gpu) = slot {
|
||||
@@ -332,11 +294,6 @@ impl GpuTextures {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds group 2 for one standalone image: the shared atlas array, this
|
||||
/// image's own view and the shared sampler -- the same layout the main
|
||||
/// draw uses with a null view in the image slot. Deliberately does not
|
||||
/// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see
|
||||
/// that field's comment for why folding them in here was the bug.
|
||||
fn make_image_bind_group(
|
||||
device: &Device,
|
||||
rsc_layout: &BindGroupLayout,
|
||||
@@ -364,20 +321,6 @@ impl GpuTextures {
|
||||
})
|
||||
}
|
||||
|
||||
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
|
||||
/// array is not one on the GLES backend**: wgpu-hal picks the GL
|
||||
/// texture target from the descriptor alone
|
||||
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
|
||||
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
|
||||
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
|
||||
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
|
||||
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
|
||||
/// filled box. That was iris's appearance on the emulator's GLES for
|
||||
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
|
||||
/// it is a real defect on any device whose adapter is GL rather than
|
||||
/// Vulkan, not an emulator artifact. So the array never has fewer than
|
||||
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
|
||||
/// texture memory and is used by the next atlas page anyway.
|
||||
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
|
||||
debug_assert!(
|
||||
capacity >= MIN_ARRAY_LAYERS,
|
||||
@@ -426,15 +369,10 @@ impl GpuTextures {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and zeroes the standalone-image bind-group creation counter --
|
||||
/// call once per frame before `update()`, mirroring
|
||||
/// `UiRenderState::take_counters`.
|
||||
pub fn take_bind_group_creates(&mut self) -> u64 {
|
||||
std::mem::take(&mut self.bind_group_creates)
|
||||
}
|
||||
|
||||
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
|
||||
/// field comment.
|
||||
pub fn take_pages_grown(&mut self) -> u64 {
|
||||
std::mem::take(&mut self.pages_grown)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ use crate::util::Dirty;
|
||||
use bytemuck::Pod;
|
||||
use wgpu::*;
|
||||
|
||||
/// A GPU array whose `Buffer` outlives the data in it.
|
||||
///
|
||||
/// **The buffer has a capacity, and shrinking never reallocates.** That
|
||||
/// is not only about allocation cost: a fresh `Buffer`'s contents are
|
||||
/// undefined, so a reallocation is the one event after which a *partial*
|
||||
@@ -13,23 +11,11 @@ use wgpu::*;
|
||||
/// is therefore the precondition for uploading only what changed, and
|
||||
/// [`Self::update`] says which of the two happened so a caller can force
|
||||
/// the whole range dirty.
|
||||
///
|
||||
/// It reallocated on every length change until 2026-09-09, which made the
|
||||
/// streaming path pay a full rewrite of every arena on nearly every
|
||||
/// frame -- adding one glyph changes a length. Measured over the bench
|
||||
/// fixture's 401 streamed deltas (`scripts/rigs/ui-profile`'s
|
||||
/// `arena_churn`): the glyph buffer's *changed* bytes were 3.0% of its
|
||||
/// size, but 95% of it had to be re-uploaded anyway because the buffer
|
||||
/// underneath had just been replaced.
|
||||
pub struct ArrBuf<T: Pod> {
|
||||
label: &'static str,
|
||||
usage: BufferUsages,
|
||||
pub buffer: Buffer,
|
||||
/// Entries the caller last wrote -- what a draw call reads.
|
||||
len: usize,
|
||||
/// Entries the buffer has room for. Grows geometrically and never
|
||||
/// shrinks, so a list that oscillates in length (every frame of a
|
||||
/// fling adds and drops rows) settles on one allocation.
|
||||
capacity: usize,
|
||||
_pd: PhantomData<T>,
|
||||
}
|
||||
@@ -52,11 +38,6 @@ impl<T: Pod> ArrBuf<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grows to hold `len` entries if it does not already, answering
|
||||
/// whether that meant a new `Buffer`. Doubling rather than exact, so a
|
||||
/// buffer that grows by one entry per frame -- which is what a
|
||||
/// streamed reply does to the glyph arena -- reallocates a logarithmic
|
||||
/// number of times rather than every frame.
|
||||
pub fn reserve(&mut self, device: &Device, len: usize) -> bool {
|
||||
if len <= self.capacity {
|
||||
return false;
|
||||
@@ -76,9 +57,6 @@ impl<T: Pod> ArrBuf<T> {
|
||||
usage: BufferUsages,
|
||||
label: &'static str,
|
||||
) -> Buffer {
|
||||
// A storage binding of size 0 is a validation error, and an empty
|
||||
// arena is the ordinary state of a buffer nothing has drawn into
|
||||
// yet.
|
||||
let size = (entries.max(1) * std::mem::size_of::<T>()) as u64;
|
||||
device.create_buffer(&BufferDescriptor {
|
||||
label: Some(label),
|
||||
@@ -123,10 +101,6 @@ impl<T: Pod> ArrBuf<T> {
|
||||
reallocated
|
||||
}
|
||||
|
||||
/// How far apart two dirty runs may be and still be uploaded as one
|
||||
/// -- in entries, so a wider entry merges across fewer of them and
|
||||
/// the *byte* cost of merging is the same either way. See
|
||||
/// [`Dirty::ranges`] for the measurement behind 1 KiB.
|
||||
const MERGE_GAP: usize = 1024 / std::mem::size_of::<T>();
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
//! I4 (RUST.md): an AccessKit tree built from iris's own widget tree,
|
||||
//! shared by both backends -- `android/view.rs` pushes its `TreeUpdate`s
|
||||
//! through `accesskit_android::Adapter`, `default/mod.rs` through
|
||||
//! `accesskit_winit::Adapter`. Kept modular the way input's sense registry
|
||||
//! is: `Widgets::named()` is a side set populated only by `.label()`, so a
|
||||
//! widget nobody named is never visited here at all, not even to decide it
|
||||
//! has no name.
|
||||
//!
|
||||
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
|
||||
//! root with every named widget as a direct child, in no particular order.
|
||||
//! iris's actual widget nesting (a label three `Span`s deep inside a
|
||||
//! `ScrollArea`) carries no accessibility meaning of its own here: nothing
|
||||
//! upstream of a named leaf needs a node, since a screen reader's own
|
||||
//! traversal (and uiautomator's tap-by-name, the pass condition this was
|
||||
//! built for) works from each node's on-screen bounds rather than from
|
||||
//! tree structure. Mirroring the real widget tree exactly would also mean
|
||||
//! rebuilding intermediate nodes whenever *any* container above a named
|
||||
//! widget resizes, which is most frames -- the flat shape is what keeps
|
||||
//! rebuilds tied to "a name, a role or a position actually changed".
|
||||
|
||||
use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap};
|
||||
use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate};
|
||||
|
||||
@@ -57,11 +37,6 @@ fn entry_node(entry: &Entry) -> Node {
|
||||
#[derive(Default)]
|
||||
pub struct AccessTree {
|
||||
known: HashMap<WidgetId, Entry>,
|
||||
/// `TreeUpdate`s actually produced since the last `take_rebuilds` --
|
||||
/// the AccessKit-tree twin of `UiRenderState::take_counters`. Should
|
||||
/// stay at 0 across an unchanged frame and move by exactly 1 when a
|
||||
/// named widget's position, name or role changes, however many other
|
||||
/// widgets are on screen; see `iris/src/access_tests.rs`.
|
||||
rebuilds: u64,
|
||||
}
|
||||
|
||||
@@ -128,8 +103,6 @@ impl AccessTree {
|
||||
build_update(&Self::collect(widgets, render, rsc))
|
||||
}
|
||||
|
||||
/// Reads and zeroes the rebuild counter, the same call shape as
|
||||
/// `UiRenderState::take_counters`.
|
||||
pub fn take_rebuilds(&mut self) -> u64 {
|
||||
std::mem::take(&mut self.rebuilds)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::{
|
||||
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
|
||||
};
|
||||
|
||||
/// important non rendering data for retained drawing
|
||||
#[derive(Debug)]
|
||||
pub struct ActiveData {
|
||||
pub id: WidgetId,
|
||||
@@ -11,23 +10,13 @@ pub struct ActiveData {
|
||||
pub textures: Vec<TextureHandle>,
|
||||
pub primitives: Vec<PrimitiveHandle>,
|
||||
pub children: Vec<WidgetId>,
|
||||
/// Direct children whose reported size this widget used during its
|
||||
/// latest draw. Dirtiness propagates across these edges before layout
|
||||
/// starts, so the resulting draw still travels only parent to child.
|
||||
pub size_dependencies: Vec<WidgetId>,
|
||||
/// The inherited mask, not `own_mask`.
|
||||
pub mask: MaskIdx,
|
||||
/// The widget's retained mask slot, or `MaskIdx::NONE`.
|
||||
pub own_mask: MaskIdx,
|
||||
pub layer: LayerId,
|
||||
/// The size recorded by the last `Widget::draw` through its painter.
|
||||
pub size: Size,
|
||||
/// Retained so descendants' parent links stay valid across redraws.
|
||||
pub move_slot: MoveIdx,
|
||||
/// The optional coordinate boundary between this widget and its direct
|
||||
/// children. Descendants retain links to it across redraws, just as they
|
||||
/// do to `move_slot`.
|
||||
pub child_move_slot: Option<MoveIdx>,
|
||||
/// The part of this widget's move delta already folded into `region`.
|
||||
pub move_applied: Vec2,
|
||||
}
|
||||
@@ -18,20 +18,7 @@ pub struct UiData {
|
||||
pub textures: Textures,
|
||||
pub text: TextData,
|
||||
pub masks: TrackedArena<Mask, u32>,
|
||||
/// One entry per widget ever drawn, plus optional child-coordinate
|
||||
/// boundaries owned by containers. Together they form the parent-linked
|
||||
/// chain `resolve_move` walks in both shader stages. A widget's ordinary
|
||||
/// entry is allocated once on its first draw and reused for every later
|
||||
/// redraw of the same id, so a retained descendant's `parent` index never
|
||||
/// goes stale -- see LAYOUT.md section 2.
|
||||
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||
/// Every widget whose [`crate::Widget::tick`] should run before the
|
||||
/// next frame -- today, a `LazySpan` coasting through a fling. Added by
|
||||
/// [`Self::animate`] when the animation starts and removed by
|
||||
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
||||
/// a stopped animation costs nothing and a dropped widget cannot be
|
||||
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
|
||||
/// way).
|
||||
animating: Vec<WidgetId>,
|
||||
}
|
||||
|
||||
@@ -46,15 +33,7 @@ impl UiData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick every registered widget to `now`, drop the ones that finished,
|
||||
/// and say whether any is still going -- which is a backend's cue to
|
||||
/// ask for another frame. Called once per frame *before* the draw, so
|
||||
/// what the frame draws is this instant's position rather than the
|
||||
/// previous one's.
|
||||
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
|
||||
// Taken out and put back rather than iterated in place: `tick`
|
||||
// needs `&mut` on the widget arena this list lives beside, and a
|
||||
// widget is free to register another one while ticking.
|
||||
let mut registered = std::mem::take(&mut self.animating);
|
||||
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
|
||||
Some(widget) => widget.tick(now),
|
||||
|
||||
@@ -18,11 +18,9 @@ pub struct Painter<'a> {
|
||||
pub(super) mask: MaskIdx,
|
||||
pub(super) move_slot: MoveIdx,
|
||||
pub(super) child_move_slot: Option<MoveIdx>,
|
||||
/// This widget's retained mask slot.
|
||||
pub(super) own_mask: MaskIdx,
|
||||
pub(super) textures: Vec<TextureHandle>,
|
||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||
/// Previous handles, consumed in draw order and freed if left over.
|
||||
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
|
||||
pub(super) children: Vec<WidgetId>,
|
||||
pub(super) size_dependencies: Vec<WidgetId>,
|
||||
@@ -37,16 +35,12 @@ pub struct Painter<'a> {
|
||||
pub(super) id: WidgetId,
|
||||
}
|
||||
|
||||
/// A child draw whose size has not necessarily been observed by its parent.
|
||||
/// Holding this value keeps the painter borrowed, so `.size()` can only name
|
||||
/// the child from the immediately preceding draw.
|
||||
pub struct DrawResult<'p, 'a> {
|
||||
painter: &'p mut Painter<'a>,
|
||||
child: WidgetId,
|
||||
}
|
||||
|
||||
impl DrawResult<'_, '_> {
|
||||
/// Return the child's reported size and record the layout dependency.
|
||||
pub fn size(self) -> Size {
|
||||
if !self.painter.size_dependencies.contains(&self.child) {
|
||||
self.painter.size_dependencies.push(self.child);
|
||||
@@ -56,8 +50,6 @@ impl DrawResult<'_, '_> {
|
||||
}
|
||||
|
||||
impl<'a> Painter<'a> {
|
||||
/// Record the size this widget used. Every `Widget::draw` calls this
|
||||
/// exactly once; parents observe it through [`DrawResult::size`].
|
||||
pub fn set_size(&mut self, size: Size) {
|
||||
assert!(
|
||||
self.size.replace(size).is_none(),
|
||||
@@ -69,10 +61,6 @@ impl<'a> Painter<'a> {
|
||||
self.write_primitive(primitive, region, Drawn::Yes);
|
||||
}
|
||||
|
||||
/// The next handle from the previous draw, if it can hold what is
|
||||
/// about to be written: same kind of primitive, same layer, and the
|
||||
/// same answer to "does a layer's draw order name it".
|
||||
///
|
||||
/// **Consumed strictly in order, and one mismatch ends recycling for
|
||||
/// the rest of the draw.** A widget's `draw` is a function of its own
|
||||
/// state, so a redraw writes the same sequence of primitives in the
|
||||
@@ -90,8 +78,6 @@ impl<'a> Painter<'a> {
|
||||
self.recycle.next()
|
||||
}
|
||||
|
||||
/// The one path every primitive this widget owns goes through --
|
||||
/// drawn or, for a mask's shape, only referenced.
|
||||
fn write_primitive<P: Primitive>(
|
||||
&mut self,
|
||||
primitive: P,
|
||||
@@ -121,13 +107,6 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
|
||||
/// Take ownership of a handle this widget just wrote.
|
||||
///
|
||||
/// The one place a `PrimitiveHandle` enters `self.primitives`, and so
|
||||
/// the one place that can keep `Primitives::handle_index` in step with
|
||||
/// where it lands -- which is what `UiRenderState::apply_free` reads
|
||||
/// instead of scanning this vec. Anything that writes a primitive
|
||||
/// without coming through here leaves that index unset, and its
|
||||
/// position in a layer's draw order stops being renumbered.
|
||||
fn own(&mut self, h: PrimitiveHandle) {
|
||||
self.state
|
||||
.primitives
|
||||
@@ -135,7 +114,6 @@ impl<'a> Painter<'a> {
|
||||
self.primitives.push(h);
|
||||
}
|
||||
|
||||
/// Writes a primitive to be rendered
|
||||
pub fn primitive<P: Primitive>(&mut self, primitive: P) {
|
||||
self.primitive_at(primitive, self.region)
|
||||
}
|
||||
@@ -144,18 +122,6 @@ impl<'a> Painter<'a> {
|
||||
self.primitive_at(primitive, region.within(&self.region));
|
||||
}
|
||||
|
||||
/// Clip everything this widget draws, itself and its descendants, to
|
||||
/// `region`. One call per widget; a widget drawn inside another
|
||||
/// widget's mask nests instead -- the new mask chains to the inherited
|
||||
/// one (`Mask::parent`) and the fragment stage multiplies both
|
||||
/// coverages, which is what lets a transcript row's code fence clip
|
||||
/// to itself *and* to the list it scrolls inside.
|
||||
///
|
||||
/// The clip is a **primitive**, not a rectangle copied into the mask:
|
||||
/// this writes an undrawn `RectPrimitive` at `region` and points the
|
||||
/// mask at it, so the fragment stage evaluates the same rounded-rect
|
||||
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
|
||||
///
|
||||
/// The slot is allocated once and **rewritten in place** on every
|
||||
/// later draw rather than pushed again, because a descendant whose own
|
||||
/// region did not change is not redrawn (`draw_inner`'s fast path) and
|
||||
@@ -184,24 +150,12 @@ impl<'a> Painter<'a> {
|
||||
self.set_mask_to(slot);
|
||||
}
|
||||
|
||||
/// Points this widget's mask at a primitive that has already been
|
||||
/// written -- the shared half of [`Self::set_mask`].
|
||||
fn set_mask_to(&mut self, shape: u32) {
|
||||
// `assert!`, not `debug_assert!`: one comparison per widget draw,
|
||||
// and the second call silently *replacing* the first is a widget
|
||||
// drawn unclipped -- which reaches the screen and nothing says so.
|
||||
// Every build anybody runs here is release
|
||||
// (review, 2026-09-07).
|
||||
assert!(
|
||||
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
|
||||
"set_mask called twice while drawing one widget: the second would replace the first \
|
||||
rather than nest inside it",
|
||||
);
|
||||
// A glyph would need a CPU-side alpha plane for the hit test to
|
||||
// agree with the shader, and a standalone image a bind-group
|
||||
// switch the fragment stage cannot make -- see `Mask::primitive`.
|
||||
// Named here rather than left to the shader, which would read a
|
||||
// rect that is not there and clip to nothing.
|
||||
let binding = self.state.primitives.instance(shape).binding;
|
||||
assert_eq!(
|
||||
binding,
|
||||
@@ -215,9 +169,6 @@ impl<'a> Painter<'a> {
|
||||
};
|
||||
let old_parent = if self.own_mask == MaskIdx::NONE {
|
||||
let slot = self.rsc.ui_mut().masks.push(mask);
|
||||
// The one ref this widget holds on its own slot, so the slot
|
||||
// outlives any single frame's primitives; released in
|
||||
// `UiRenderState::remove`'s `undraw` branch.
|
||||
self.rsc.ui_mut().masks.push_ref(slot);
|
||||
self.own_mask = slot;
|
||||
MaskIdx::NONE
|
||||
@@ -226,10 +177,6 @@ impl<'a> Painter<'a> {
|
||||
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||
old
|
||||
};
|
||||
// The chain link's own ref, taken before the old one is dropped so
|
||||
// that re-chaining to the same slot cannot free it in between.
|
||||
// Released here when the link changes, and in
|
||||
// `UiRenderState::remove` when this widget's slot goes.
|
||||
if old_parent != parent {
|
||||
if parent != MaskIdx::NONE {
|
||||
self.rsc.ui_mut().masks.push_ref(parent);
|
||||
@@ -241,14 +188,10 @@ impl<'a> Painter<'a> {
|
||||
self.mask = self.own_mask;
|
||||
}
|
||||
|
||||
/// Draw a widget within this widget's region. Reading the result's size
|
||||
/// records that this widget's layout depends on the child.
|
||||
pub fn widget<'p, W: ?Sized>(&'p mut self, id: &StrongWidget<W>) -> DrawResult<'p, 'a> {
|
||||
self.widget_at(id, self.region)
|
||||
}
|
||||
|
||||
/// Draws a widget somewhere within this one.
|
||||
/// Useful for drawing child widgets in select areas.
|
||||
pub fn widget_within<'p, W: ?Sized>(
|
||||
&'p mut self,
|
||||
id: &StrongWidget<W>,
|
||||
@@ -263,11 +206,6 @@ impl<'a> Painter<'a> {
|
||||
/// Once retained, it may be updated later in a redraw (for example after
|
||||
/// measuring a changed child). All deeper descendants inherit it and the
|
||||
/// CPU hit-test walk resolves the same translation as the shader.
|
||||
///
|
||||
/// This offsets the child coordinate space, not this widget: its own
|
||||
/// primitives and hit region remain fixed. Once allocated, the boundary
|
||||
/// stays in the chain across redraws; set it to zero to return children to
|
||||
/// their unshifted positions.
|
||||
pub fn set_child_offset(&mut self, offset: Vec2) {
|
||||
let slot = match self.child_move_slot {
|
||||
Some(slot) => slot,
|
||||
@@ -322,13 +260,6 @@ impl<'a> Painter<'a> {
|
||||
region: UiRegion,
|
||||
) -> DrawResult<'p, 'a> {
|
||||
self.children.push(id.id());
|
||||
// Passed directly rather than looked up from `self.active`: this
|
||||
// widget's own `ActiveData` (which would carry its `move_slot`) is
|
||||
// not inserted there until *after* its own `Widget::draw` returns,
|
||||
// so a lookup here -- for a child drawn partway through that same
|
||||
// call -- would always find nothing. `self.move_slot` is this
|
||||
// widget's own slot, already known, and always correct regardless
|
||||
// of insertion order. See `UiRenderState::move_parent_of`.
|
||||
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
|
||||
self.state.draw_inner(
|
||||
self.layer,
|
||||
@@ -346,7 +277,6 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Place an already-drawn child's used area, redrawing only if its size changes.
|
||||
pub fn place<'p, W: ?Sized>(
|
||||
&'p mut self,
|
||||
id: &StrongWidget<W>,
|
||||
@@ -436,9 +366,6 @@ impl<'a> Painter<'a> {
|
||||
self.write_image(handle.image_index(), region);
|
||||
}
|
||||
|
||||
/// A standalone image draws with its own bind group rather than sharing
|
||||
/// the layer's one instanced draw, so it goes through
|
||||
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
||||
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
||||
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
|
||||
Some(h) => {
|
||||
@@ -474,27 +401,16 @@ impl<'a> Painter<'a> {
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
let density = self.state.density;
|
||||
// Counted here rather than in `TextView::render`, which returns
|
||||
// its memoized layout without reaching this -- so this counts
|
||||
// shapes, not requests. `UiRenderState::take_counters`.
|
||||
self.state.shape_count += 1;
|
||||
let ui = self.rsc.ui_mut();
|
||||
ui.text
|
||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||
}
|
||||
|
||||
/// Which glyph atlas the glyphs handed out right now belong to --
|
||||
/// what a widget caching a [`RenderedText`] across frames has to
|
||||
/// compare against before re-emitting it (`GlyphAtlas::clear`).
|
||||
pub fn atlas_generation(&mut self) -> u64 {
|
||||
self.rsc.ui_mut().text.atlas.generation()
|
||||
}
|
||||
|
||||
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||
///
|
||||
/// `origin` is where the text's top-left goes; every glyph is placed at an
|
||||
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
||||
/// and nothing else.
|
||||
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
||||
// A caller re-emitting quads placed against an atlas that has since
|
||||
// been cleared draws every glyph from coordinates now holding
|
||||
|
||||
@@ -11,27 +11,16 @@ use crate::{
|
||||
util::{HashMap, HashSet, Id, Vec2},
|
||||
};
|
||||
|
||||
/// What [`UiRenderState::update`] did on its last call -- read back by the
|
||||
/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris`
|
||||
/// crate) so a report can tell a full relayout from a frame that only
|
||||
/// redrew a handful of dirty widgets from one that drew nothing at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RedrawKind {
|
||||
/// Neither the root nor any widget changed -- `update` did nothing.
|
||||
None,
|
||||
/// [`UiRenderState::redraw_all`]: a new root, or a resize.
|
||||
All,
|
||||
/// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw`
|
||||
/// named.
|
||||
Updates,
|
||||
}
|
||||
|
||||
pub struct UiRenderState {
|
||||
pub active: HashMap<WidgetId, ActiveData>,
|
||||
/// Every primitive in the tree, in one arena -- see [`Primitives`] for
|
||||
/// why it is not per layer.
|
||||
pub primitives: Primitives,
|
||||
/// What each layer draws, in order: slots into `primitives`.
|
||||
pub layers: PrimitiveLayers,
|
||||
pub(super) output_size: Vec2,
|
||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||
@@ -43,14 +32,6 @@ pub struct UiRenderState {
|
||||
|
||||
old_root: Option<WidgetId>,
|
||||
resized: bool,
|
||||
/// The widgets whose `Widget::draw` is on the stack right now -- so
|
||||
/// [`Self::redraw`] can tell "this widget needs drawing again" from
|
||||
/// "an ancestor is drawing it at this very moment", where a second
|
||||
/// draw would leave the first one's primitives behind with nothing
|
||||
/// owning them. An id is inserted immediately before `draw` is called
|
||||
/// and removed the moment it returns (both in `draw_inner`), so this
|
||||
/// is empty between frames -- asserted at the end of `update`.
|
||||
///
|
||||
/// It used to only ever be inserted into, and `redraw` removed the id
|
||||
/// *before* testing for it, which made the test constant `false`: the
|
||||
/// guard could never fire and the set grew by one entry per widget
|
||||
@@ -65,14 +46,8 @@ pub struct UiRenderState {
|
||||
draw_count: u64,
|
||||
region_mut_count: u64,
|
||||
mov_count: u64,
|
||||
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
||||
/// which `TextView::render` only reaches on a cache miss.
|
||||
pub(super) shape_count: u64,
|
||||
|
||||
/// `Instant::now()` at construction -- the zero every `iris::frame` line
|
||||
/// dates itself from, so a report's `now=` is comparable to a harness's
|
||||
/// own `t_ms` (`Harness::new` builds its `base` the same way, in the
|
||||
/// same constructor call) without either side needing the wall clock.
|
||||
epoch: Instant,
|
||||
/// How many times [`Self::update`] has run -- the `iris::frame` line's
|
||||
/// frame number. Counts every call, including one that found nothing to
|
||||
@@ -80,9 +55,6 @@ pub struct UiRenderState {
|
||||
/// was never asked to run at all (a stalled event loop), not one that
|
||||
/// ran and did nothing.
|
||||
frame_no: u64,
|
||||
/// How long the redraw phase of the last [`Self::update`] took --
|
||||
/// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or
|
||||
/// zero if neither did. Read back by `iris::diagnostics::log_frame`.
|
||||
last_layout: Duration,
|
||||
last_redraw_kind: RedrawKind,
|
||||
/// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris`
|
||||
@@ -94,7 +66,6 @@ pub struct UiRenderState {
|
||||
last_input_at: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// State retained while replacing one draw with another.
|
||||
pub(crate) struct Retained {
|
||||
pub region: Option<UiRegion>,
|
||||
pub children: Vec<WidgetId>,
|
||||
@@ -117,21 +88,6 @@ impl Default for Retained {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||
/// which walks the identical chain and must be kept in step with this
|
||||
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
|
||||
/// not as a statement about how deep a real tree gets: it was 16, and the
|
||||
/// transcript screen's composer field turned out to sit **17** slots below
|
||||
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
|
||||
/// the composer in a debug build -- the assert in `resolve_move_chain`
|
||||
/// prints the chain). A chain past the bound is not reported anywhere at
|
||||
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||
/// tested short by whatever the outer slots held.
|
||||
///
|
||||
/// Named for the walk rather than for one of its two subjects: it bounds
|
||||
/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in
|
||||
/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first
|
||||
/// (review, 2026-09-07).
|
||||
pub const PARENT_CHAIN_LIMIT: usize = 64;
|
||||
|
||||
impl UiRenderState {
|
||||
@@ -157,15 +113,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
||||
/// writes, text shapes) counters -- call once per frame before
|
||||
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
||||
///
|
||||
/// The fourth is the one a draw count cannot stand in for: a widget
|
||||
/// can be redrawn without re-shaping (`TextView::render` memoizes by
|
||||
/// width) and re-shaped without any extra draw, and it is re-shaping
|
||||
/// that the per-block transcript row exists to avoid -- see
|
||||
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
|
||||
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
|
||||
(
|
||||
std::mem::take(&mut self.draw_count),
|
||||
@@ -179,8 +126,6 @@ impl UiRenderState {
|
||||
self.mov_count += 1;
|
||||
}
|
||||
|
||||
/// Writes a primitive into the arena and, unless it is
|
||||
/// [`Drawn::No`], into `layer`'s draw order.
|
||||
pub(super) fn write_primitive<P: Primitive>(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
@@ -201,8 +146,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A standalone image, which draws with its own bind group rather
|
||||
/// than sharing the layer's one instanced draw.
|
||||
pub(super) fn write_image(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
@@ -225,23 +168,9 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compacts every layer's draw order around the primitives freed
|
||||
/// this frame, corrects the handles that moved, and only then hands
|
||||
/// the arena slots back for reuse -- that order is the whole reason
|
||||
/// `Primitives::freed` exists. Once per frame, at the end of
|
||||
/// [`Self::update`], so the harness (which has no renderer) applies
|
||||
/// it exactly as a real backend does.
|
||||
fn apply_free(&mut self) {
|
||||
for (layer, order) in self.layers.iter_mut() {
|
||||
for change in order.apply_free() {
|
||||
// Straight to the handle, never a scan of everything the
|
||||
// owner drew: a widget freed and redrawn in one frame has
|
||||
// *every* one of its primitives renumbered here, so a scan
|
||||
// makes this pass quadratic in that widget's primitive
|
||||
// count -- 1.37s for one 51,200-glyph text block, against
|
||||
// 20ms to shape and rasterise the same text (measured
|
||||
// 2026-09-08). `Primitives::handle_index` is written where
|
||||
// the handle is taken, in `Painter::own`.
|
||||
let owner = self.primitives.owner(change.slot);
|
||||
let Some(idx) = self.primitives.handle_index(change.slot) else {
|
||||
continue;
|
||||
@@ -274,12 +203,6 @@ impl UiRenderState {
|
||||
/// different triggers (a surface resize on every rotation or keyboard
|
||||
/// open; a density change only if the app follows the display to a
|
||||
/// different screen, which Android surfaces separately).
|
||||
///
|
||||
/// Marks the tree for a full redraw when the value actually changes:
|
||||
/// every `Len::dp` already resolved and every glyph already shaped
|
||||
/// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs
|
||||
/// to the old one, and nothing else would ask for them again
|
||||
/// (review, 2026-09-07).
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
if density != self.density {
|
||||
self.resized = true;
|
||||
@@ -334,30 +257,23 @@ impl UiRenderState {
|
||||
self.last_layout = layout_start.elapsed();
|
||||
self.last_redraw_kind = kind;
|
||||
self.frame_no += 1;
|
||||
// After the redraw and before anything reads the frame: every
|
||||
// slot freed above is still named by its layer's draw order until
|
||||
// this runs.
|
||||
self.apply_free();
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||
}
|
||||
|
||||
/// `Instant::now()` at construction -- see the field's own doc.
|
||||
pub fn epoch(&self) -> Instant {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// How many times [`Self::update`] has run, counting from 1.
|
||||
pub fn frame_number(&self) -> u64 {
|
||||
self.frame_no
|
||||
}
|
||||
|
||||
/// How long the last [`Self::update`]'s redraw phase took.
|
||||
pub fn last_layout_duration(&self) -> Duration {
|
||||
self.last_layout
|
||||
}
|
||||
|
||||
/// What the last [`Self::update`] did -- see [`RedrawKind`].
|
||||
pub fn last_redraw_kind(&self) -> RedrawKind {
|
||||
self.last_redraw_kind
|
||||
}
|
||||
@@ -384,17 +300,7 @@ impl UiRenderState {
|
||||
at.map(|at| now.saturating_duration_since(at))
|
||||
}
|
||||
|
||||
/// Primitive instances every currently-active widget owns, summed --
|
||||
/// what `iris::frame`'s `primitives=` reports. Not a per-frame delta:
|
||||
/// `redraw_updates` only rewrites what changed, so this is "how much is
|
||||
/// on screen", which is what a report reads as "did this frame have
|
||||
/// more to draw than the last one", not "how much work did this frame
|
||||
/// do" (`take_counters` answers that).
|
||||
///
|
||||
/// A mask's shape does not count: it is a [`Drawn::No`] primitive
|
||||
/// that is never rasterized, so including it would put one extra on
|
||||
/// the line for every masked widget and make a number Iris reads off
|
||||
/// a phone report disagree with what is drawn.
|
||||
/// Excludes undrawn mask shapes so diagnostics match rasterized primitives.
|
||||
pub fn active_primitive_count(&self) -> usize {
|
||||
self.active
|
||||
.values()
|
||||
@@ -404,7 +310,6 @@ impl UiRenderState {
|
||||
|
||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||
self.clear(rsc);
|
||||
// free all resources & cache
|
||||
if let Some(id) = root {
|
||||
self.draw_inner(
|
||||
0,
|
||||
@@ -419,16 +324,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot an *already-active* widget's `move_offsets` entry chains
|
||||
/// to, read back from `self.active`. Only valid where the parent is
|
||||
/// guaranteed to already be in `self.active` -- true for `redraw()`,
|
||||
/// which targets a widget that was fully drawn on some earlier update,
|
||||
/// but **not** for a widget being drawn as part of its own parent's
|
||||
/// `Widget::draw` call: that parent's `ActiveData` is not inserted
|
||||
/// until its `draw` returns (below), so a child drawn partway through
|
||||
/// it would always read back "no parent" here. `Painter::widget_at`
|
||||
/// avoids that trap by passing its own already-known `move_slot`
|
||||
/// straight through instead of asking `self.active` to look it up.
|
||||
fn move_parent_of(&self, parent: Option<WidgetId>) -> u32 {
|
||||
parent
|
||||
.and_then(|p| self.active.get(&p))
|
||||
@@ -535,9 +430,6 @@ impl UiRenderState {
|
||||
let move_slot = Self::move_slot_for(old_move_slot, parent_move_slot, rsc);
|
||||
|
||||
let inherited_mask = mask;
|
||||
// `Painter::layer` is a cursor widgets advance while assigning
|
||||
// layers to their children. Retain the layer this widget itself was
|
||||
// entered on, not wherever that cursor finishes after `draw`.
|
||||
let inherited_layer = layer;
|
||||
let reuse_child_sizes = old_region.map_or([false; 2], |old| {
|
||||
[
|
||||
@@ -616,16 +508,10 @@ impl UiRenderState {
|
||||
id,
|
||||
} = painter;
|
||||
|
||||
// Whatever the draw did not claim is genuinely gone: this draw
|
||||
// wrote fewer primitives than the last one, or stopped matching
|
||||
// part way. Freeing it here rather than in `remove` is what lets
|
||||
// the draw in between reuse the slots -- see
|
||||
// `Primitives::recycle`.
|
||||
for h in recycle {
|
||||
self.free_primitive(&h);
|
||||
}
|
||||
|
||||
// add to active
|
||||
let active = ActiveData {
|
||||
id,
|
||||
region,
|
||||
@@ -643,7 +529,6 @@ impl UiRenderState {
|
||||
move_applied: Vec2::ZERO,
|
||||
};
|
||||
|
||||
// remove old children that weren't kept
|
||||
for c in &old_children {
|
||||
if !active.children.contains(c) {
|
||||
self.remove_rec(*c, rsc);
|
||||
@@ -655,9 +540,6 @@ impl UiRenderState {
|
||||
size
|
||||
}
|
||||
|
||||
/// This widget's slot in `move_offsets`: the one it already had if it
|
||||
/// is being redrawn, or a fresh one linked to its parent's.
|
||||
///
|
||||
/// A redraw **reuses the slot in place with its delta reset**, never
|
||||
/// reallocates: the geometry this draw is about to write is already
|
||||
/// at its correct absolute position, so a delta accumulated before it
|
||||
@@ -687,11 +569,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// O(1): write the delta for this widget's own slot in
|
||||
/// `move_offsets`. No primitive is touched and there is no recursion --
|
||||
/// every descendant's primitive references this slot transitively
|
||||
/// through the parent chain the shader walks (`resolve_move`), so it
|
||||
/// picks the new delta up for free. See LAYOUT.md section 2.
|
||||
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) {
|
||||
let Some(active) = self.active.get_mut(&id) else {
|
||||
return;
|
||||
@@ -772,22 +649,11 @@ impl UiRenderState {
|
||||
Some(size)
|
||||
}
|
||||
|
||||
/// Retires `id`'s primitives (unless `keep_primitives`, in which case
|
||||
/// they come back in the returned `ActiveData` for the redraw about to
|
||||
/// happen to recycle -- see `Painter::take_recycled`), drops the mask
|
||||
/// refs they held, and takes the widget out of `active`.
|
||||
///
|
||||
/// The handles stay in the returned `ActiveData` either way, freed or
|
||||
/// not: `remask_shape_users` below reads them, and so does the
|
||||
/// caller. **A caller that passed `keep_primitives: false` must not
|
||||
/// free them again** -- they name slots that may already have been
|
||||
/// handed out.
|
||||
///
|
||||
/// The mask refs are dropped either way: a recycled slot is rewritten
|
||||
/// with whatever mask the *new* draw is under, and that draw takes its
|
||||
/// own ref (`Painter::write_primitive`).
|
||||
///
|
||||
/// NOTE: instance textures are cleared and self.textures freed
|
||||
fn remove(
|
||||
&mut self,
|
||||
id: WidgetId,
|
||||
@@ -862,11 +728,6 @@ impl UiRenderState {
|
||||
active
|
||||
}
|
||||
|
||||
/// Retires one primitive: its arena slot and, if a layer's draw order
|
||||
/// names it, its position there. The two go together -- a slot handed
|
||||
/// out again while its old order entry still names it would be drawn
|
||||
/// twice -- which is why this is one function rather than two lines
|
||||
/// repeated at each call site.
|
||||
fn free_primitive(&mut self, h: &PrimitiveHandle) {
|
||||
self.primitives.free(h);
|
||||
if h.pos != NOT_DRAWN {
|
||||
@@ -874,26 +735,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mask whose shape primitive was just freed clips to a slot that
|
||||
/// now holds something else, so the widget that owns it is marked for
|
||||
/// redraw -- its own `set_mask` is the only thing that resolves the
|
||||
/// slot, and it is the same mechanism a dirty widget already goes
|
||||
/// through.
|
||||
///
|
||||
/// `own` is the mask belonging to the widget being removed and is
|
||||
/// skipped: this runs in the middle of that widget's own redraw,
|
||||
/// which sets its mask again on the way out, and a mark left on
|
||||
/// itself would redraw it every frame from then on. Skipping it is
|
||||
/// also what keeps the O(active) scan off the ordinary path -- a
|
||||
/// plain `.masked()` frees exactly its own shape, so `stale` is empty
|
||||
/// and this returns before touching `active`.
|
||||
///
|
||||
/// Both `Vec`s start empty and stay unallocated in that case, and
|
||||
/// membership is a linear scan of two lists that are a handful long
|
||||
/// (a widget's own primitives, and the live masks): this runs once
|
||||
/// per widget removed, which is once per dirty widget per frame, and
|
||||
/// a set built there would be an allocation on the phone's frame
|
||||
/// path in exchange for nothing at these sizes.
|
||||
fn remask_shape_users(
|
||||
active: &HashMap<WidgetId, ActiveData>,
|
||||
id: WidgetId,
|
||||
@@ -944,15 +785,9 @@ impl UiRenderState {
|
||||
|
||||
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
|
||||
while rsc.widgets().has_updates() {
|
||||
// Expand size dependencies before drawing anything. The parent
|
||||
// links are the retained widget tree already used by hit testing
|
||||
// and removal; only the direct-child dependency list is new.
|
||||
let pending: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect();
|
||||
for mut child in pending {
|
||||
for _ in 0..PARENT_CHAIN_LIMIT {
|
||||
// An exact hint is the child's current answer without a
|
||||
// draw. If both axes still match the retained size, no
|
||||
// parent can observe a size change from this mutation.
|
||||
if self.size_matches_hints(child, rsc) {
|
||||
break;
|
||||
}
|
||||
@@ -972,8 +807,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// A dirty ancestor draws its dirty descendants on the way down;
|
||||
// starting those descendants separately would duplicate work.
|
||||
let dirty: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect();
|
||||
let mut roots = Vec::new();
|
||||
for id in dirty {
|
||||
@@ -1041,20 +874,6 @@ impl UiRenderState {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
/// Primitive instances still bound for the GPU whose owner is no
|
||||
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
||||
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
||||
/// frame at whatever position it last had. `(slot, owner)` each --
|
||||
/// the arena knows which primitive, not which layer's draw order still
|
||||
/// names it.
|
||||
///
|
||||
/// Asserted empty at the end of every [`Self::update`], because this
|
||||
/// is exactly the shape of the duplicated transcript row on Iris's
|
||||
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
|
||||
/// `active` alone cannot see it, since the orphan's owner is very
|
||||
/// much alive -- it is the *earlier* set of primitives that got
|
||||
/// stranded when the widget was drawn a second time without the first
|
||||
/// draw being freed. O(primitives), debug builds only.
|
||||
pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> {
|
||||
let mut orphans = Vec::new();
|
||||
for (slot, owner, _) in self.primitives.live_instances() {
|
||||
@@ -1069,13 +888,6 @@ impl UiRenderState {
|
||||
orphans
|
||||
}
|
||||
|
||||
/// Whether every primitive still bound for the GPU is owned by a live
|
||||
/// widget, decided by counting rather than by walking: an orphan is a
|
||||
/// live instance no `ActiveData` names, so it can only ever make the
|
||||
/// live count exceed the owned one. O(active widgets) -- a few dozen --
|
||||
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
||||
/// transcript is tens of thousands and made a debug build on a phone
|
||||
/// too slow to finish a benchmark run.
|
||||
#[cfg(debug_assertions)]
|
||||
fn primitive_counts_agree(&self) -> bool {
|
||||
let live: usize = self.primitives.live_count();
|
||||
@@ -1083,9 +895,6 @@ impl UiRenderState {
|
||||
live == owned
|
||||
}
|
||||
|
||||
/// The message [`Self::update`]'s orphan assert prints -- built here
|
||||
/// rather than inline so the (allocating, O(primitives)) work only
|
||||
/// happens on the failing path.
|
||||
#[cfg(debug_assertions)]
|
||||
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
|
||||
let orphans = self.orphaned_primitives();
|
||||
@@ -1130,26 +939,12 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||
/// `id` and the root -- the CPU-side twin of the vertex shader's chain
|
||||
/// walk, over the same arena, so the two cannot disagree about where a
|
||||
/// widget is. O(chain depth), not O(primitives). See LAYOUT.md
|
||||
/// section 2b.
|
||||
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
||||
let active = self.active.get(&id.id())?;
|
||||
// The chain sum is what the shader adds to this widget's
|
||||
// *primitives*, which were written before any of those moves.
|
||||
// `region`, unlike them, has already been shifted by whatever
|
||||
// part of this widget's own slot `mov` put there -- see
|
||||
// `ActiveData::move_applied`, which is exactly that part.
|
||||
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
|
||||
Some(active.region.offset(UiVec2::abs(delta)))
|
||||
}
|
||||
|
||||
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||
/// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||
/// about where the chain ends.
|
||||
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||
let offsets = &rsc.ui().move_offsets;
|
||||
let mut delta = Vec2::ZERO;
|
||||
@@ -1162,10 +957,6 @@ impl UiRenderState {
|
||||
return delta;
|
||||
}
|
||||
at = Id::preset(entry.parent);
|
||||
// The chain itself, not just the fact that it was too long: a
|
||||
// cycle and a tree genuinely nested deeper than the shader can
|
||||
// follow are different faults with different fixes, and the
|
||||
// slot numbers are the only thing that tells them apart.
|
||||
debug_assert!(
|
||||
i + 1 < PARENT_CHAIN_LIMIT,
|
||||
"move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \
|
||||
@@ -1178,10 +969,6 @@ impl UiRenderState {
|
||||
delta
|
||||
}
|
||||
|
||||
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||
/// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||
/// rather than as a chain that merely stops. Only ever called from the
|
||||
/// failed assertion above.
|
||||
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut at = slot;
|
||||
@@ -1201,14 +988,6 @@ impl UiRenderState {
|
||||
parts.join(" -> ")
|
||||
}
|
||||
|
||||
/// One primitive's corners in window pixels -- the transliteration of
|
||||
/// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is
|
||||
/// the whole reason this is not `region.to_px()`: the shader floors
|
||||
/// each half separately before adding the move delta, and a hit test
|
||||
/// that skipped it would disagree with the pixels by up to one along
|
||||
/// each edge -- invisible in every test written against a whole-pixel
|
||||
/// layout and wrong on the phone, whose 2.55 density makes nothing
|
||||
/// land on a whole pixel.
|
||||
pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion {
|
||||
let inst = self.primitives.instance(slot);
|
||||
let delta = self.resolve_move_chain(inst.move_idx, rsc);
|
||||
@@ -1220,27 +999,10 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a mask's clip actually is on screen: the box of the
|
||||
/// primitive it references. Its *shape* within that box is
|
||||
/// [`Self::mask_coverage`]'s -- this is the bounding box, which is
|
||||
/// what a test asking "is the clip over the right part of the screen"
|
||||
/// wants and all a square-cornered mask has ever had.
|
||||
pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion {
|
||||
self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc)
|
||||
}
|
||||
|
||||
/// How much of the pixel at `pos` (window pixels) survives `mask` and
|
||||
/// every mask it nests inside: the referenced primitives' own
|
||||
/// coverage, multiplied along the chain. The CPU half of
|
||||
/// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same
|
||||
/// `rounded_rect_coverage` -- so a corner that cannot be tapped and a
|
||||
/// corner that is not drawn are the same corner (LAYOUT.md's "Masks
|
||||
/// with a shape", point 4).
|
||||
///
|
||||
/// A mask whose shape is not a rect covers everything, exactly as the
|
||||
/// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects
|
||||
/// those by name, so this is the unreachable half of the same
|
||||
/// agreement rather than a second policy.
|
||||
pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 {
|
||||
let mut coverage = 1.0;
|
||||
let mut at = mask;
|
||||
@@ -1264,18 +1026,10 @@ impl UiRenderState {
|
||||
coverage
|
||||
}
|
||||
|
||||
/// Whether `pos` is inside `mask` at all -- more than half covered,
|
||||
/// which is where the drawn edge is (`rounded_rect_coverage`'s doc).
|
||||
/// What a hit test asks.
|
||||
pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool {
|
||||
self.mask_coverage(mask, pos, rsc) > 0.5
|
||||
}
|
||||
|
||||
/// The first primitive `id`'s subtree wrote this frame, depth first
|
||||
/// in draw order -- what a mask pointed at a widget clips to
|
||||
/// (`Painter::set_mask_to_widget`). A widget that draws more than one
|
||||
/// (a bordered rect is one primitive; a card with a stripe is two)
|
||||
/// gives its first; a widget that wants another names it.
|
||||
pub fn first_primitive(&self, id: WidgetId) -> Option<u32> {
|
||||
let active = self.active.get(&id)?;
|
||||
if let Some(h) = active.primitives.first() {
|
||||
@@ -1292,13 +1046,8 @@ impl UiRenderState {
|
||||
Some(region.to_px(self.output_size))
|
||||
}
|
||||
|
||||
/// redraws a widget that's currently active (drawn)
|
||||
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||
// An ancestor is drawing this widget right now, and that draw is
|
||||
// about to write fresh primitives for it. Drawing it a second time
|
||||
// here would leave one of the two copies on screen with nothing
|
||||
// owning it -- see `draw_started`'s own doc.
|
||||
if self.draw_started.contains(&id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ impl<T, I: IdNum> Default for Arena<T, I> {
|
||||
pub struct TrackedArena<T, I> {
|
||||
inner: Arena<T, I>,
|
||||
refs: Vec<u32>,
|
||||
/// Which entries changed since the last upload. Was a `bool`, so one
|
||||
/// widget getting a move offset re-uploaded every other widget's.
|
||||
pub dirty: Dirty,
|
||||
}
|
||||
|
||||
@@ -73,17 +71,11 @@ impl<T, I: IdNum> TrackedArena<T, I> {
|
||||
self.refs[i.idx()] += 1;
|
||||
}
|
||||
|
||||
/// Mutable access to an existing entry, for the rare case (the move
|
||||
/// offset chain) where an already-allocated slot is updated in place
|
||||
/// rather than replaced. Marks the arena changed so the GPU copy is
|
||||
/// re-uploaded.
|
||||
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
|
||||
self.dirty.mark(id.idx());
|
||||
&mut self.inner.data[id.idx()]
|
||||
}
|
||||
|
||||
/// The entries and the dirty set together -- see
|
||||
/// `PrimitiveVec::for_upload`.
|
||||
pub fn for_upload(&mut self) -> (&[T], &mut Dirty) {
|
||||
(&self.inner.data, &mut self.dirty)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
//! Which entries of a GPU-bound array changed since the last upload.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// A bitset of dirty entries, coalesced into a handful of ranges when it
|
||||
/// is time to upload.
|
||||
///
|
||||
/// **Why a bitset** rather than the two obvious alternatives, both of
|
||||
/// which were measured against the bench fixture before this was written
|
||||
/// (`scripts/rigs/ui-profile`'s `arena_churn`). A `min..max` span is far
|
||||
/// too coarse: a frame's changes land in 5-20 runs scattered across the
|
||||
/// whole arena, so the span is very nearly the whole buffer. A `Vec` of
|
||||
/// touched indices is too expensive to *write*: a streaming frame marks
|
||||
/// several thousand entries, which would mean an allocation and a sort
|
||||
/// per frame. Marking a bit is O(1), allocation-free and idempotent, and
|
||||
/// the scan that reads it back is one word per 64 entries.
|
||||
#[derive(Default)]
|
||||
pub struct Dirty {
|
||||
words: Vec<u64>,
|
||||
@@ -26,7 +12,6 @@ pub struct Dirty {
|
||||
}
|
||||
|
||||
impl Dirty {
|
||||
/// Nothing uploaded yet, so nothing may be assumed about the buffer.
|
||||
pub fn new_all() -> Self {
|
||||
Self {
|
||||
words: Vec::new(),
|
||||
@@ -76,14 +61,6 @@ impl Dirty {
|
||||
!self.all && self.words.iter().all(|w| *w == 0)
|
||||
}
|
||||
|
||||
/// The ranges to upload, in ascending order, merging two runs
|
||||
/// separated by a gap of fewer than `gap` entries.
|
||||
///
|
||||
/// Merging trades bytes for `write_buffer` calls, and the fixture
|
||||
/// says the trade is very cheap in one direction: over a fling, a
|
||||
/// 1 KiB gap costs 0.1% more bytes than merging nothing at all and
|
||||
/// halves the worst-case call count (23 to 13). Past that it stops
|
||||
/// paying -- 4 KiB is +2% bytes for two fewer calls.
|
||||
pub fn ranges(&self, len: usize, gap: usize) -> Vec<Range<usize>> {
|
||||
if self.all {
|
||||
return Vec::from_iter((len > 0).then_some(0..len));
|
||||
@@ -93,15 +70,12 @@ impl Dirty {
|
||||
let mut bits = *word;
|
||||
while bits != 0 {
|
||||
let start = w * 64 + bits.trailing_zeros() as usize;
|
||||
// The run of set bits starting here, within this word.
|
||||
let run = (bits >> (start - w * 64)).trailing_ones() as usize;
|
||||
let end = (start + run).min(len);
|
||||
if start >= len {
|
||||
break;
|
||||
}
|
||||
match ranges.last_mut() {
|
||||
// `start - last.end` is the gap; equal ends means
|
||||
// adjacent, which always merges.
|
||||
Some(last) if start - last.end <= gap => last.end = end,
|
||||
_ => ranges.push(start..end),
|
||||
}
|
||||
@@ -158,8 +132,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ranges_stop_at_the_length() {
|
||||
// Entries marked and then dropped by a shrink must not be
|
||||
// uploaded past the end of what the caller is writing.
|
||||
assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]);
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user