Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

+3 -4
View File
@@ -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
View File
@@ -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"]
@@ -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) {
@@ -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;
-44
View File
@@ -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)
-62
View File
@@ -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 -8
View File
@@ -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 -52
View File
@@ -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()) {
+2 -330
View File
@@ -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);
-46
View File
@@ -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)
}
-51
View File
@@ -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
-46
View File
@@ -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
-67
View File
@@ -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");
-95
View File
@@ -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),
}
}
-26
View File
@@ -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);
-78
View File
@@ -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");
}
-63
View File
@@ -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);
}
-49
View File
@@ -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();
-26
View File
@@ -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(""), "");
}
-17
View File
@@ -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
-62
View File
@@ -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;
-48
View File
@@ -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 = [
-114
View File
@@ -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;
-84
View File
@@ -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");
-4
View File
@@ -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;
-13
View File
@@ -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 {
-19
View File
@@ -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
}
}
-41
View File
@@ -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);
-58
View File
@@ -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()]
-226
View File
@@ -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]);
-183
View File
@@ -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
-109
View File
@@ -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();
-94
View File
@@ -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,
-13
View File
@@ -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)
-4
View File
@@ -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;
-6
View File
@@ -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();
-18
View File
@@ -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")]
-53
View File
@@ -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())
}
-36
View File
@@ -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>,
-31
View File
@@ -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
-9
View File
@@ -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(
-28
View File
@@ -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(
-59
View File
@@ -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))
-40
View File
@@ -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();
-160
View File
@@ -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";
-301
View File
@@ -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);
}
}
-172
View File
@@ -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));
-152
View File
@@ -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));
-45
View File
@@ -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);
}
-250
View File
@@ -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);
-45
View File
@@ -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,
-39
View File
@@ -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)
-56
View File
@@ -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(&para.key());
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
.extent(para_key)
-48
View File
@@ -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"),
-94
View File
@@ -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
-91
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-5
View File
@@ -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
-129
View File
@@ -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);
-2
View File
@@ -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.]+)")
-10
View File
@@ -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
+1 -5
View File
@@ -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 }
-2
View File
@@ -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
-1
View File
@@ -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>>>,
}
-13
View File
@@ -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>> {
-34
View File
@@ -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}";
+4 -6
View File
@@ -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)
}
}
-18
View File
@@ -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
View File
@@ -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> {
-9
View File
@@ -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
View File
@@ -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();
-46
View File
@@ -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();
-78
View File
@@ -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,
-54
View File
@@ -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 --
-247
View File
@@ -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)),
-137
View File
@@ -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()
}
-137
View File
@@ -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();
-27
View File
@@ -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;
+17 -69
View File
@@ -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;
}
-62
View File
@@ -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)
}
-26
View File
@@ -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)]
-27
View File
@@ -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)
}
-11
View File
@@ -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,
}
-21
View File
@@ -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),
-84
View File
@@ -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
+1 -252
View File
@@ -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;
}
-8
View File
@@ -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)
}
-28
View File
@@ -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