Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 | ||
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 |
No files matched your search
@@ -19,6 +19,21 @@ child process, translated into one common event model.** A new session type
|
|||||||
is a new driver — never a session-type branch in shared code (routes,
|
is a new driver — never a session-type branch in shared code (routes,
|
||||||
transcript, app screens).
|
transcript, app screens).
|
||||||
|
|
||||||
|
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||||||
|
and a planned desktop app share almost all of their code.** Screens, widgets,
|
||||||
|
folding, paging, config and the network client live in the shared crates
|
||||||
|
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and
|
||||||
|
`desktop-app` are thin entry points that own only what the platform forces
|
||||||
|
(JNI and the IME on one side, winit and argv on the other). The two
|
||||||
|
*layouts* will differ, to suit a phone's screen and a finger against a
|
||||||
|
desktop's screen and a mouse -- but the widgets a layout is made of (a
|
||||||
|
button, a text field, a list, a card) and the styling (colours, spacing,
|
||||||
|
type) are one implementation with no per-platform copy. Anything that could
|
||||||
|
work on both goes in a shared crate the first time it is written, and a
|
||||||
|
platform crate growing a widget or a colour is a defect to move, not a
|
||||||
|
convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the
|
||||||
|
details.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||||
@@ -268,6 +283,20 @@ Each exists because something was invisible without it.
|
|||||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
||||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
report -- written so the P0 build/install/tap/read-report cycle stops
|
||||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
being retyped by hand each time (docs/RUST.md's P0 box).
|
||||||
|
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
|
||||||
|
the commands and what each cannot answer): test at the cheapest one
|
||||||
|
that can answer the question. `cargo test -p transcript-fixture` runs
|
||||||
|
the real transcript screen over the bench fixture with **no window, no
|
||||||
|
compositor and no GPU** (`iris::harness`), on a clock the test owns and
|
||||||
|
a gesture replayed from a `t_ms action x y` file under
|
||||||
|
`iris/transcript-fixture/touch/` -- which is how the batched 120Hz
|
||||||
|
flick a finger actually makes is testable at all, since a `ui-trace`
|
||||||
|
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
|
||||||
|
--shot …` opens the same screen in a window at the phone's own size and
|
||||||
|
density for looking at, and `--replay FILE` drives the same recording
|
||||||
|
into it. The emulator is for JNI, the IME, insets, the surface
|
||||||
|
lifecycle and one verification run before a build goes to the phone --
|
||||||
|
not for iterating on layout.
|
||||||
|
|
||||||
### Driving the UI
|
### Driving the UI
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ version = "0.23.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -77,6 +83,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -206,6 +213,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -490,6 +506,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -783,12 +818,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -173,6 +174,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -425,6 +435,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -661,12 +690,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
event-model = { path = "../event-model" }
|
event-model = { path = "../event-model" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
# "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
|
||||||
|
# bytes the server sent, not this crate's own re-serialization of a parsed
|
||||||
|
# `Value`, so a cached line and a live SSE frame for the same event agree
|
||||||
|
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
|
||||||
|
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||||
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
||||||
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
||||||
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||||
@@ -27,6 +32,12 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
|
|||||||
# no need of an async runtime, and RUST.md's brief for this port is
|
# no need of an async runtime, and RUST.md's brief for this port is
|
||||||
# "lightweight" throughout.
|
# "lightweight" throughout.
|
||||||
ureq = { version = "3", features = ["json"] }
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
# The markdown block split (`markdown_blocks`), which has to agree with the
|
||||||
|
# renderer in `iris/transcript-ui` about where a block begins -- so it is
|
||||||
|
# the same parser at the same version, rather than a hand-written splitter
|
||||||
|
# that would drift from it.
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -116,6 +117,14 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
Self { transport }
|
Self { transport }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The transport underneath, for a caller that needs the raw SSE
|
||||||
|
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||||
|
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||||
|
/// is the one that does.
|
||||||
|
pub fn transport(&self) -> &T {
|
||||||
|
&self.transport
|
||||||
|
}
|
||||||
|
|
||||||
fn json_request<R: for<'de> Deserialize<'de>>(
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -266,6 +275,61 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
) -> Result<Vec<Value>, ApiError> {
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
|
self.json_request(
|
||||||
|
"GET",
|
||||||
|
&transcript_path(session_id, before, limit, coalesce, None),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of transcript history, each line handed back paired with the
|
||||||
|
/// exact text it came from, and bounded below by `after` -- the shape
|
||||||
|
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
||||||
|
/// fetched in the transcript cache without a second round trip to fetch
|
||||||
|
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||||
|
///
|
||||||
|
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||||
|
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||||
|
/// sent (key order and float literal included) rather than this
|
||||||
|
/// crate's own idea of how to write them back out -- the cache and a
|
||||||
|
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||||
|
/// exactly what caught the `serde_json` float-rounding bug this
|
||||||
|
/// project's `AGENTS.md` records.
|
||||||
|
pub fn fetch_transcript_lines(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: u32,
|
||||||
|
coalesce: bool,
|
||||||
|
after: Option<u64>,
|
||||||
|
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||||
|
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||||
|
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||||
|
raw.into_iter()
|
||||||
|
.map(|value| {
|
||||||
|
let line = value.get().to_string();
|
||||||
|
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||||
|
message: format!(
|
||||||
|
"the server sent a transcript line this build couldn't parse: {e}"
|
||||||
|
),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
Ok((line, event))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||||
|
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||||
|
/// parameter is written rather than keeping two copies to drift.
|
||||||
|
fn transcript_path(
|
||||||
|
session_id: &str,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: u32,
|
||||||
|
coalesce: bool,
|
||||||
|
after: Option<u64>,
|
||||||
|
) -> String {
|
||||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||||
if let Some(before) = before {
|
if let Some(before) = before {
|
||||||
path.push_str(&format!("&before={before}"));
|
path.push_str(&format!("&before={before}"));
|
||||||
@@ -273,8 +337,10 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
if coalesce {
|
if coalesce {
|
||||||
path.push_str("&coalesce=true");
|
path.push_str("&coalesce=true");
|
||||||
}
|
}
|
||||||
self.json_request("GET", &path, None)
|
if let Some(after) = after {
|
||||||
|
path.push_str(&format!("&after={after}"));
|
||||||
}
|
}
|
||||||
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||||
|
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||||
|
//!
|
||||||
|
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||||
|
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||||
|
//! bar, and nothing in this crate needs it yet.
|
||||||
|
|
||||||
|
/// A span of milliseconds, written the way somebody reads it.
|
||||||
|
///
|
||||||
|
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||||
|
/// minutes. The rule has two halves, because a short span and a long one
|
||||||
|
/// are read for different things. Under a minute the question is "roughly
|
||||||
|
/// how long", so only the largest unit is shown and a fraction carries the
|
||||||
|
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||||
|
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||||
|
/// units are left out rather than written as zero.
|
||||||
|
///
|
||||||
|
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||||
|
/// is measured in milliseconds.
|
||||||
|
pub fn format_millis(ms: i64) -> String {
|
||||||
|
if ms < 0 {
|
||||||
|
return format!("-{}", format_millis(-ms));
|
||||||
|
}
|
||||||
|
if ms < 1000 {
|
||||||
|
return format!("{ms}ms");
|
||||||
|
}
|
||||||
|
if ms < 60_000 {
|
||||||
|
let tenths = (ms + 50) / 100;
|
||||||
|
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||||
|
return if rest == 0 {
|
||||||
|
format!("{whole}s")
|
||||||
|
} else {
|
||||||
|
format!("{whole}.{rest}s")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let seconds = ms / 1000;
|
||||||
|
[
|
||||||
|
("d", seconds / 86_400),
|
||||||
|
("h", seconds / 3600 % 24),
|
||||||
|
("m", seconds / 60 % 60),
|
||||||
|
("s", seconds % 60),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, n)| *n > 0)
|
||||||
|
.map(|(unit, n)| format!("{n}{unit}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as a span when it is a whole number of milliseconds, and
|
||||||
|
/// unchanged when it is not.
|
||||||
|
pub fn format_millis_text(text: &str) -> String {
|
||||||
|
match text.trim().parse::<i64>() {
|
||||||
|
Ok(ms) => format_millis(ms),
|
||||||
|
Err(_) => text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The two ways a span of time is written here, and the rule each of
|
||||||
|
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||||
|
/// both are read off a screen to make a decision, so what matters is
|
||||||
|
/// that the shortest form that answers the question is what appears.
|
||||||
|
#[test]
|
||||||
|
fn under_a_minute_is_the_largest_unit_alone() {
|
||||||
|
assert_eq!(format_millis(30), "30ms");
|
||||||
|
assert_eq!(format_millis(999), "999ms");
|
||||||
|
assert_eq!(format_millis(1000), "1s");
|
||||||
|
assert_eq!(format_millis(2500), "2.5s");
|
||||||
|
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||||
|
// half than two and four.
|
||||||
|
assert_eq!(format_millis(2460), "2.5s");
|
||||||
|
assert_eq!(format_millis(59_900), "59.9s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||||
|
// The figure this rule was written for: a tool timeout, which
|
||||||
|
// arrives as milliseconds and is unreadable as 480000.
|
||||||
|
assert_eq!(format_millis(480_000), "8m");
|
||||||
|
assert_eq!(format_millis(60_000), "1m");
|
||||||
|
assert_eq!(format_millis(90_000), "1m 30s");
|
||||||
|
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||||
|
// Empty units are left out rather than written as zero: the labels
|
||||||
|
// say which is which, and "5d 0h 4m" is only longer.
|
||||||
|
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||||
|
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||||
|
// A timeout a tool expressed some other way is its own words,
|
||||||
|
// passed through rather than guessed at.
|
||||||
|
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||||
|
assert_eq!(format_millis_text(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,15 @@
|
|||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod tool_summary;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
|
pub mod transcript_source;
|
||||||
|
|
||||||
pub use event_model::*;
|
pub use event_model::*;
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
//! Split a markdown message into its top-level **blocks** -- one
|
||||||
|
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||||
|
//! byte slice of the original source.
|
||||||
|
//!
|
||||||
|
//! This exists for streaming. A transcript row used to be one text widget
|
||||||
|
//! holding the whole message, so a single streamed delta re-shaped every
|
||||||
|
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||||
|
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||||
|
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||||
|
//! block now, and a delta that lands in the last block leaves every
|
||||||
|
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
|
||||||
|
//! what that rejected and why the split lives here rather than in the UI
|
||||||
|
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||||
|
//! keeping it here means iris stays a text renderer that knows nothing
|
||||||
|
//! about markdown.
|
||||||
|
//!
|
||||||
|
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||||
|
//! renderer's own job, per block -- this deliberately does not build a
|
||||||
|
//! full AST, because nothing needs one yet.
|
||||||
|
//!
|
||||||
|
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||||
|
//!
|
||||||
|
//! It nearly always does, which is what makes the fast path worth having,
|
||||||
|
//! but markdown has no such rule: appending a "```" line can turn text
|
||||||
|
//! that was three paragraphs into one fenced block, and appending "---"
|
||||||
|
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||||
|
//! taking the O(last block) path **must compare the prefix it is about to
|
||||||
|
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||||
|
//! it is cheap next to laying the text out again.
|
||||||
|
|
||||||
|
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||||
|
|
||||||
|
/// What a block is, for a renderer that wants to style or space blocks
|
||||||
|
/// differently. `Other` is deliberately present rather than a panic or a
|
||||||
|
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||||
|
/// list and more get added, and a renderer treating an unknown one as
|
||||||
|
/// prose is right, but it should be able to *tell* that is what it is
|
||||||
|
/// doing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BlockKind {
|
||||||
|
Paragraph,
|
||||||
|
Heading,
|
||||||
|
/// A fenced or indented code block.
|
||||||
|
Code,
|
||||||
|
List,
|
||||||
|
Table,
|
||||||
|
Quote,
|
||||||
|
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||||
|
/// distinguished treatment here.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One top-level block: its kind and the exact source that produced it.
|
||||||
|
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||||
|
/// two splits of the same prefix compare equal even when one of them had a
|
||||||
|
/// delta arriving after it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Block {
|
||||||
|
pub kind: BlockKind,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_of(tag: &Tag) -> BlockKind {
|
||||||
|
match tag {
|
||||||
|
Tag::Paragraph => BlockKind::Paragraph,
|
||||||
|
Tag::Heading { .. } => BlockKind::Heading,
|
||||||
|
Tag::CodeBlock(_) => BlockKind::Code,
|
||||||
|
Tag::List(_) => BlockKind::List,
|
||||||
|
Tag::Table(_) => BlockKind::Table,
|
||||||
|
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||||
|
_ => BlockKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options() -> Options {
|
||||||
|
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||||
|
// boundary here and the styling there cannot disagree about what the
|
||||||
|
// source means.
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||||
|
/// whitespace-only input gives no blocks; text the parser does not put
|
||||||
|
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||||
|
/// as `Other`, rather than being dropped.
|
||||||
|
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||||
|
let mut out: Vec<Block> = Vec::new();
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut kind = BlockKind::Other;
|
||||||
|
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => {
|
||||||
|
if depth == 0 {
|
||||||
|
kind = kind_of(&tag);
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
Event::End(_) => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
push(&mut out, kind, &src[range]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A top-level event that is not part of any block -- a
|
||||||
|
// thematic break, a block of raw HTML. Inside one, it is the
|
||||||
|
// enclosing block's business and this does nothing.
|
||||||
|
_ => {
|
||||||
|
if depth == 0 {
|
||||||
|
push(&mut out, BlockKind::Other, &src[range]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||||
|
let source = source.trim_end();
|
||||||
|
if source.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push(Block {
|
||||||
|
kind,
|
||||||
|
source: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||||
|
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||||
|
/// this is a comparison rather than an assumption.
|
||||||
|
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||||
|
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||||
|
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_splits_into_its_top_level_blocks() {
|
||||||
|
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||||
|
assert_eq!(
|
||||||
|
kinds(src),
|
||||||
|
vec![
|
||||||
|
BlockKind::Heading,
|
||||||
|
BlockKind::Paragraph,
|
||||||
|
BlockKind::Code,
|
||||||
|
BlockKind::List
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks[1].source, "First para.");
|
||||||
|
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_input_has_no_blocks() {
|
||||||
|
assert!(split_blocks("").is_empty());
|
||||||
|
assert!(split_blocks(" \n\n ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the streaming fast path rests on, in its ordinary
|
||||||
|
/// shape: a delta landing in the last paragraph must leave every
|
||||||
|
/// earlier block byte-identical.
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||||
|
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||||
|
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||||
|
assert_eq!(common_prefix(&before, &after), 2);
|
||||||
|
assert_eq!(before.len(), 3);
|
||||||
|
assert_eq!(after.len(), 3);
|
||||||
|
assert_ne!(before[2], after[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that starts a *new* block keeps every old block, including
|
||||||
|
/// the one that was last -- so the fast path appends rather than
|
||||||
|
/// replacing.
|
||||||
|
#[test]
|
||||||
|
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||||
|
let before = split_blocks("First para.\n\nSecond para.");
|
||||||
|
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||||
|
assert_eq!(common_prefix(&before, &after), 2);
|
||||||
|
assert_eq!(after.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code fence arrives one delta at a time and is unterminated for
|
||||||
|
/// most of its life. It must still be *one* block the whole way, or
|
||||||
|
/// every delta would re-split the message into a different number of
|
||||||
|
/// pieces.
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||||
|
for src in [
|
||||||
|
"Here:\n\n```rust\n",
|
||||||
|
"Here:\n\n```rust\nfn main() {\n",
|
||||||
|
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
kinds(src),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||||
|
"{src:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the fast path had no reason to touch, and the reason
|
||||||
|
/// `common_prefix` is a comparison rather than an assumption:
|
||||||
|
/// appending can rewrite what came before. `---` under a paragraph
|
||||||
|
/// turns that paragraph into a setext heading, so the block that was
|
||||||
|
/// already laid out is not the block it is now.
|
||||||
|
#[test]
|
||||||
|
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||||
|
let before = split_blocks("Not a heading\n\nsecond");
|
||||||
|
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||||
|
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||||
|
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||||
|
assert_eq!(
|
||||||
|
common_prefix(&before, &after),
|
||||||
|
1,
|
||||||
|
"the rewritten block must not be reported as keepable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thematic_break_is_its_own_block() {
|
||||||
|
assert_eq!(
|
||||||
|
kinds("one\n\n---\n\ntwo"),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shapes a real transcript actually contains, each checked for
|
||||||
|
/// the one property the streaming fast path needs: the *number* of
|
||||||
|
/// blocks and every earlier block's source stay put while the message
|
||||||
|
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||||
|
/// list and a table are all places where a naive line-based split
|
||||||
|
/// would break the message into more pieces than there are blocks.
|
||||||
|
#[test]
|
||||||
|
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||||
|
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||||
|
assert_eq!(
|
||||||
|
kinds(fence_with_blanks),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||||
|
"a blank line inside a fence is not a block boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("```\n---\n```"),
|
||||||
|
vec![BlockKind::Code],
|
||||||
|
"a thematic break inside a fence is code, not a break"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("- a\n - a1\n - a2\n- b"),
|
||||||
|
vec![BlockKind::List],
|
||||||
|
"a nested list is one top-level block"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("## Heading\n```sh\nls\n```"),
|
||||||
|
vec![BlockKind::Heading, BlockKind::Code],
|
||||||
|
"a fence directly under a heading, with no blank line"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||||
|
vec![BlockKind::Table]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds("> quoted\n> more\n\nplain"),
|
||||||
|
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apply_delta`'s precondition, stated as the property rather than
|
||||||
|
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||||
|
/// the blocks before the last one must be exactly the blocks the
|
||||||
|
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||||
|
/// above), `common_prefix` has to *say* so -- which is what the
|
||||||
|
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||||
|
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||||
|
/// would keep a widget whose text is no longer what it holds.
|
||||||
|
#[test]
|
||||||
|
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||||
|
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||||
|
// Every character boundary, so a delta landing mid-word and one
|
||||||
|
// landing exactly on a fence's closing backtick are both covered.
|
||||||
|
let mut prev = Vec::new();
|
||||||
|
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||||
|
let now = split_blocks(&full[..end]);
|
||||||
|
let common = common_prefix(&prev, &now);
|
||||||
|
assert!(
|
||||||
|
prev.is_empty() || common + 1 >= prev.len(),
|
||||||
|
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||||
|
prev.len()
|
||||||
|
);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half a growing message cannot show: a fence that never closes.
|
||||||
|
/// The stream ends there and the block must still be the code block
|
||||||
|
/// it has been all along, not re-split into paragraphs.
|
||||||
|
#[test]
|
||||||
|
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||||
|
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(
|
||||||
|
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||||
|
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||||
|
);
|
||||||
|
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that closes a fence changes the *last* block only, so the
|
||||||
|
/// fast path takes it -- the case the module doc says is the reason
|
||||||
|
/// `common_prefix` is a comparison.
|
||||||
|
#[test]
|
||||||
|
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||||
|
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||||
|
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||||
|
assert_eq!(before.len(), after.len());
|
||||||
|
assert_eq!(common_prefix(&before, &after), 1);
|
||||||
|
assert_ne!(before[1], after[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! A tool call's input, read rather than dumped -- the port of
|
||||||
|
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||||
|
//! card's one-line summary and the expanded card's key/value list are
|
||||||
|
//! derived from.
|
||||||
|
//!
|
||||||
|
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||||
|
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||||
|
//! line they care about. So the fields that carry the meaning are pulled
|
||||||
|
//! out, and anything left over is still shown, because dropping a field
|
||||||
|
//! would be claiming the tool has no other input when it might.
|
||||||
|
//!
|
||||||
|
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||||
|
//! of this crate exists: the derivation is the same on a phone and on a
|
||||||
|
//! desktop, and it is testable without a renderer.
|
||||||
|
|
||||||
|
use crate::durations::format_millis_text;
|
||||||
|
use crate::highlight::Language;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A tool call's input, split into the parts a card draws separately.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ToolInput {
|
||||||
|
/// The thing that will actually be run or read, if this tool has one.
|
||||||
|
pub subject: Option<String>,
|
||||||
|
/// The language [`ToolInput::subject`] is written in, for
|
||||||
|
/// highlighting.
|
||||||
|
pub language: Option<Language>,
|
||||||
|
/// The tool's own one-line summary, when it wrote one.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// How long the call may take, in the largest units it fits. Shown
|
||||||
|
/// apart because it is a limit on the call rather than part of what
|
||||||
|
/// the call does.
|
||||||
|
pub timeout: Option<String>,
|
||||||
|
/// Everything else, as `name: value` lines. Never dropped.
|
||||||
|
pub rest: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolInput {
|
||||||
|
/// The one line to show when there is only room for one: what this
|
||||||
|
/// call is for.
|
||||||
|
pub fn title(&self) -> Option<&str> {
|
||||||
|
self.description
|
||||||
|
.as_deref()
|
||||||
|
.or(self.subject.as_deref())
|
||||||
|
// A subject that is only whitespace would draw as an empty
|
||||||
|
// summary line, which reads as a tool with nothing to say
|
||||||
|
// rather than as one whose subject was blank.
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which field of which tool is the subject.
|
||||||
|
///
|
||||||
|
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||||
|
/// shape stops any of them from being the special case that gets its own
|
||||||
|
/// code path. Unknown tools fall through to "no subject, everything is
|
||||||
|
/// rest".
|
||||||
|
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||||
|
("Bash", "command", Some(Language::Shell)),
|
||||||
|
("Read", "file_path", None),
|
||||||
|
("Write", "file_path", None),
|
||||||
|
("Edit", "file_path", None),
|
||||||
|
("Glob", "pattern", None),
|
||||||
|
("Grep", "pattern", None),
|
||||||
|
("WebFetch", "url", None),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Fields that are the tool's own prose about itself rather than input to
|
||||||
|
/// it.
|
||||||
|
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||||
|
|
||||||
|
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||||
|
/// string is its own characters, anything else is its JSON form.
|
||||||
|
///
|
||||||
|
/// One function rather than two, because the same coercion decides both
|
||||||
|
/// what a subject reads as and what a leftover field's value reads as, and
|
||||||
|
/// two copies would eventually disagree about a number.
|
||||||
|
fn as_text(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||||
|
let text = as_text(value?);
|
||||||
|
(!text.trim().is_empty()).then_some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||||
|
///
|
||||||
|
/// Input that is not a JSON object -- older transcripts and some tools
|
||||||
|
/// send a bare string -- is still the input, so it is still shown, as the
|
||||||
|
/// whole of `rest`.
|
||||||
|
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||||
|
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||||
|
return ToolInput {
|
||||||
|
rest: match input.trim().is_empty() {
|
||||||
|
true => Vec::new(),
|
||||||
|
false => vec![input.to_string()],
|
||||||
|
},
|
||||||
|
..ToolInput::default()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parse_object(tool, &json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||||
|
let (subject_key, language) = SUBJECTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, ..)| *name == tool)
|
||||||
|
.map(|(_, key, language)| (Some(*key), *language))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||||
|
let description = DESCRIPTIONS
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| non_blank(json.get(*key)));
|
||||||
|
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||||
|
|
||||||
|
// Sorted, so the leftovers are in the same order every time this call
|
||||||
|
// is drawn rather than in whatever order the JSON happened to arrive
|
||||||
|
// in. A field is left out only when it is already drawn somewhere
|
||||||
|
// else on the card.
|
||||||
|
let mut keys: Vec<&String> = json
|
||||||
|
.keys()
|
||||||
|
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||||
|
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||||
|
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||||
|
.collect();
|
||||||
|
keys.sort();
|
||||||
|
let rest = keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ToolInput {
|
||||||
|
subject,
|
||||||
|
language,
|
||||||
|
description,
|
||||||
|
timeout,
|
||||||
|
rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_tool_in_the_table_has_its_own_subject() {
|
||||||
|
// One assertion per row of `SUBJECTS`, because the table is the
|
||||||
|
// whole of the rule and a row lost in an edit would otherwise
|
||||||
|
// only show up as a card with no summary line.
|
||||||
|
let cases = [
|
||||||
|
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||||
|
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||||
|
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||||
|
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||||
|
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||||
|
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||||
|
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||||
|
];
|
||||||
|
for (tool, input, expected) in cases {
|
||||||
|
let parsed = parse_tool_input(tool, input);
|
||||||
|
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||||
|
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||||
|
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||||
|
Some(Language::Shell),
|
||||||
|
"a Bash command is shell, and is the one row that names a language"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||||
|
// The description wins over the subject: it is the tool's own
|
||||||
|
// prose about what this call is for, which is what a reader
|
||||||
|
// scanning a collapsed run is looking for.
|
||||||
|
let parsed = parse_tool_input(
|
||||||
|
"Bash",
|
||||||
|
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||||
|
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||||
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||||
|
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||||
|
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||||
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||||
|
// The half the "never dropped" promise is about: a tool this
|
||||||
|
// build has never heard of has no subject, so *everything* is
|
||||||
|
// rest -- and a known tool's extra fields are too.
|
||||||
|
let parsed = parse_tool_input(
|
||||||
|
"Edit",
|
||||||
|
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec![
|
||||||
|
"new_string: y".to_string(),
|
||||||
|
"old_string: x".to_string(),
|
||||||
|
"replace_all: true".to_string(),
|
||||||
|
],
|
||||||
|
"sorted, and a non-string value written as JSON"
|
||||||
|
);
|
||||||
|
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||||
|
assert_eq!(unknown.subject, None);
|
||||||
|
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_that_is_not_an_object_is_still_the_input() {
|
||||||
|
// Older transcripts and some tools send a bare string; a card
|
||||||
|
// that dropped it would claim the call had no input at all.
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_input("Bash", "just a string").rest,
|
||||||
|
vec!["just a string".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||||
|
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||||
|
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||||
|
assert_eq!(parsed.subject, None);
|
||||||
|
assert_eq!(parsed.title(), None);
|
||||||
|
// Not dropped just because it was blank -- it is still a field
|
||||||
|
// the call carried.
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,6 +361,10 @@ impl SessionCache {
|
|||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
debug_assert!(
|
||||||
|
lines.iter().all(|l| !l.contains('\n')),
|
||||||
|
"a stored page's lines must each be one line"
|
||||||
|
);
|
||||||
fs::create_dir_all(&this.dir)?;
|
fs::create_dir_all(&this.dir)?;
|
||||||
let kind = if rows { "rows" } else { "raw" };
|
let kind = if rows { "rows" } else { "raw" };
|
||||||
let mut content = lines.join("\n");
|
let mut content = lines.join("\n");
|
||||||
@@ -389,8 +393,16 @@ impl SessionCache {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Written as it arrived. A newline inside it would split one
|
// Written as it arrived. A newline inside it would split one
|
||||||
// event into two unreadable halves, but neither source can
|
// event into two unreadable halves. No source here can produce
|
||||||
// produce one.
|
// 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}"
|
||||||
|
);
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
writer.write_all(line.as_bytes())?;
|
writer.write_all(line.as_bytes())?;
|
||||||
writer.write_all(b"\n")?;
|
writer.write_all(b"\n")?;
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ pub enum TranscriptItem {
|
|||||||
input: String,
|
input: String,
|
||||||
output: String,
|
output: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
/// Whether the result that arrived said the call failed
|
||||||
|
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||||
|
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||||
|
/// pair, so the two cannot be combined wrongly at a call site.
|
||||||
|
failed: bool,
|
||||||
asks: Vec<QuestionCard>,
|
asks: Vec<QuestionCard>,
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -294,6 +299,201 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
|||||||
out
|
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
|
||||||
|
/// -- correctly, because a call that renders as nothing is indistinguishable
|
||||||
|
/// from one that never happened. When the older page arrives it brings the
|
||||||
|
/// real `ToolStart`, and concatenating the two lists left *both*: the same
|
||||||
|
/// call twice.
|
||||||
|
///
|
||||||
|
/// Merged by the call's own id rather than by position, because position is
|
||||||
|
/// 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())))
|
||||||
|
.filter(|(id, _)| started_earlier.contains(id.as_str()))
|
||||||
|
.collect();
|
||||||
|
let healed: Vec<TranscriptItem> = older
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| match row {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
asks: row_asks,
|
||||||
|
images: row_images,
|
||||||
|
..
|
||||||
|
} if ended_later.contains_key(id.as_str()) => {
|
||||||
|
let &TranscriptItem::ToolRun {
|
||||||
|
ref output,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
asks: ref half_asks,
|
||||||
|
images: ref half_images,
|
||||||
|
..
|
||||||
|
} = &ended_later[id.as_str()]
|
||||||
|
else {
|
||||||
|
unreachable!("filtered to ToolRun above");
|
||||||
|
};
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let kept: Vec<TranscriptItem> = newer
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| match item.as_tool_run() {
|
||||||
|
Some(id) => !ended_later.contains_key(id),
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut out = adopt_run(&healed, &kept);
|
||||||
|
out.extend(kept);
|
||||||
|
// What this function exists to prevent, checked rather than assumed: the same
|
||||||
|
// call drawn twice, once from the page that saw its start and once from the page
|
||||||
|
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
|
||||||
|
// seq its turn began at, which can be older than the page it arrived in, so the
|
||||||
|
// two pages' seqs legitimately interleave at the boundary.
|
||||||
|
debug_assert!(
|
||||||
|
{
|
||||||
|
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
|
||||||
|
let before = ids.len();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
ids.len() == before
|
||||||
|
},
|
||||||
|
"join_pages left the same tool call in both halves"
|
||||||
|
);
|
||||||
|
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],
|
||||||
|
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
|
||||||
|
let (
|
||||||
|
Some(TranscriptItem::AssistantMsg {
|
||||||
|
text: head_text, ..
|
||||||
|
}),
|
||||||
|
Some(TranscriptItem::AssistantMsg {
|
||||||
|
seq: tail_seq,
|
||||||
|
text: tail_text,
|
||||||
|
settled: tail_settled,
|
||||||
|
}),
|
||||||
|
) = (earlier.last(), later.first())
|
||||||
|
else {
|
||||||
|
return (earlier.to_vec(), later.to_vec());
|
||||||
|
};
|
||||||
|
let merged = TranscriptItem::AssistantMsg {
|
||||||
|
seq: *tail_seq,
|
||||||
|
text: format!("{head_text}{tail_text}"),
|
||||||
|
settled: *tail_settled,
|
||||||
|
};
|
||||||
|
let mut newer = vec![merged];
|
||||||
|
newer.extend(later[1..].iter().cloned());
|
||||||
|
(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();
|
||||||
|
}
|
||||||
|
let joining = run_id.clone();
|
||||||
|
let tail_len = earlier
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
|
||||||
|
.count();
|
||||||
|
if tail_len == 0 {
|
||||||
|
return earlier.to_vec();
|
||||||
|
}
|
||||||
|
let split = earlier.len() - tail_len;
|
||||||
|
let mut out = earlier[..split].to_vec();
|
||||||
|
out.extend(earlier[split..].iter().cloned().map(|mut item| {
|
||||||
|
// `take_while` above already restricted this slice to non-question tool calls;
|
||||||
|
// this just guards the invariant rather than trusting it silently.
|
||||||
|
debug_assert!(
|
||||||
|
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
|
||||||
|
"adopt_run must never rename a question's own run"
|
||||||
|
);
|
||||||
|
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
|
||||||
|
*run_id = joining.clone();
|
||||||
|
}
|
||||||
|
item
|
||||||
|
}));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||||
@@ -369,6 +569,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -379,15 +580,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Event::ToolEnd { id, output } => {
|
Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
update_tool(items, id, |item| {
|
update_tool(items, id, |item| {
|
||||||
if let TranscriptItem::ToolRun {
|
if let TranscriptItem::ToolRun {
|
||||||
output: out, done, ..
|
output: out,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
*done = true;
|
*done = true;
|
||||||
|
*failed = *is_error;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -401,6 +610,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: *is_error,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -457,6 +667,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
images,
|
images,
|
||||||
} if asks.iter().any(|a| &a.id == id) => {
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
for ask in asks.iter_mut() {
|
for ask in asks.iter_mut() {
|
||||||
@@ -472,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -557,6 +769,74 @@ 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`,
|
||||||
|
/// and drawing them the same way states a verdict nobody reached: "it
|
||||||
|
/// worked and said nothing" reads as a fact, where the truth is that the
|
||||||
|
/// 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, ..
|
||||||
|
} = item
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
debug_assert!(
|
||||||
|
!failed || *done,
|
||||||
|
"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 {
|
||||||
|
true => Self::Running,
|
||||||
|
false => Self::NoResult,
|
||||||
|
}
|
||||||
|
} else if *failed {
|
||||||
|
Self::Failed
|
||||||
|
} else {
|
||||||
|
Self::Succeeded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||||
@@ -796,6 +1076,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "x".to_string(),
|
id: "x".to_string(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -808,6 +1089,7 @@ mod tests {
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}]
|
}]
|
||||||
@@ -955,4 +1237,284 @@ mod tests {
|
|||||||
let err = fold_page(&values).unwrap_err();
|
let err = fold_page(&values).unwrap_err();
|
||||||
assert!(err.contains("couldn't parse"));
|
assert!(err.contains("couldn't parse"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
seq,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
seq,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")]);
|
||||||
|
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
let run_ids: Vec<_> = joined
|
||||||
|
.iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
|
||||||
|
other => panic!("expected only ToolRun items, got {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
run_ids,
|
||||||
|
vec!["b", "b"],
|
||||||
|
"the older call must adopt the newer, already-on-screen run's name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_split_across_the_boundary_merges_into_one_row() {
|
||||||
|
let older = fold_all(&[tool_start(1, "x", "Bash")]);
|
||||||
|
let newer = fold_all(&[tool_end(2, "x", "the result")]);
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
assert_eq!(
|
||||||
|
joined,
|
||||||
|
vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 1,
|
||||||
|
id: "x".to_string(),
|
||||||
|
run_id: "x".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
output: "the result".to_string(),
|
||||||
|
done: true,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}],
|
||||||
|
"the older half's tool/input and the newer half's output/done must both survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
|
||||||
|
let older = vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "Hel".to_string(),
|
||||||
|
settled: false,
|
||||||
|
}];
|
||||||
|
let newer = vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "lo".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 3,
|
||||||
|
text: "next".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
assert_eq!(
|
||||||
|
joined,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "Hello".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 3,
|
||||||
|
text: "next".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")]);
|
||||||
|
let newer = vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: "q".to_string(),
|
||||||
|
run_id: "q".to_string(),
|
||||||
|
tool: ASK_USER_QUESTION.to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}];
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
match &joined[0] {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
|
||||||
|
other => panic!("expected a ToolRun, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`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::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, e: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 0.0,
|
||||||
|
event: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(id: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({"command": "ls"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
|
||||||
|
let items = fold_all(events);
|
||||||
|
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_arrived_is_read_from_is_error() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "ok", false)], false),
|
||||||
|
ToolState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "No such file", true)], false),
|
||||||
|
ToolState::Failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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!(
|
||||||
|
state_of(&[start("a"), end("a", "", false)], false),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"a result arrived; it was empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a")], false),
|
||||||
|
ToolState::NoResult,
|
||||||
|
"no result, and the session is not working any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
|
||||||
|
let asking = event(
|
||||||
|
3,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "Allow?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption {
|
||||||
|
label: "Allow".to_string(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let answered = event(
|
||||||
|
4,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], false),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(
|
||||||
|
&[start("a"), asking, answered, end("a", "ok", false)],
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"once it is answered the call is an ordinary one again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_a_tool_call_has_a_tool_state() {
|
||||||
|
assert_eq!(
|
||||||
|
ToolState::of(
|
||||||
|
&TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
//! Where a session screen gets a transcript from: this phone's copy first,
|
||||||
|
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
||||||
|
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
||||||
|
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
||||||
|
//!
|
||||||
|
//! One seam rather than a cache the screen has to remember to consult.
|
||||||
|
//! Everything fetched before is asked of this, and everything the server
|
||||||
|
//! sends is written into the cache on the way past, so a caller never
|
||||||
|
//! learns which side answered. The one rule worth keeping in mind: the
|
||||||
|
//! cache is never load-bearing. Every read here has a network path beside
|
||||||
|
//! it producing the same result.
|
||||||
|
//!
|
||||||
|
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
||||||
|
//! ability to close a live stream from another thread. Both are wall-clock
|
||||||
|
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
||||||
|
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
||||||
|
//! scope) rather than to this pure logic -- `follow` below is the same
|
||||||
|
//! decorator shape `iris/desktop-app/src/app.rs` and
|
||||||
|
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
||||||
|
//! `event_stream::follow_session_events`, just with the cache write built
|
||||||
|
//! in so a future caller does not have to repeat it a third time.
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
|
use crate::api::{ApiClient, ApiError, Transport};
|
||||||
|
use crate::event_stream::{self, StreamItem};
|
||||||
|
use crate::transcript_cache::SessionCache;
|
||||||
|
|
||||||
|
/// How many events a session screen opens with, cached or fetched.
|
||||||
|
///
|
||||||
|
/// The server's own default page size, named here because the cached
|
||||||
|
/// opening has to be the same size as the fetched one -- a reader must not
|
||||||
|
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||||
|
/// in the Kotlin original).
|
||||||
|
pub const OPENING_WINDOW: u32 = 80;
|
||||||
|
|
||||||
|
/// A transcript-line parse failure, told apart from [`ApiError`] so a
|
||||||
|
/// caller can tell "the server is unreachable" from "the server (or this
|
||||||
|
/// phone's own disk) sent something this build cannot read" -- the two
|
||||||
|
/// mean different things to a reader (retry, versus a build that is
|
||||||
|
/// behind).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParseError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for ParseError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for ParseError {}
|
||||||
|
|
||||||
|
/// Either half of what can go wrong asking for a page: the network, or a
|
||||||
|
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
||||||
|
/// read.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PageError {
|
||||||
|
Api(ApiError),
|
||||||
|
Parse(ParseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiError> for PageError {
|
||||||
|
fn from(e: ApiError) -> Self {
|
||||||
|
Self::Api(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseError> for PageError {
|
||||||
|
fn from(e: ParseError) -> Self {
|
||||||
|
Self::Parse(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||||
|
/// one possibly-empty list.
|
||||||
|
///
|
||||||
|
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||||
|
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||||
|
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||||
|
/// question could not be asked yet, which it must not. Collapsing the two
|
||||||
|
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||||
|
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||||
|
/// same silently.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum OlderPage {
|
||||||
|
/// The events before the cursor, oldest first. Empty means the start
|
||||||
|
/// of the conversation has been reached.
|
||||||
|
Events(Vec<SeqEvent>),
|
||||||
|
/// Nothing is loaded, so there was no cursor to page back from
|
||||||
|
/// (`before == 0`). Not an answer about the conversation at all.
|
||||||
|
NothingLoaded,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||||
|
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This phone's copy of one session's transcript, plus the server it
|
||||||
|
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
||||||
|
pub struct TranscriptSource<T: Transport> {
|
||||||
|
api: ApiClient<T>,
|
||||||
|
session_id: String,
|
||||||
|
pub cache: SessionCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> TranscriptSource<T> {
|
||||||
|
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
|
||||||
|
Self {
|
||||||
|
api,
|
||||||
|
session_id: session_id.into(),
|
||||||
|
cache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cached opening window, or `None` when there is nothing usable
|
||||||
|
/// to draw.
|
||||||
|
///
|
||||||
|
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
||||||
|
/// whole point of the feature: the rows are on screen while the check
|
||||||
|
/// that they are still the server's rows is in flight, and a failed
|
||||||
|
/// check replaces them exactly as a reset does.
|
||||||
|
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||||
|
self.cache.tail()?;
|
||||||
|
let lines = self.cache.newest(limit);
|
||||||
|
if lines.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||||
|
Ok(events) => Some(events),
|
||||||
|
// A line this build cannot read at all, which the cache's own checks cannot
|
||||||
|
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
||||||
|
// open.
|
||||||
|
Err(ParseError(_)) => {
|
||||||
|
self.cache.purge();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server's event at the cached cursor is still the cached
|
||||||
|
/// one.
|
||||||
|
///
|
||||||
|
/// A caller must not resume a live stream from a cached seq unless it
|
||||||
|
/// is the same conversation: a transcript is append-only in ordinary
|
||||||
|
/// use, but the file backing it can be replaced or truncated (a
|
||||||
|
/// sandbox re-seeded with the same ids, a backup restored, a session
|
||||||
|
/// re-imported), and the server's catch-up on such a file would hand
|
||||||
|
/// this phone a continuation of a *different* conversation, spliced
|
||||||
|
/// onto the cached one with no seam. Caught with one request of a few
|
||||||
|
/// hundred bytes.
|
||||||
|
///
|
||||||
|
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||||
|
/// server not being askable, which is neither: the cached rows stay
|
||||||
|
/// on screen and the caller tries again on its own reconnect schedule.
|
||||||
|
///
|
||||||
|
/// What this cannot see is a line changed in the middle of the file
|
||||||
|
/// with the tail intact -- that is what a full reload is for.
|
||||||
|
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||||
|
let Some(tail) = self.cache.tail() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
||||||
|
// event *at* the cursor when the server still has one there.
|
||||||
|
let page = self.api.fetch_transcript_lines(
|
||||||
|
&self.session_id,
|
||||||
|
Some(tail.seq + 1),
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let matches = page.len() == 1
|
||||||
|
&& parse_line(&tail.line)
|
||||||
|
.map(|cached| cached == page[0].1)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !matches {
|
||||||
|
self.cache.purge();
|
||||||
|
}
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's opening fetch, kept as the start of the live run. Only
|
||||||
|
/// called when the cache has nothing to open with, or when
|
||||||
|
/// [`Self::probe`] said what it had was not the server's.
|
||||||
|
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||||
|
let page =
|
||||||
|
self.api
|
||||||
|
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
|
||||||
|
for (line, event) in &page {
|
||||||
|
self.cache.append(line, event.seq);
|
||||||
|
}
|
||||||
|
self.cache.flush();
|
||||||
|
Ok(page.into_iter().map(|(_, event)| event).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page before `before`: from the cache when it holds it,
|
||||||
|
/// otherwise from the server bounded by what the cache already has.
|
||||||
|
///
|
||||||
|
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||||
|
/// coalesced page reaches back as far as its row count takes it -- a
|
||||||
|
/// single reply is hundreds of lines -- so a page fetched after the
|
||||||
|
/// reader has been away could run straight past the cached run and
|
||||||
|
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||||
|
/// this phone's copy starts, the server stops there instead.
|
||||||
|
///
|
||||||
|
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||||
|
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||||
|
/// have bitten": there is no event before the first one, so the
|
||||||
|
/// request is not a harmless no-op, and its empty answer is
|
||||||
|
/// indistinguishable from having reached the start of history.
|
||||||
|
/// Guarded here rather than left to every caller, because it is a fact
|
||||||
|
/// about the question, not about who is asking it.
|
||||||
|
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||||
|
if before == 0 {
|
||||||
|
return Ok(OlderPage::NothingLoaded);
|
||||||
|
}
|
||||||
|
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||||
|
let events: Vec<SeqEvent> = lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| parse_line(l).map_err(PageError::from))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
return Ok(OlderPage::Events(events));
|
||||||
|
}
|
||||||
|
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||||
|
let page = self.api.fetch_transcript_lines(
|
||||||
|
&self.session_id,
|
||||||
|
Some(before),
|
||||||
|
limit,
|
||||||
|
coalesce,
|
||||||
|
after,
|
||||||
|
)?;
|
||||||
|
if let Some((_, first_event)) = page.first() {
|
||||||
|
// `before` rather than the newest line's seq: a coalesced page covers
|
||||||
|
// everything up to the cursor it was asked with, and nothing in its lines
|
||||||
|
// says so.
|
||||||
|
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||||
|
self.cache
|
||||||
|
.store_page(&lines, first_event.seq, before, coalesce);
|
||||||
|
}
|
||||||
|
Ok(OlderPage::Events(
|
||||||
|
page.into_iter().map(|(_, event)| event).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`event_stream::follow_session_events`], with every frame written to
|
||||||
|
/// the cache before `on_item` sees it.
|
||||||
|
///
|
||||||
|
/// Before, so that an event held back for a reader who is scrolled
|
||||||
|
/// away is already on disk -- what the cache holds is what the server
|
||||||
|
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||||
|
/// status change, which is a turn's boundary and the granularity a
|
||||||
|
/// crash may as well lose, and once more when the stream ends.
|
||||||
|
pub fn follow(
|
||||||
|
&self,
|
||||||
|
after: u64,
|
||||||
|
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let cache = &self.cache;
|
||||||
|
let result = event_stream::follow_session_events(
|
||||||
|
self.api.transport(),
|
||||||
|
&self.session_id,
|
||||||
|
after,
|
||||||
|
|item| {
|
||||||
|
if let StreamItem::Event { raw, event } = &item {
|
||||||
|
cache.append(raw, event.seq);
|
||||||
|
if matches!(event.event, event_model::Event::Status { .. }) {
|
||||||
|
cache.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on_item(item)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cache.flush();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaves the cache with everything it was given -- called once a
|
||||||
|
/// caller is done with this source, mirroring the Kotlin `close`'s
|
||||||
|
/// final flush (that method's stream cancellation itself is the
|
||||||
|
/// runtime concern the module doc says is not ported here).
|
||||||
|
pub fn close(&self) {
|
||||||
|
self.cache.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::{Body, RawResponse};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// A transport that answers fixed bodies in call order, and records
|
||||||
|
/// every path it was asked for -- so a test can assert *how many*
|
||||||
|
/// requests a method made, which is the point for the `before == 0`
|
||||||
|
/// guard (AGENTS.md's regression: the guard must stop the request
|
||||||
|
/// before it happens, not merely tolerate the empty answer).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ScriptedTransport {
|
||||||
|
responses: Mutex<VecDeque<(u16, String)>>,
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedTransport {
|
||||||
|
fn respond(&self, status: u16, body: impl Into<String>) {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back((status, body.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_count(&self) -> usize {
|
||||||
|
self.calls.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for ScriptedTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
self.calls.lock().unwrap().push(path.to_string());
|
||||||
|
let (status, body) = self
|
||||||
|
.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
|
||||||
|
Ok(RawResponse {
|
||||||
|
status,
|
||||||
|
body: body.into_bytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||||
|
self.calls.lock().unwrap().push(path.to_string());
|
||||||
|
let (_, body) = self
|
||||||
|
.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("ScriptedTransport got an unscripted stream request: {path}")
|
||||||
|
});
|
||||||
|
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(
|
||||||
|
transport: ScriptedTransport,
|
||||||
|
cache_root: &std::path::Path,
|
||||||
|
) -> TranscriptSource<ScriptedTransport> {
|
||||||
|
let api = ApiClient::new(transport);
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||||
|
TranscriptSource::new(api, "s1", cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_line(seq: u64) -> String {
|
||||||
|
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
|
||||||
|
assert_eq!(source.cached_opening(80), None);
|
||||||
|
let opening = source.fetch_opening().unwrap();
|
||||||
|
assert_eq!(opening.len(), 1);
|
||||||
|
assert_eq!(opening[0].seq, 1);
|
||||||
|
// The fetch wrote through: reopening the same cache now has something to show.
|
||||||
|
assert!(source.cache.tail().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
assert!(source2.probe().unwrap());
|
||||||
|
assert!(source2.cache.tail().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_mismatching_the_cached_tail_purges_the_cache() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
// The server now answers with a different event at the same seq -- the file
|
||||||
|
// behind this session was replaced.
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||||
|
transport2.respond(200, format!("[{different}]"));
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
assert!(!source2.probe().unwrap());
|
||||||
|
assert!(source2.cache.tail().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_finding_no_server_leaves_the_cache_untouched() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(1)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
transport2.respond(500, "server on fire");
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
assert!(source2.probe().is_err());
|
||||||
|
assert!(
|
||||||
|
source2.cache.tail().is_some(),
|
||||||
|
"an unreachable server must not be treated as a mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression this module exists to close: `before == 0` must
|
||||||
|
/// never reach the network or the cache, because an empty answer there
|
||||||
|
/// is indistinguishable from "there is genuinely no more history" --
|
||||||
|
/// AGENTS.md's `loadOlderPage` incident.
|
||||||
|
#[test]
|
||||||
|
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||||
|
assert_eq!(source.api.transport().call_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let calls_before = source.api.transport().call_count();
|
||||||
|
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||||
|
panic!("a cursor of 2 is a real question about the conversation");
|
||||||
|
};
|
||||||
|
assert_eq!(page.len(), 1);
|
||||||
|
assert_eq!(page[0].seq, 1);
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().call_count(),
|
||||||
|
calls_before,
|
||||||
|
"a cache hit must not touch the network"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With nothing older cached there is no floor to give the server, so
|
||||||
|
/// the request carries no `after` at all.
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(5)));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
|
let transport2 = ScriptedTransport::default();
|
||||||
|
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
|
source2.page(5, 10, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source2.api.transport().calls.lock().unwrap()[0],
|
||||||
|
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the test above cannot show: when the cache *does* hold an
|
||||||
|
/// older run, the fetch is floored at its end, or the page would run
|
||||||
|
/// straight past it and overlap -- which `store_page` then refuses,
|
||||||
|
/// silently costing the phone the page it just paid for.
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||||
|
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||||
|
// cache reads the directory as damaged and discards it.
|
||||||
|
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||||
|
assert!(cache.store_page(&lines, 3, 6, true));
|
||||||
|
cache.append(&status_line(6), 6);
|
||||||
|
cache.append(&status_line(7), 7);
|
||||||
|
cache.flush();
|
||||||
|
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(9)));
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
source.page(10, 10, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().calls.lock().unwrap()[0],
|
||||||
|
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||||
|
"the fetch must stop one seq below where this phone's copy ends"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page the server could not answer is an error, never an empty
|
||||||
|
/// page: the caller would read the second as "this conversation has no
|
||||||
|
/// more history" and stop paging for good.
|
||||||
|
#[test]
|
||||||
|
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(500, "server on fire");
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cached line this build cannot read is told apart from the network
|
||||||
|
/// failing, for the same reason: neither is "no more history".
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
cache.store_page(
|
||||||
|
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
cache.append(&status_line(4), 4);
|
||||||
|
cache.flush();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().call_count(),
|
||||||
|
0,
|
||||||
|
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
cache.append("not json at all", 1);
|
||||||
|
cache.flush();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
assert_eq!(source.cached_opening(80), None);
|
||||||
|
assert!(
|
||||||
|
source.cache.tail().is_none(),
|
||||||
|
"a damaged line purges the cache"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
source
|
||||||
|
.follow(0, |item| {
|
||||||
|
if let StreamItem::Event { event, .. } = item {
|
||||||
|
seen.push(event.seq);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seen, vec![1]);
|
||||||
|
assert_eq!(source.cache.tail().unwrap().seq, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_frame(data: &str) -> String {
|
||||||
|
format!("data:{data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,16 +25,19 @@ next (a Masonry or iris transcript screen, most likely).
|
|||||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
| `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 |
|
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
|
||||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
| `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
|
||||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||||
|
|
||||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||||
cases ported alongside it, plus new tests for the pieces that had none
|
cases ported alongside it, plus new tests for the pieces that had none
|
||||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
|
||||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
`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
|
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||||
them via `session::transcript`'s round-trip coverage).
|
them via `session::transcript`'s round-trip coverage).
|
||||||
|
|
||||||
@@ -88,13 +91,27 @@ the full table to work from when one of these is next.
|
|||||||
including tool-call/question/image attachment and peer-message placement.
|
including tool-call/question/image attachment and peer-message placement.
|
||||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||||
|
|
||||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
|
||||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
|
||||||
merges a tool call split across two fetched pages and re-merges a run a
|
call split across two fetched pages, rejoins a message a boundary cut
|
||||||
boundary cut through. This matters the moment paging backward through
|
through, and renames a run of tool calls onto whichever name is already on
|
||||||
history is exercised; it is deliberately left rather than rushed, since
|
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
|
||||||
it is exactly the kind of boundary logic this project's own "things that
|
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
|
||||||
have bitten" section warns reads fine and is wrong at the edges.
|
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.
|
||||||
|
|
||||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
**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.
|
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||||
@@ -119,20 +136,72 @@ caller-specific (the code rules' "ask for the least you need"). Its only
|
|||||||
caller today is `desktop-app`; a future Android build of this crate would
|
caller today is `desktop-app`; a future Android build of this crate would
|
||||||
be a second one, not a reason to move the type.
|
be 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
|
||||||
|
`iris/desktop-app/src/app.rs` and `iris/android-app/src/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
|
## What is not started at all
|
||||||
|
|
||||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
|
||||||
from the transcript cache or the server, and stitches the two. Needs
|
into its *top-level* blocks -- heading, paragraph, fence, list, table,
|
||||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
quote -- with each block's own source, which is what a renderer needs to
|
||||||
which exist now, so this is unblocked whenever picked up.
|
lay out prose versus code and what lets a streamed delta re-lay out one
|
||||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
block instead of the message (docs/RUST.md's Task B). What it
|
||||||
colours a `.md` file or fence for the highlighter, but does not build the
|
deliberately does **not** build is the tree below that: nested list
|
||||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
items, table cells, inline spans. Inline styling is still the renderer's
|
||||||
renderer walks to lay out prose versus code versus a table.
|
own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
|
||||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
|
||||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
for a full CommonMark AST is Compose rendering plumbing, not something
|
||||||
UI layer will want its own block parser or a crate for it, decided
|
to port as-is.
|
||||||
alongside the framework choice in RUST.md.
|
|
||||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||||
it flattens a row into bounded units for a *specific* lazy-list
|
it flattens a row into bounded units for a *specific* lazy-list
|
||||||
framework's composition cost, which is a fact about that framework
|
framework's composition cost, which is a fact about that framework
|
||||||
@@ -142,5 +211,6 @@ be a second one, not a reason to move the type.
|
|||||||
|
|
||||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||||
same way it always has). From `client-core/` directly: `cargo test`,
|
same way it always has). From `client-core/` directly: `cargo test`
|
||||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
(119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
||||||
|
this writing (2026-09-06).
|
||||||
@@ -5,6 +5,157 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
|||||||
for iris API changes); this file is only the summary. Newest first. Items
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-06 (how a tool call looks, P1b)
|
||||||
|
|
||||||
|
- **A card that never got a result says "no result", in yellow, and it is
|
||||||
|
a state Compose cannot say.** A call that finished having printed
|
||||||
|
nothing and a call whose turn was interrupted before anything came back
|
||||||
|
both leave an empty output. Compose draws both as an ordinary finished
|
||||||
|
call, which reads as a fact somebody established. There are five states
|
||||||
|
now, each with a word and a colour: nothing at all for a call that
|
||||||
|
worked, "running" (grey), "your turn" (peach, Compose's own wording and
|
||||||
|
colour), "failed" (red), "no result" (yellow).
|
||||||
|
|
||||||
|
- **A failed call is drawn as failed, which needed a field on the wire.**
|
||||||
|
`is_error` is on the CLI's `tool_result` and was being dropped; the
|
||||||
|
server now carries it to the phone. Reversible, but the alternative is a
|
||||||
|
card that says a call succeeded because it cannot tell.
|
||||||
|
|
||||||
|
- **A group's cards do not each carry their own surface.** Compose gives
|
||||||
|
each card a fill and squares the corners where it faces a neighbour, so
|
||||||
|
a run reads as one object broken into parts. iris has no per-corner
|
||||||
|
radius, and -- more to the point -- a group built the way Compose builds
|
||||||
|
it hit a framework layout defect that drew every card's text a card
|
||||||
|
below its own box. So a group is one surface with its cards on it,
|
||||||
|
separated by a small gap, and the 4dp inset Compose holds them off the
|
||||||
|
edge by is gone. Worth revisiting once the layout defect is fixed
|
||||||
|
(docs/IRIS_TODO.md).
|
||||||
|
|
||||||
|
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
|
||||||
|
lines".** Compose draws the whole thing, and gets away with it because
|
||||||
|
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
|
||||||
|
text widget and shaping a hundred kilobytes of it costs what the file
|
||||||
|
editor's 32 kB limit was measured against. If iris's text gets cheaper,
|
||||||
|
this is the number to move.
|
||||||
|
|
||||||
|
- **A card's command is clipped, not pannable, and its summary line is
|
||||||
|
clipped rather than ellipsised.** Both are framework gaps rather than
|
||||||
|
choices (`scrollable_on` on a non-editable text draws nothing; there is
|
||||||
|
no overflow ellipsis), and both are worse than Compose today. Named here
|
||||||
|
because they are visible.
|
||||||
|
|
||||||
|
## 2026-09-06 (how a markdown block looks, P1a)
|
||||||
|
|
||||||
|
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
||||||
|
call to reverse. Compose draws a real grid: cells on a tint, each
|
||||||
|
column with a 136dp floor, scrolling sideways when there are too many.
|
||||||
|
iris has no grid widget, and building one would be a widget per
|
||||||
|
markdown feature -- which is the thing the block model exists to avoid.
|
||||||
|
In a monospace face a character count *is* a pixel width, so padding
|
||||||
|
each cell to its column's width is alignment, the widths are still
|
||||||
|
measured from the cells, and a table that is too wide pans sideways
|
||||||
|
through the same mechanism a code fence already uses. The header is
|
||||||
|
bold with a rule under it, and a long cell wraps inside its column
|
||||||
|
(capped at 28 characters, which is what fits three columns across a
|
||||||
|
phone). **What it trades:** no cell borders, and a table looks like
|
||||||
|
code rather than like a table. If you want the grid, it is a new widget
|
||||||
|
and it is a day's work.
|
||||||
|
- **Three block frames, and only three.** A heading, paragraph and list
|
||||||
|
are plain text with spans; a fence and a table are a rounded panel that
|
||||||
|
does not wrap; a quote is a bar with the text padded past it.
|
||||||
|
Everything else markdown says is expressed in span styles, which cost
|
||||||
|
no widgets and no layout nodes. So a new markdown feature is a span,
|
||||||
|
not a widget.
|
||||||
|
- **A list's marker is part of the text, so a wrapped item's second line
|
||||||
|
returns to the left margin.** Compose keeps it indented by giving the
|
||||||
|
marker its own column. Doing the same here needs per-line indent in
|
||||||
|
iris's text attributes; it is written down rather than done, because
|
||||||
|
the list items in a real reply are usually one line.
|
||||||
|
- **A link opens on a tap and not on the end of a drag.** A press that
|
||||||
|
panned the transcript past a link, or that held long enough to start a
|
||||||
|
selection, does not follow it -- decided by the same gesture machine
|
||||||
|
that decides pan-versus-select, so there is one rule rather than two
|
||||||
|
that can disagree.
|
||||||
|
|
||||||
|
## 2026-09-06 (composer scroll and the streaming block model)
|
||||||
|
|
||||||
|
- **A streamed message becomes a column of per-block widgets.** Decided by
|
||||||
|
the design agent; recorded here because it is the shape of every message
|
||||||
|
on screen. A transcript row is one `TextEdit` today, so a streamed delta
|
||||||
|
re-shapes the entire message through parley on every event -- the stream
|
||||||
|
phase is the one place iris is behind Compose on your phone (p50 18.2ms
|
||||||
|
vs 13.4ms). A row becomes a column of one widget per markdown block
|
||||||
|
(paragraph, heading, fence, list, table) and a delta replaces only the
|
||||||
|
last block, keeping every earlier block's layout. **Rejected:** splitting
|
||||||
|
parley's layout at block boundaries inside one text widget (couples
|
||||||
|
iris's text widget to markdown structure, and parley has no incremental
|
||||||
|
API), and caching shaped runs per paragraph inside `TextEdit` (a second
|
||||||
|
cache with its own invalidation beside the glyph cache). Chosen because
|
||||||
|
P1's markdown block model is needed anyway, so the split happens once, in
|
||||||
|
`client-core`, and iris stays a text renderer. **Status: designed, not
|
||||||
|
built** -- this pass spent its budget on the composer's three layout
|
||||||
|
defects; docs/RUST.md has the design and the pass conditions.
|
||||||
|
- **The composer's overflowing text now scrolls on a finger**, capped at
|
||||||
|
six lines and clipped to the bar. Reverses the "still does not scroll"
|
||||||
|
item below.
|
||||||
|
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
|
||||||
|
widget authors, enforced by a `debug_assert!`; nothing changes for app
|
||||||
|
code.
|
||||||
|
|
||||||
|
## 2026-09-06 (stale-primitives and touch-scroll pass)
|
||||||
|
|
||||||
|
- **A vertical drag inside a focused composer now scrolls rather than
|
||||||
|
selects.** Android's own `EditText` does this -- a vertical drag scrolls
|
||||||
|
the field, and only a long press starts a selection -- so the platform
|
||||||
|
decided it. What it costs: you can no longer drag straight down inside
|
||||||
|
the composer to select several lines of what you typed; use a long press
|
||||||
|
and then drag, or drag sideways. Say if that trade is wrong for you.
|
||||||
|
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
|
||||||
|
does not, because it has no per-frame tick to animate one and the areas
|
||||||
|
it wraps are at most a screenful (Android does not fling a six-line text
|
||||||
|
box either). Easy to add later if a scroll area ever wraps something long.
|
||||||
|
- **The composer still does not scroll its overflowed text**, though the
|
||||||
|
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
|
||||||
|
was tried and reverted the same day: `Scroll` measures its content and
|
||||||
|
container against the *window*, so inside the `MaxSize` that caps the
|
||||||
|
composer at six lines the two are in different spaces and the field pans
|
||||||
|
itself entirely out of the bar (measured on the emulator with 474
|
||||||
|
characters in it -- the bar collapsed to its padding). Fixing that means
|
||||||
|
`Scroll` measuring against its own offered box, which is a change to a
|
||||||
|
widget the transcript and the bench shell both use, so it is its own
|
||||||
|
piece of work rather than a rider on this one.
|
||||||
|
|
||||||
|
## 2026-09-06 (defect pass)
|
||||||
|
|
||||||
|
- **The keyboard-open diagnostics overlay is gone; the capture only
|
||||||
|
logs now.** It was added when `on_insets_changed` was not firing at all
|
||||||
|
and there was no way to get a report off the phone. It fires reliably
|
||||||
|
since the activity went edge-to-edge -- and what that looks like in
|
||||||
|
use is a full-screen report covering the app **every time the keyboard
|
||||||
|
opens**, with its own Copy/Close buttons sitting underneath the
|
||||||
|
keyboard, so it cannot be dismissed (reproduced on the emulator this
|
||||||
|
pass: two `tap 'CLOSE'` runs left it up). An interruption for something
|
||||||
|
nobody asked for, over the app you are trying to type into. The named
|
||||||
|
`Diagnostics` button still shows the same text on demand, and the new
|
||||||
|
`iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat`
|
||||||
|
pull needs. Reversible: `capture_keyboard_diagnostics` is still the one
|
||||||
|
place this is decided, and `PlatformHandle::show_diagnostics_overlay`
|
||||||
|
is still there.
|
||||||
|
|
||||||
|
- **The bench shell's report pane is sized to its report, not to a share
|
||||||
|
of the window.** It held `.height(rest(1))` beside the transcript's
|
||||||
|
`rest(2)`, so an *empty* `TextEdit` reserved a third of every screen --
|
||||||
|
which is what Iris's "the app does not start with keyboard spacing
|
||||||
|
correct" screenshot was showing, with the composer two thirds down and
|
||||||
|
black below it. It is `.max_height(dp(260))` now and sits above the
|
||||||
|
transcript rather than under the composer, where it was eating the
|
||||||
|
navigation-bar clearance. Cost: a filled report is clipped at 260dp
|
||||||
|
rather than scrolling (a `Scroll` there drew itself off the top of the
|
||||||
|
screen, since `Scroll` pins to the end of its content and reports its
|
||||||
|
content's full length to the parent -- worth fixing in `Scroll`, not
|
||||||
|
worked around here). "Copy report" and `logcat` still have the whole
|
||||||
|
thing.
|
||||||
|
|
||||||
## 2026-09-05
|
## 2026-09-05
|
||||||
|
|
||||||
- **iris no longer asks every device for compute-shader limits it never
|
- **iris no longer asks every device for compute-shader limits it never
|
||||||
|
|||||||
@@ -8,7 +8,367 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
|
## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
|
||||||
|
|
||||||
|
Layer 1 and 2 of docs/RUST.md's "Three test layers".
|
||||||
|
|
||||||
|
**New: `iris::harness`** -- a screen driven in-process with no window, no
|
||||||
|
compositor and no GPU, on a clock the caller advances. `Harness::new(size,
|
||||||
|
density)` gives you an `Rsc`, a `UiRenderState` and a state that
|
||||||
|
implements `FocusHost`/`OpenUrl` by *recording* what the platform was
|
||||||
|
asked for (`keyboard_shown`, `opened_urls`) rather than doing it;
|
||||||
|
`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)`
|
||||||
|
feeds one pointer sample the way Android's `on_touch_event` does, and
|
||||||
|
`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses
|
||||||
|
a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the
|
||||||
|
batched 120Hz flick shape your phone actually delivers is a file that
|
||||||
|
`cargo test` can replay -- something the emulator cannot produce at all.
|
||||||
|
|
||||||
|
**New: `List::fling_velocity() -> Option<f32>`**, what the release
|
||||||
|
measured, readable where it landed rather than by re-timing the gesture.
|
||||||
|
|
||||||
|
**Changed: `List` starts a fling's curve at its first `tick_fling`, not
|
||||||
|
at the release.** The only clock it reads is now the one its driver hands
|
||||||
|
it; in a running app the difference is at most a frame.
|
||||||
|
|
||||||
|
**Changed: the desktop backend lays out in physical pixels with a
|
||||||
|
density, exactly as Android does.** `iris::default::content_scale(window)`
|
||||||
|
is the desktop's `content_scale` -- winit's scale factor, overridable with
|
||||||
|
the `IRIS_SCALE` environment variable -- and it now feeds
|
||||||
|
`UiRenderState::set_density`/`TextData::density` instead of dividing
|
||||||
|
coordinates into a separate "logical" space. That division had
|
||||||
|
`UiRenderState::resize` (physical) and the window uniform (logical)
|
||||||
|
disagreeing on any display whose scale factor is not 1.0, and rasterised
|
||||||
|
glyphs at one resolution to display them at another. `Input::event` lost
|
||||||
|
its `scale_factor` parameter as a result, and `DefaultUiState::
|
||||||
|
window_size()` now answers physical pixels. On a 1.0 display nothing
|
||||||
|
changes. The override is what lets `run-headless.sh --phone` open a window
|
||||||
|
at your phone's own 1080x2424 and 2.55.
|
||||||
|
|
||||||
|
## 2026-09-07: the fling curve was the identity function
|
||||||
|
|
||||||
|
You said the fling "seems to just be linear velocity with an abrupt stop."
|
||||||
|
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
|
||||||
|
`t`. Two halves of AOSP's spline build loop had been transposed, which made
|
||||||
|
its two tables identical, and the lookup interpolated one against the
|
||||||
|
other -- which reduces algebraically to `t`. So a fling coasted at its
|
||||||
|
release speed for the whole (correctly computed) duration and stopped dead
|
||||||
|
at the end of it.
|
||||||
|
|
||||||
|
Ported exactly now from `OverScroller.java` and Compose's
|
||||||
|
`SplineBasedDecay.kt`, which agree line for line. One public addition:
|
||||||
|
|
||||||
|
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
|
||||||
|
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
|
||||||
|
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
|
||||||
|
rather than inferred, and it is what `List::tick_fling`'s new
|
||||||
|
`iris fling tick:` debug line reports each frame.
|
||||||
|
|
||||||
|
The lesson worth keeping, since it cost two builds on your phone: every
|
||||||
|
test the calculator had compared it with itself -- monotonic, correctly
|
||||||
|
signed, integrates to the closed form, per-tick deltas non-increasing --
|
||||||
|
and **all of them pass on a straight line**. The numbers now come from
|
||||||
|
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
|
||||||
|
the two sources, checked in beside the tests.
|
||||||
|
|
||||||
|
## 2026-09-07: the Android insets bridge counts its own dispatches
|
||||||
|
|
||||||
|
`AndroidUiState::insets_report() -> String` is new, and the bench app's
|
||||||
|
Diagnostics pane shows it. It carries the last insets plus **how many times
|
||||||
|
the platform has delivered any**, because "the keyboard did not push
|
||||||
|
anything up" has two causes that look identical on screen -- the listener
|
||||||
|
never fired, or it fired with a zero height -- and you have no logcat on
|
||||||
|
the phone. `dispatches=0` prints a sentence saying so rather than the
|
||||||
|
numbers, which would be defaults rather than measurements.
|
||||||
|
|
||||||
|
## 2026-09-07: widgets can animate, and a fling finally moves
|
||||||
|
|
||||||
|
Iris's phone said "fling still doesn't work" twice. The velocity was only
|
||||||
|
half of it: **nothing in iris advanced an animation between input
|
||||||
|
events**, so `List::fling` stored a speed that nothing ever applied. Three
|
||||||
|
public changes come out of fixing that.
|
||||||
|
|
||||||
|
**`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method,
|
||||||
|
defaulted to `false`, so no existing widget changes. A widget that
|
||||||
|
overrides it is animating; answering `false` is how it stops.
|
||||||
|
|
||||||
|
**`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are
|
||||||
|
the registry and its driver. A gesture that starts an animation registers
|
||||||
|
the widget; each backend calls `tick_animations` once per frame before the
|
||||||
|
draw and asks for another frame while it answers `true`. That answer is
|
||||||
|
the *only* thing in iris that makes a frame happen without an input event,
|
||||||
|
and an animation's path out is its own `tick` returning false -- nothing
|
||||||
|
has to remember to unregister it.
|
||||||
|
|
||||||
|
// before: the velocity was stored and never applied
|
||||||
|
list(ui).fling(-v);
|
||||||
|
// after
|
||||||
|
list(ui).fling(-v);
|
||||||
|
let id = list.id();
|
||||||
|
ui.ui_mut().animate(id);
|
||||||
|
|
||||||
|
The two calls are deliberate rather than folded into `fling`: the velocity
|
||||||
|
is the list's business and whether anything animates at all is the frame
|
||||||
|
loop's, and a caller driving its own frames (the benchmark, the headless
|
||||||
|
tests) still calls `tick_fling` directly.
|
||||||
|
|
||||||
|
**`FlingCalculator` needs the real display density, and its coefficient
|
||||||
|
was wrong.** `new(density)` takes physical pixels per `dp` and the
|
||||||
|
velocity handed to it must be in those same physical pixels -- the
|
||||||
|
density does *not* cancel out, contrary to what that type's doc used to
|
||||||
|
claim. Separately, `physical_coefficient` multiplied by the scroll
|
||||||
|
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
|
||||||
|
factor of 56 inside an exponential. Together they gave an ordinary flick a
|
||||||
|
**45-second** coast, which nobody could see while flings never animated.
|
||||||
|
`List` reads its density from the painter now, and
|
||||||
|
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
|
||||||
|
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
|
||||||
|
formula -- the check every previous test could not make, because they all
|
||||||
|
compared the calculator with itself.
|
||||||
|
|
||||||
|
**`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and
|
||||||
|
`shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang
|
||||||
|
either side; it was never meant as a claim about tree depth, and the
|
||||||
|
transcript screen's composer field sits 17 slots below the root. Past the
|
||||||
|
bound both walks silently stop summing, so a widget draws and hit-tests
|
||||||
|
short with nothing to say so; the CPU assert now prints the chain, so a
|
||||||
|
cycle and a deep tree can be told apart.
|
||||||
|
|
||||||
|
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
||||||
|
|
||||||
|
`transcript_ui::tool` is new: a card per tool call, a group per run
|
||||||
|
(P1b). Three things in the public surface follow from it.
|
||||||
|
|
||||||
|
**`client_core::transcript_fold::ToolState`** is what a card colours
|
||||||
|
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
|
||||||
|
built by `ToolState::of(&item, session_working)`. The pair it exists for
|
||||||
|
is `Succeeded` against `NoResult`: a call that finished having printed
|
||||||
|
nothing and a call whose result never arrived both leave an empty
|
||||||
|
`output`, and drawing them the same way states a verdict nobody reached.
|
||||||
|
Only the session's own status separates them, which is why `of` takes it.
|
||||||
|
|
||||||
|
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
|
||||||
|
so an older transcript still parses), and
|
||||||
|
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
|
||||||
|
Without them a result was everything a card knew and a broken call drew
|
||||||
|
exactly as confidently as one that worked -- the missing state, not a
|
||||||
|
wrong one. Every construction site of both had to gain a field; the value
|
||||||
|
comes from the CLI's own `tool_result`, read in one place
|
||||||
|
(`import::tool_result_is_error`) by both the live translator and the
|
||||||
|
import replay.
|
||||||
|
|
||||||
|
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
|
||||||
|
only thing that writes it. Before: a card with no result was drawn the
|
||||||
|
same whether its turn was still going or had been interrupted. After:
|
||||||
|
only the *newest* row can say "running", because every row behind it
|
||||||
|
belongs to a turn that has ended, and changing the flag redraws that one
|
||||||
|
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
|
||||||
|
bool)` joins it, answering whether there was a tool run to act on -- a
|
||||||
|
group's expanded appearance is otherwise unreachable from anything that
|
||||||
|
cannot press the screen.
|
||||||
|
|
||||||
|
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
|
||||||
|
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
|
||||||
|
markdown block) or `Tools` for a run (an arriving result costs one card).
|
||||||
|
One mechanism for "what can this row change cheaply", asked of the row
|
||||||
|
rather than decided again at each call site. It also takes the row's own
|
||||||
|
`working` flag.
|
||||||
|
|
||||||
|
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
|
||||||
|
`ToolInput.kt`'s subject/description/timeout/rest split, and
|
||||||
|
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
|
||||||
|
both with the Kotlin's own tests ported.
|
||||||
|
|
||||||
|
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
||||||
|
|
||||||
|
Three related additions, all for following a markdown link.
|
||||||
|
|
||||||
|
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
|
||||||
|
has the same shape: declared in `iris`, implemented once per backend (a
|
||||||
|
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
|
||||||
|
on Android, deferred to the next view callback exactly the way
|
||||||
|
`pending_show_keyboard` is). A widget asks for the capability by bound --
|
||||||
|
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
|
||||||
|
callback down through every builder. One method, not a general "run an
|
||||||
|
intent": a narrower capability is a narrower thing to get wrong. Nothing
|
||||||
|
is returned; the platform either shows a browser or does not, and both
|
||||||
|
are outside the process.
|
||||||
|
|
||||||
|
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
|
||||||
|
"the press ended having selected something" and "the press ended having
|
||||||
|
done nothing at all", and only the second is a tap. Any caller that acts
|
||||||
|
on a tap -- following a link -- must not also act when the finger was
|
||||||
|
panning the list past that link, so the distinction is made once, in the
|
||||||
|
gesture machine every widget already shares, rather than timed again per
|
||||||
|
widget. `DragArbiter::is_undecided()` is what answers it.
|
||||||
|
`Selection::drag` returns the outcome now instead of `()`.
|
||||||
|
|
||||||
|
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
|
||||||
|
still vertical). A code fence pans across its own long lines exactly the
|
||||||
|
way a transcript pans down its rows, and the two were the same state
|
||||||
|
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
|
||||||
|
joins `scrollable()` for the same reason. Before this, a horizontal
|
||||||
|
`Scroll` existed but could not be dragged by a finger at all -- its
|
||||||
|
arbiter only ever committed on the vertical axis.
|
||||||
|
|
||||||
|
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
|
||||||
|
answers which byte of the text a tap landed on, doing the same
|
||||||
|
region-relative transform `select` does, without handing out the parley
|
||||||
|
layout a caller could shape against stale text. And **`Rect::radius` now
|
||||||
|
takes a `Len`**, so a corner can be written in `dp` and come out the same
|
||||||
|
physical size on every display; a bare number still means physical pixels.
|
||||||
|
|
||||||
|
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
|
||||||
|
answers `false` now. It answered `true`, and a `Rect` fills whatever
|
||||||
|
region it is given -- so `draw_inner`'s fast path, which rewrites a
|
||||||
|
widget's primitives in place instead of redrawing it, could not reproduce
|
||||||
|
what `draw` would have done. A `.background(rect(..))` behind
|
||||||
|
variable-height content kept the size of the provisional pass its parent
|
||||||
|
`Span` had drawn it at, which on the transcript screen meant one code
|
||||||
|
block's panel covering every block below it. Costs one primitive's redraw
|
||||||
|
when a rect is resized.
|
||||||
|
|
||||||
|
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
|
||||||
|
|
||||||
|
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
|
||||||
|
It makes one per top-level markdown block now -- heading, paragraph,
|
||||||
|
fenced code, list, table -- in a `Span::down`, because a streamed delta
|
||||||
|
into a single buffer re-shaped the whole message through parley on every
|
||||||
|
event. `client_core::markdown_blocks::split_blocks` does the splitting;
|
||||||
|
`row::RowBlocks::apply_delta` updates the block a delta lands in and
|
||||||
|
leaves the rest of the message's layout alone.
|
||||||
|
|
||||||
|
**The change to judge, since it is what a reader feels**:
|
||||||
|
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
|
||||||
|
so **a block, not a row, is the unit a selection steps in**. A drag still
|
||||||
|
runs from a reply into the tool output beneath it and copies as one
|
||||||
|
thing; what changed is that the row under the finger is filled in block by
|
||||||
|
block rather than all at once, which is if anything closer to what the
|
||||||
|
old shortcut in `Selection`'s module doc was apologising for. `register`
|
||||||
|
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
|
||||||
|
block of it (dropping only the first is how a freed widget gets left in
|
||||||
|
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||||
|
|
||||||
|
`Selection::locate(ui, render, pos_window)` is new: which block is under a
|
||||||
|
window position, with that block's own local position and size. The
|
||||||
|
list-level handler uses it for the pointer-captured half of a drag,
|
||||||
|
instead of computing a row-local position from `List::extent`.
|
||||||
|
|
||||||
|
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
|
||||||
|
the third is the per-block state a caller keeps only for the row a reply
|
||||||
|
is streaming into, and is `None` for a tool run, which never streams.
|
||||||
|
|
||||||
|
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
|
||||||
|
|
||||||
|
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
|
||||||
|
(`dp` becomes physical pixels), but staying a `Len` so `rest` survives.
|
||||||
|
|
||||||
|
**New rule, and it is a rule about every widget, not about the two that
|
||||||
|
broke it**: a `Len` a widget *reports* from `draw` must not carry an
|
||||||
|
unresolved `dp`. `dp` is an input unit -- a number the widget author wrote
|
||||||
|
-- and the containers that consume a reported length read `abs`, `rel` and
|
||||||
|
`rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition),
|
||||||
|
so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both
|
||||||
|
returned the caller's declared `Len` as written; a `.max_height(dp(168))`
|
||||||
|
therefore gave its child a slot of nothing the moment the cap actually
|
||||||
|
applied, which is what made the composer's bar collapse. Both put their
|
||||||
|
declared lengths through `fold_dp` now, and
|
||||||
|
`UiRenderState::draw_inner` `debug_assert!`s the invariant after every
|
||||||
|
`Widget::draw`, so a widget that gets this wrong says so at the mistake
|
||||||
|
rather than laying out at zero somewhere else.
|
||||||
|
|
||||||
|
Nothing changes for a caller: `.max_height(dp(48))` is written the same
|
||||||
|
way. It is only widget *authors* who now have a rule to follow, and a
|
||||||
|
debug build that enforces it.
|
||||||
|
|
||||||
|
## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields
|
||||||
|
|
||||||
|
**`Painter::set_mask(region)` allocates its widget's mask slot once and
|
||||||
|
rewrites it in place** on every later draw, instead of pushing a new one
|
||||||
|
each time. It has to: `draw_inner`'s unchanged-region fast path does not
|
||||||
|
revisit a descendant whose own region did not change, so those descendants
|
||||||
|
go on referencing whichever slot they were first drawn under. Pushing a
|
||||||
|
fresh slot per draw left the composer's field clipped to a box the bar had
|
||||||
|
long since moved away from -- four live mask entries, none of them the
|
||||||
|
`Masked`'s current region -- and it drew nothing at all. Same call, same
|
||||||
|
signature; only the lifetime changed.
|
||||||
|
|
||||||
|
**`ActiveData` gains `own_mask` and `move_applied`** (both public, since
|
||||||
|
`ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a
|
||||||
|
widget that sets no mask. `move_applied` is how much of a widget's own
|
||||||
|
move-slot delta its `region` already accounts for: `mov` shifts both,
|
||||||
|
`Painter::reposition` shifts only the slot, and `resolved_region` -- and so
|
||||||
|
every hit test -- has to subtract it. Without that a widget that had been
|
||||||
|
panned had its *own* hit box at twice the pan while its descendants were
|
||||||
|
correct, which made the composer's field untappable after a finger drag.
|
||||||
|
|
||||||
|
## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
|
||||||
|
|
||||||
|
Three related public changes, all in aid of IRIS_TODO.md's "the composer
|
||||||
|
has no touch-drag scroll".
|
||||||
|
|
||||||
|
**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and
|
||||||
|
`WidgetLike::scrollable()` now registers it alongside the wheel handler it
|
||||||
|
already registered -- so anything built with `.scrollable()` pans on a
|
||||||
|
finger drag with no extra wiring at the call site. It goes through the same
|
||||||
|
`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List`
|
||||||
|
with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a
|
||||||
|
second copy of that widget's wiring: `DragGesture` owns the mechanics and
|
||||||
|
each caller decides only what a committed pan *means*. `Scroll::amt()` is
|
||||||
|
new too, the read-only pan position a test or a scroll indicator needs.
|
||||||
|
|
||||||
|
There is deliberately **no fling** on `Scroll`. Unlike `List` it has no
|
||||||
|
per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`),
|
||||||
|
and the areas it wraps today are at most a screenful, where Android does not
|
||||||
|
fling either. The released velocity is dropped rather than approximated.
|
||||||
|
|
||||||
|
**A vertical drag inside an already-focused `TextEdit` no longer extends a
|
||||||
|
selection.** `iris::attr`'s `on_press` used to treat a focused field as the
|
||||||
|
plain `click_or_drag` case -- every `Pressing` frame updated the selection.
|
||||||
|
It now applies the same `DRAG_SLOP` rule the *unfocused* branch already
|
||||||
|
applied: a press that moves past the slop vertically abandons its pending
|
||||||
|
selection for the rest of the gesture, so the scroll area around the field
|
||||||
|
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
|
||||||
|
press still starts a selection. This is Android's own `EditText` behaviour
|
||||||
|
(a vertical drag scrolls; only a long press selects), and it is what makes
|
||||||
|
"swipe up over the composer to scroll the transcript" work without dragging
|
||||||
|
a highlight through the message you were typing.
|
||||||
|
|
||||||
|
**`UiRenderState::orphaned_primitives()` is new**, and `update` now
|
||||||
|
`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is
|
||||||
|
a primitive still bound for the GPU that no live `ActiveData` names -- a
|
||||||
|
copy nothing can move, clip or free. That was the doubled `Compacted:` row
|
||||||
|
on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The
|
||||||
|
per-frame guard is a count comparison (O(active widgets)); the walk that
|
||||||
|
names the offenders only runs when the counts disagree, because the walk is
|
||||||
|
O(primitives) and made a debug build on a phone too slow to finish a
|
||||||
|
benchmark run.
|
||||||
|
|
||||||
|
## 2026-09-06: a tap on a text field always leaves a caret
|
||||||
|
|
||||||
|
`TextEditCtx::select` used to compare the tap position against the
|
||||||
|
*laid-out text's* own box and set `selection = None` for anything outside
|
||||||
|
it. A press only reaches `select` after being hit-tested to the widget, so
|
||||||
|
that "outside" meant the field's own padding -- or, for an **empty** field,
|
||||||
|
everything, since an empty layout is a zero-width box. So tapping an empty
|
||||||
|
composer focused it and opened the keyboard while leaving no caret, and
|
||||||
|
`TextEditCtx::insert`/`insert_str` return early with no caret: every
|
||||||
|
keystroke was dropped in silence, and no glyph ever appeared. Parley's
|
||||||
|
`from_point`/`extend_to_point` already clamp a point outside the layout to
|
||||||
|
the nearest cursor position, which is also what a tap in a field's padding
|
||||||
|
should do.
|
||||||
|
|
||||||
|
Behaviour change a caller would notice, in one line: **`select` with a
|
||||||
|
non-drag position now always produces a selection; it no longer clears
|
||||||
|
one.** Clearing is `TextEditCtx::deselect`, which is what the backends'
|
||||||
|
focus handling already calls. A drag is unchanged -- with no previous
|
||||||
|
selection there is still nothing to extend, so it produces none.
|
||||||
|
|
||||||
|
`insert_str` also gained a `debug_assert!` for the no-caret case, so an
|
||||||
|
insert routed to an unfocused field fails at the mistake in a debug build
|
||||||
|
instead of silently swallowing input.
|
||||||
|
|
||||||
|
## 2026-09-06: `List::anchor_position_display`## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
|
||||||
|
|
||||||
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
||||||
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
||||||
@@ -552,7 +912,14 @@ streamed event" cost RUST.md's P0 box measured (20 events/second against a
|
|||||||
new rows appended after it. A row changing *before* the tail (only
|
new rows appended after it. A row changing *before* the tail (only
|
||||||
`group_tool_runs` retroactively grouping tool calls into a run does
|
`group_tool_runs` retroactively grouping tool calls into a run does
|
||||||
this) falls back to `List::clear` plus a full rebuild, counted in
|
this) falls back to `List::clear` plus a full rebuild, counted in
|
||||||
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs`
|
`TranscriptScreen::take_rebuilds()`. **A caller that keeps its own
|
||||||
|
row-keyed side table alongside `List` (`Selection`'s `rows:
|
||||||
|
BTreeMap<RowKey, WeakWidget<TextEdit>>` is the one this crate has) must
|
||||||
|
clear it in step with `List::clear()`** — the fallback drops every row
|
||||||
|
`List` was holding, so any side table not cleared the same way is left
|
||||||
|
pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md
|
||||||
|
finding 1, fixed 2026-09-06 by `Selection::clear()`, called from
|
||||||
|
`apply`'s `Rebuild` arm right before `List::clear()`). `bench_client.rs`, `transcript_client.rs`
|
||||||
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
||||||
event; only the opening page (and `apply`'s own fallback) still calls
|
event; only the opening page (and `apply`'s own fallback) still calls
|
||||||
`build_tree`.
|
`build_tree`.
|
||||||
@@ -680,3 +1047,23 @@ still not root-caused).
|
|||||||
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
||||||
device's textures, which is why text used to vanish again after leaving
|
device's textures, which is why text used to vanish again after leaving
|
||||||
and returning to the app.
|
and returning to the app.
|
||||||
|
|
||||||
|
## 2026-09-06: `take_counters` counts text layouts too
|
||||||
|
|
||||||
|
One public API change, from the verification pass over the composer-scroll
|
||||||
|
and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
|
||||||
|
|
||||||
|
- **`UiRenderState::take_counters` returns four numbers, not three**:
|
||||||
|
`(draws, region rewrites, move writes, **text shapes**)`. The new one is
|
||||||
|
bumped in `Painter::render_text`, which `TextView::render` only reaches
|
||||||
|
on a cache miss, so it counts layouts actually computed rather than
|
||||||
|
layouts asked for. Callers destructuring the tuple need one more `_`.
|
||||||
|
|
||||||
|
It exists because a draw counter cannot answer the question the
|
||||||
|
per-block transcript row was built for. A widget can be redrawn without
|
||||||
|
re-shaping (the layout is memoized by width) and re-shaped without any
|
||||||
|
extra draw, and re-shaping is the expensive half — so "a streamed delta
|
||||||
|
costs one block" was, until now, argued from the code rather than
|
||||||
|
measured. With the counter it is a test: one delta into a 100-paragraph
|
||||||
|
reply shapes exactly **1** text layout, the same as into a
|
||||||
|
one-paragraph one.
|
||||||
@@ -162,8 +162,33 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
|
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
|
||||||
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
|
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
|
||||||
not yet on the phone.
|
not yet on the phone.
|
||||||
- [ ] **Composed/typed text never becomes visible at all -- found
|
- [x] **Composed/typed text never becomes visible at all -- root-caused
|
||||||
2026-09-06, not fixed.** The composer bar stays empty even once the
|
and fixed 2026-09-06.** Not the renderer at all: **the composer's buffer
|
||||||
|
was empty the whole time.** `TextEditCtx::select` (`iris/src/widget/
|
||||||
|
text/edit.rs`) compared the tap against the *laid-out text's* box and
|
||||||
|
set `selection = None` for anything outside it -- and an empty field's
|
||||||
|
layout is a zero-width box, so tapping an empty composer granted focus
|
||||||
|
and opened the keyboard while leaving no caret; `insert_str` returns
|
||||||
|
early with no caret, so every keystroke after that was dropped in
|
||||||
|
silence. Gboard's suggestion strip is its own composing state, not a
|
||||||
|
read of our buffer, which is what made the earlier pass conclude the
|
||||||
|
buffer held the text. Fixed by letting parley clamp a tap outside the
|
||||||
|
layout to the nearest cursor position (a press that reaches `select`
|
||||||
|
has already been hit-tested to the widget, so there is no "outside"),
|
||||||
|
plus a `debug_assert!` in `insert_str` so an insert with no caret fails
|
||||||
|
at the mistake instead of dropping input -- it immediately caught
|
||||||
|
`layout_tests::composing_text_after_a_keyboard_resize_...` typing into
|
||||||
|
an unfocused field. Three new tests in `edit.rs`
|
||||||
|
(`tapping_an_empty_field_places_a_caret_so_typing_lands`,
|
||||||
|
`tapping_past_the_end_of_the_text_clamps_to_the_end`,
|
||||||
|
`dragging_without_a_previous_selection_selects_nothing`); the first
|
||||||
|
fails on the pre-fix code. Emulator evidence: `adb shell input text`
|
||||||
|
after `tap 'Message'` now shows the text in the bar
|
||||||
|
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
|
||||||
|
glyphs=5`, against `glyphs=0` on every keystroke before.
|
||||||
|
|
||||||
|
**The old, superseded diagnosis, kept because it was wrong in an
|
||||||
|
instructive way:** The composer bar stays empty even once the
|
||||||
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
||||||
own suggestion strip reacts correctly to each keystroke). A new unit
|
own suggestion strip reacts correctly to each keystroke). A new unit
|
||||||
test proves the widget tree's own layout math resolves the field's
|
test proves the widget tree's own layout math resolves the field's
|
||||||
@@ -176,12 +201,290 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||||
inset) -- structurally in place and unit-tested, but its own visual
|
inset) -- structurally in place and unit-tested, but its own visual
|
||||||
correctness cannot be screenshotted until text actually renders.
|
correctness cannot be screenshotted until text actually renders.
|
||||||
- [ ] **The composer has no touch-drag scroll for overflowing text.** The
|
- [x] **The composer has no touch-drag scroll for overflowing text.**
|
||||||
2026-09-06 rebuild caps the field at ~6 lines and wraps it in
|
**Done 2026-09-06.** `field.scrollable().masked()` in
|
||||||
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over
|
`transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
|
||||||
text that has overflowed the cap does not scroll it -- `Scroll`'s touch
|
message, the bar stays capped at six lines, and a vertical drag in the
|
||||||
handling is a follow-up, the same shape `List`'s own touch-drag pan
|
focused field no longer extends a selection (Android `EditText`'s own
|
||||||
needed before I3/I5.
|
behaviour). Verified on this checkout's emulator with the
|
||||||
|
`transcript-screen bench force-gles` debug build -- six repetitions of a
|
||||||
|
13-word sentence typed in, then
|
||||||
|
`ui-trace record --do "swipe 540 1200 540 1460 300"`: the field's
|
||||||
|
`Message` box moved `31,1041..1048,1509` -> `31,1131..1048,1651` (the
|
||||||
|
content panned down with the finger) with its **height unchanged at
|
||||||
|
468px** (the bar did not grow), and the two screenshots either side show
|
||||||
|
different text in the same band.
|
||||||
|
Three real defects had to be fixed first, each with a headless
|
||||||
|
regression test in `iris/src/layout_tests.rs` and each confirmed to fail
|
||||||
|
without its fix (docs/RUST.md's plan box has the measurements):
|
||||||
|
a `MaxSize` reporting its cap as an unresolved `dp` (`Len::fold_dp`), a
|
||||||
|
`Masked` allocating a fresh mask slot per draw (`ActiveData::own_mask`),
|
||||||
|
and a panned widget's own hit box moving twice (`move_applied`).
|
||||||
|
`Scroll` itself turned out to measure the right number by a misleading
|
||||||
|
route -- it is written against `painter.px_size()` now, and the claim
|
||||||
|
below that it "measures against the window" was wrong.
|
||||||
|
**The grey background was not missing** -- that note (written here on
|
||||||
|
2026-09-06 and repeated as still open) is withdrawn. Re-measured the
|
||||||
|
same day on the same AVD by decoding the screencap rather than reading
|
||||||
|
it: the bar is `rgb(41,40,49)`, the declared `UiColor::new(40, 40, 46)`
|
||||||
|
after sRGB rounding, **full width and y2245..y2365** on 1080x2424, with
|
||||||
|
the field at `31,2277..1048,2329` and the 63px nav strip below it. It
|
||||||
|
is dark by design and sits on black, which is very likely what the
|
||||||
|
earlier reading was: at a glance the band and the background are hard
|
||||||
|
to tell apart. If it should read as a bar rather than as a slightly
|
||||||
|
different black, the colour is the thing to change, not the tree.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
||||||
|
|
||||||
|
Iris's report on the build with the composing-text, tap-vs-swipe and
|
||||||
|
atlas-reset fixes, with a screenshot, verbatim. Each is open until an
|
||||||
|
agent ticks it here with the evidence.
|
||||||
|
|
||||||
|
- [x] **"The app definitely does not start with keyboard spacing
|
||||||
|
correct. This is how it looks without me doing anything initially."**
|
||||||
|
**Not an inset bug at all -- fixed 2026-09-06.** The black third is the
|
||||||
|
bench shell's own empty *benchmark report* pane: `bench_client.rs`'s
|
||||||
|
root tree gave it `.height(rest(1))` beside `content.height(rest(2))`,
|
||||||
|
so an empty `TextEdit` reserved a third of the window at every launch
|
||||||
|
and pushed the composer up by exactly that. Measured on this checkout's
|
||||||
|
emulator at the phone's own size (1080x2424, density 420, gesture nav),
|
||||||
|
which reproduced Iris's screenshot exactly: new `iris insets:` log line
|
||||||
|
reported `bottom=63 ime_bottom=0` at launch (a nav bar, no keyboard --
|
||||||
|
so the inset the composer was fed was never large), while `ui-trace
|
||||||
|
show -m Message --field box` put the field at `31,1488..1048,1540` on a
|
||||||
|
2282px-tall surface, 789px clear of the bottom -- that pane's third.
|
||||||
|
**Unit mixing checked explicitly and cleared**: `set_bottom_inset` takes
|
||||||
|
physical px and stores `Len::abs`, `MainActivity.java`'s `1`/`0`
|
||||||
|
`ime_bottom` only ever reaches `insets.bottom.max(ime_bottom)` and
|
||||||
|
`> 0.0`, and every `dp` in the composer resolves at layout time. Fix:
|
||||||
|
the report pane is sized to its content (`.max_height(dp(260))
|
||||||
|
.scrollable()`), and moved above the transcript so it cannot eat the
|
||||||
|
composer's nav-bar clearance. After: field box `31,2277..1048,2329`,
|
||||||
|
grey bar ending at device y2361 with the 63px nav strip below it
|
||||||
|
(`/tmp/fix1.png` this pass).
|
||||||
|
The screenshot shows the composer bar (the grey band) sitting about
|
||||||
|
two thirds of the way down a 704x1568 screen, with black below it to
|
||||||
|
the bottom, and the transcript ending at "Claude / Results" just above
|
||||||
|
it -- at launch, no keyboard. So the composer's bottom padding, which
|
||||||
|
the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a
|
||||||
|
large value at start on the phone. Suspects, in order: the initial
|
||||||
|
inset delivery on the phone (GrapheneOS, gesture navigation) versus
|
||||||
|
the emulator; `ime_bottom` now carrying a `1`/`0` boolean through a
|
||||||
|
field the composer may still read as pixels or dp; a stale value from
|
||||||
|
before the first `on_insets_changed`. Reproduce with the phone's
|
||||||
|
screen size and density on the emulator before guessing.
|
||||||
|
- [~] **"Swiping still gets caught by the grey bar but keeps working
|
||||||
|
after I go past it."** Improved 2026-09-06 by the focused-field rule
|
||||||
|
below, still needs her phone to close. `attr.rs`'s `on_press` treated an
|
||||||
|
already-focused composer as the plain drag-to-select case, so a swipe
|
||||||
|
starting inside it dragged a highlight through the typed text for the
|
||||||
|
whole gesture; it now abandons that the moment the press passes
|
||||||
|
`DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one
|
||||||
|
of the two things that made the bar feel like it caught the swipe. The
|
||||||
|
residual `DRAG_SLOP` measured from the boundary crossing, described
|
||||||
|
below, is unchanged. Original note follows.
|
||||||
|
Not closeable from the emulator, annotated
|
||||||
|
2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
|
||||||
|
calls `capture_pointer` and never consumes a `Pressing` frame past
|
||||||
|
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
|
||||||
|
position leaves the composer's box and enters the list's, `List`
|
||||||
|
starts receiving ordinary hit-tested `Pressing` frames there --
|
||||||
|
`DragArbiter::is_idle()`'s 2026-09-05 recovery (a missed `PressStart`)
|
||||||
|
picks it up rather than leaving it stuck. What this does **not** do is
|
||||||
|
what "wherever it began" implies literally: `DragArbiter::press_start`
|
||||||
|
restarts from the *boundary-crossing* position, not from the original
|
||||||
|
touch-down inside the composer, so the pan still needs a fresh
|
||||||
|
`DRAG_SLOP` of travel measured from the boundary rather than from the
|
||||||
|
start of the gesture -- composer and list are adjacent, non-overlapping
|
||||||
|
widgets (`lib.rs`'s `(list, composer_bar).span(Dir::DOWN)`), and only
|
||||||
|
the composer forwarding its own drag to the list would remove that
|
||||||
|
residual slop entirely, which is more than this pass's merge changes.
|
||||||
|
RUST.md's merge-pass box has the reasoning in full and an emulator
|
||||||
|
swipe confirming the composer's own box never moves/resizes during it;
|
||||||
|
whether the residual slop is still perceptible as "caught" needs Iris's
|
||||||
|
phone, since the emulator's per-widget boundary is a few dp wide and
|
||||||
|
easy to cross without noticing on a real screen too.
|
||||||
|
- [ ] **"Flinging still does not work."** No longer expected to reproduce
|
||||||
|
after the `DragGesture` merge (`e12c708`, pointer capture +
|
||||||
|
`CursorSense::Drop`), 2026-09-06. Emulator evidence (RUST.md's
|
||||||
|
merge-pass box, check (b)): a real `ui-trace` finger swipe followed by
|
||||||
|
screenshot-hash sampling caught a post-release frame distinct from the
|
||||||
|
drag's own last frame in one run, and every run showed 28-32
|
||||||
|
`render()` frames per gesture against an idle baseline of 0 and ~8
|
||||||
|
expected from the drag alone -- redraw kept being requested well past
|
||||||
|
the finger lifting, which only happens while a fling is still
|
||||||
|
animating. Left unticked in spirit until Iris's phone confirms it,
|
||||||
|
since only she can say whether it *feels* like a fling now; the
|
||||||
|
emulator's screenshot timing could not always catch the tail of a
|
||||||
|
fast-settling one visually (same caveat noted in RUST.md).
|
||||||
|
- [~] **"Text still disappears if I leave and come back to the app."**
|
||||||
|
**Instrumented 2026-09-06 so the phone can answer it**, since no
|
||||||
|
emulator here has a Vulkan adapter. `iris/src/android/view.rs` now logs
|
||||||
|
one `log::info!` line per surface event with the glyph/atlas counts:
|
||||||
|
`iris surface: surface_destroyed, tearing the renderer down
|
||||||
|
(glyphs_cached=387 atlas_pages=1)`, `iris surface: surface_changed
|
||||||
|
1080x2424 already_live=false glyphs_cached=387 atlas_pages=1`, `iris
|
||||||
|
surface: new renderer built (Gl), clearing glyph atlas: glyphs=387
|
||||||
|
pages=1`, plus `iris insets: ... window=(1080, 2424)` on every insets
|
||||||
|
change. That is the emulator's own healthy app-switch cycle, verified
|
||||||
|
this pass (home, reopen, screenshot: all text intact,
|
||||||
|
`/tmp/appswitch.png`). **The one line to look for on the phone is
|
||||||
|
`already_live=`**: `true` on the return from backgrounding would mean
|
||||||
|
the surface came back *without* a `surface_destroyed`, so
|
||||||
|
`surface_changed` reconfigured a renderer whose Vulkan swapchain and
|
||||||
|
atlas textures belong to a window that is gone -- the reuse branch
|
||||||
|
never clears the atlas, by design. `false` with no `new renderer built`
|
||||||
|
line after it would mean the renderer failed to rebuild. Either answer
|
||||||
|
names the fix; guessing between them from here does not.
|
||||||
|
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
|
||||||
|
emulator under `force-gles` only; the phone runs Vulkan. So either the
|
||||||
|
reset is not reached on the phone's path (a different surface-
|
||||||
|
lifecycle sequence -- `surface_destroyed`/`surface_created` ordering,
|
||||||
|
or the renderer not being rebuilt but its textures lost), or the CPU
|
||||||
|
glyph cache and the GPU atlas still disagree after it. Needs logging
|
||||||
|
of the renderer lifecycle on the phone build, readable from `adb
|
||||||
|
logcat` when Iris next runs it, since no emulator here has a Vulkan
|
||||||
|
adapter under host GPU.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-06, 22:16 (build from 20303e0, delivered via ai-app-bench 95e25fe)
|
||||||
|
|
||||||
|
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||||
|
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||||
|
|
||||||
|
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||||
|
*"flinging now does technically do something, but it seems to just be
|
||||||
|
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||||
|
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||||
|
-- a constant-speed slide for the whole duration, then a stop at full
|
||||||
|
distance -- because two halves of AOSP's spline build loop were
|
||||||
|
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
|
||||||
|
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
|
||||||
|
between even time steps. The two cancelled to the identity. Ported
|
||||||
|
exactly now from `OverScroller.java` and
|
||||||
|
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||||
|
(they agree line for line), with `iris/benches/fling_spline_reference.py`
|
||||||
|
as an independent transcription supplying the numbers the tests assert
|
||||||
|
on. Emulator, 2026-09-07: a released `v=3750` decelerates
|
||||||
|
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
|
||||||
|
frames; a flick into the end of the list stops there in one tick with no
|
||||||
|
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
|
||||||
|
**Open until the phone says so** -- a flick should now visibly slow
|
||||||
|
before it stops. Its earlier three defects (the velocity, the missing
|
||||||
|
animation registration, the 56x coefficient) are all still fixed and were
|
||||||
|
never the linear part.* Second report; the emulator's
|
||||||
|
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
||||||
|
a finger on the phone does not. What differs: a real flick at 120Hz is
|
||||||
|
batched by Android into few `MotionEvent`s with *historical* samples
|
||||||
|
(`getHistoricalX/Y/EventTime`), and can be DOWN, one or two MOVEs, UP
|
||||||
|
inside `DRAG_SLOP`'s worth of frames; a `ui-trace` swipe is many
|
||||||
|
evenly-spaced MOVEs. Suspects, in order: `android/sense.rs` reading
|
||||||
|
only each event's final position (the velocity tracker sees two
|
||||||
|
samples, or one); the release path starting a fling only from a
|
||||||
|
gesture already in `Panning`, so a flick that crosses the slop on its
|
||||||
|
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
|
||||||
|
delivering no `Drop`. Log the release decision (samples, span,
|
||||||
|
velocity, outcome) at `info` so the next logcat settles it.
|
||||||
|
- [x] **"I can't reopen keyboard by tapping on message box after it
|
||||||
|
already happened once."** *(Fixed 2026-09-07: `attr.rs`'s already-
|
||||||
|
focused branch calls `focus_gained` on a tap inside `DRAG_SLOP`.
|
||||||
|
Emulator: first tap `mInputShown=true`, back gesture, second tap
|
||||||
|
`mInputShown=true`. Negative control with that one call removed leaves
|
||||||
|
the second tap at `false`; a horizontal and a vertical swipe over the
|
||||||
|
focused field both leave it at `false`, so the earlier "swiping over
|
||||||
|
the input bar brings up the keyboard" has not returned.)* The field stays focused after the keyboard
|
||||||
|
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
|
||||||
|
already-focused branch never requests the IME again. Android's
|
||||||
|
`EditText` shows the IME on every tap of a focused field; do the same
|
||||||
|
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||||
|
when it is already shown).
|
||||||
|
- [ ] **"Message box does not push up the scroll area."**
|
||||||
|
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||||
|
raising up does not push things upwards"* -- after being ticked on
|
||||||
|
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||||
|
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
|
||||||
|
what was wrong is one line of `iris/android-app/app/build.gradle`:
|
||||||
|
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
|
||||||
|
`app/` targets 37 and *does* push up on her phone. Below target 35 the
|
||||||
|
window keeps the legacy behaviour, where `adjustResize` shrinks it for
|
||||||
|
the IME and `getInsets(ime()).bottom` therefore measures zero;
|
||||||
|
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
|
||||||
|
the API 36 emulator here, which is why every test run passed. Now
|
||||||
|
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
|
||||||
|
where only the animation path carries the height -- which also makes the
|
||||||
|
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
|
||||||
|
jump). **This is a reading, not a measurement**: no Android 17 device is
|
||||||
|
reachable from here. So the Diagnostics pane now prints
|
||||||
|
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
|
||||||
|
**screenshot that line with the keyboard open.** `ime_bottom` in the
|
||||||
|
hundreds and the composer risen means fixed; `dispatches` climbing with
|
||||||
|
`ime_bottom=0` means the reading was wrong and the window is still being
|
||||||
|
resized; `dispatches=0` means the listener is not firing at all, which is
|
||||||
|
a third thing again.* Since
|
||||||
|
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||||
|
longer resizes the window, so the app owns the IME inset -- but
|
||||||
|
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
||||||
|
2026-09-06 "(b)" fix), so nothing has the inset's *height* to pad the
|
||||||
|
transcript and composer with. Pass both: `isVisible(ime())` and
|
||||||
|
`getInsets(ime()).bottom` in px; the list's bottom padding and the
|
||||||
|
composer's position follow the height, the visibility drives the
|
||||||
|
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
|
||||||
|
describes.
|
||||||
|
- [x] **"Picture is what happens if I leave the app and come back,
|
||||||
|
which completely removes text, and then I tap on the debug info. The
|
||||||
|
textures are definitely getting cooked for some reason after leaving
|
||||||
|
the app and resuming."** Screenshot: every glyph drawn *before* the
|
||||||
|
resume is fragments; the diagnostics text drawn *after* is perfect;
|
||||||
|
the report says `atlas format: Rgba8Unorm, views live: 0`. Reading:
|
||||||
|
`Textures::reset`/`GlyphAtlas::clear` on the new renderer emptied the
|
||||||
|
GPU atlas, but the per-widget cached text primitives (`TextView`'s
|
||||||
|
render cache -- the one `c3cfc67`'s shape counter is keyed on) still
|
||||||
|
carry the old atlas coordinates and are re-submitted as-is; only
|
||||||
|
widgets drawn fresh after the resume shape and upload again. Fix: a
|
||||||
|
renderer rebuild invalidates every cached text render (one
|
||||||
|
generation counter on the atlas, checked at `TextView::render`, or
|
||||||
|
a full-tree redraw with caches dropped), with a `debug_assert!` that
|
||||||
|
no submitted glyph quad references an atlas generation older than the
|
||||||
|
live one. Reproducible on the emulator by forcing a renderer rebuild
|
||||||
|
(home + return, or `surface_destroyed`/`surface_created`) on a screen
|
||||||
|
with text already drawn -- the earlier "verified" home/reopen check
|
||||||
|
screenshotted the emulator's GLES path, where a resume may not
|
||||||
|
destroy the surface at all.
|
||||||
|
|
||||||
|
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||||
|
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||||
|
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||||
|
not destroy the surface at all -- so the phone was the only place this
|
||||||
|
could be answered, and it has been. `clearing_the_atlas_re_renders_
|
||||||
|
cached_text_instead_of_reusing_it` is what keeps it.
|
||||||
|
|
||||||
|
The reading above is right and the mechanism is one step narrower than
|
||||||
|
"cached text primitives". `IrisViewPeer::surface_changed`
|
||||||
|
(`iris/src/android/view.rs`) *does* already force a full-tree redraw
|
||||||
|
after a rebuild: it calls `render.resize(...)` unconditionally, which
|
||||||
|
sets `UiRenderState::resized`, which makes the next `update` take
|
||||||
|
`redraw_all` rather than `redraw_updates`. So every widget's `draw`
|
||||||
|
really does run again after the resume. What survives it is one cache
|
||||||
|
further in: `TextView::render` (`iris/src/widget/text/mod.rs`) returns
|
||||||
|
its cached `RenderedText` whenever the wrap width, buffer and attrs are
|
||||||
|
unchanged -- true of every pre-resume row -- so `TextData::place` is
|
||||||
|
never reached, nothing is re-rasterised into the fresh atlas, and the
|
||||||
|
*old* atlas's `uv_min`/`uv_max`/`layer` are re-submitted verbatim. Only
|
||||||
|
text whose content changed after the resume (the diagnostics pane Iris
|
||||||
|
tapped) re-shapes, which is exactly the split in her screenshot.
|
||||||
|
`Painter::glyphs` has one call site in the whole workspace, that one,
|
||||||
|
so there is no second holder of a `RenderedText` to fix.
|
||||||
|
|
||||||
|
The fix, in `ba2afba`: `GlyphAtlas::generation`, bumped by
|
||||||
|
`GlyphAtlas::clear`; `RenderedText::generation` recording which atlas
|
||||||
|
its glyphs were placed against; `Painter::atlas_generation()`;
|
||||||
|
`TextView::render`'s cache key gains it; and a `debug_assert_eq!` in
|
||||||
|
`Painter::glyphs` that a submitted quad's generation is the live one.
|
||||||
|
Headless test
|
||||||
|
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
|
||||||
|
(`iris/src/widget/text/mod.rs`): draw, `atlas.clear()`, `resize`, draw
|
||||||
|
again, and assert the atlas holds the same glyph count again -- it
|
||||||
|
stays at 0 without the fix, because the cache short-circuits before
|
||||||
|
`place`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -372,22 +675,31 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
||||||
stable per row (its sender + a short excerpt, matching what a screen
|
stable per row (its sender + a short excerpt, matching what a screen
|
||||||
reader announcing a chat message would say).
|
reader announcing a chat message would say).
|
||||||
- [ ] **A tappable link and a background chip behind inline code.**
|
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
|
||||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
byte_at(pos, size)` answers which byte a tap landed on without
|
||||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
handing out the parley layout, `GestureOutcome::Tapped` says the
|
||||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
press committed to neither a pan nor a selection, and
|
||||||
would take (the same primitive `TextEdit::draw`'s own selection
|
`iris::platform::OpenUrl` is the capability each backend implements
|
||||||
highlight already uses internally,
|
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
|
||||||
`iris/src/widget/text/edit.rs:99`).
|
deferred to `after_input` the way `pending_show_keyboard` is).
|
||||||
|
- [ ] **A background chip behind inline code.** Still needs per-range
|
||||||
|
glyph *geometry* — a run's boxes, not one offset — which
|
||||||
|
`TextEditCtx` does not expose outside `iris::widget::text`
|
||||||
|
(`edit.rs`'s `layout()` helper is private). The same primitive
|
||||||
|
`TextEdit::draw`'s own selection highlight uses internally,
|
||||||
|
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
|
||||||
|
not open that up: a tap needs one offset and a chip needs the run.
|
||||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||||
is selected in full (`select_all`) the moment the drag leaves it,
|
is selected in full (`select_all`) the moment the drag leaves it,
|
||||||
rather than "from the click point to whichever edge points away from
|
rather than "from the click point to whichever edge points away from
|
||||||
the drag" — needs the same private `layout()` access as the item
|
the drag" — needs the same private `layout()` access as the item
|
||||||
above. `selection.rs`'s module doc has the exact reasoning.
|
above. `selection.rs`'s module doc has the exact reasoning.
|
||||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
- [x] **Syntax highlighting inside a fenced code block** — done
|
||||||
`client_core::highlight` exists (built for the file explorer) and
|
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
|
||||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
converted from its char indices to `SpanStyle`'s byte offsets, in
|
||||||
in was not attempted this pass.
|
the same Catppuccin palette `Theme.kt` uses. A language the scanner
|
||||||
|
has no rules for stays plain rather than being coloured by the
|
||||||
|
nearest one's.
|
||||||
|
|
||||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
by something *and also* applies mask B — a mask can reference a parent
|
||||||
@@ -407,6 +719,109 @@ agent takes them without colliding with that pass's `bench_client.rs`/
|
|||||||
everything, the same way input is**. Whatever the mechanism, a widget
|
everything, the same way input is**. Whatever the mechanism, a widget
|
||||||
that does not animate must pay nothing and import nothing for it.
|
that does not animate must pay nothing and import nothing for it.
|
||||||
|
|
||||||
|
## Found by P1a (2026-09-06)
|
||||||
|
|
||||||
|
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
|
||||||
|
fills whatever region it is handed, so `draw_inner`'s size-independent
|
||||||
|
fast path -- which rewrites primitives with
|
||||||
|
`r.outside(&from).within(®ion)` rather than redrawing -- could not
|
||||||
|
reproduce its `draw`, and a `.background(rect(..))` kept the size of
|
||||||
|
the *provisional* full-region pass `Span` does in phase 1. One fenced
|
||||||
|
code block's panel covered every block below it and every row below
|
||||||
|
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
|
||||||
|
definition. Suspect the same cause for anything else tinted with a
|
||||||
|
background rect.
|
||||||
|
- [x] **A wrapped transcript row tripped `reposition`'s debug assert.**
|
||||||
|
Settled 2026-09-06 by giving the move slot one owner instead of two.
|
||||||
|
`mov` accumulates a delta on it, `reposition` overwrote it, and both
|
||||||
|
legitimately land on one widget in one frame: `List::place`'s
|
||||||
|
Bottom-known branch offers a row a same-size box that has *moved*
|
||||||
|
(`mov`), then corrects the placement inside it when the row's cached
|
||||||
|
height no longer matches what the row reports (`reposition`). The
|
||||||
|
slot now always means `move_applied + repositioned`
|
||||||
|
(`ActiveData::repositioned`, `iris/core/src/ui/render_state.rs`), so
|
||||||
|
`reposition` adds the move rather than dropping it -- the assert is
|
||||||
|
gone and the arithmetic is right. Test:
|
||||||
|
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
|
||||||
|
in `layout_tests.rs`, which lands the child at the *offered* position
|
||||||
|
(-100px) instead of the placement (100px) without the fix, and a
|
||||||
|
`debug_assert_eq!` in `reposition` that nothing but those two ever
|
||||||
|
writes the slot. Verified with the `.wrap(true)` repro (draws, no
|
||||||
|
panic) and an emulator bench run with assertions live.
|
||||||
|
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
|
||||||
|
the shader writes the palette's bytes as linear.** Mocha Crust
|
||||||
|
(17,17,27) is drawn as (73,73,91), measured off
|
||||||
|
`run-headless.sh --shot`. Android is correct, so this is the surface
|
||||||
|
format rather than the palette -- but it makes the desktop build
|
||||||
|
useless as a colour reference, which is exactly what P1a needed it for
|
||||||
|
when the emulator could not draw glyphs.
|
||||||
|
- [x] **Every glyph was a solid box on the GLES backend -- iris's bug,
|
||||||
|
not the emulator's.** Fixed 2026-09-06. The atlas is one
|
||||||
|
`texture_2d_array` and `GpuTextures::new` created it with **one
|
||||||
|
layer**; wgpu-hal picks the GL target from the descriptor
|
||||||
|
(`(false, 1) => TEXTURE_2D`), so under GLES that array was a
|
||||||
|
`GL_TEXTURE_2D` bound to the shader's `sampler2DArray`, the unit was
|
||||||
|
incomplete, every `textureSample` returned (0,0,0,1), and
|
||||||
|
`draw_glyph`'s `color.a *= texel.a` filled the quad. `MIN_ARRAY_LAYERS
|
||||||
|
= 2` in `iris/core/src/render/texture.rs`, with a `debug_assert!` at
|
||||||
|
`create_array_texture`. Vulkan (the phone, the desktop's default
|
||||||
|
backend) was never affected. Reproduce the class in seconds without an
|
||||||
|
emulator: `iris`'s `force-gles` feature now switches the **desktop**
|
||||||
|
backend too -- `./run-headless.sh transcript --shot /tmp/x.png -- -p
|
||||||
|
transcript-ui --features iris/force-gles`.
|
||||||
|
|
||||||
|
- [ ] **The bench report pane draws over the transcript rows instead of
|
||||||
|
replacing them.** Visible on the emulator for the first time now that
|
||||||
|
glyphs render there (`/tmp/emu-final.png`, 2026-09-06): after a bench
|
||||||
|
run the report's lines and the transcript's occupy the same rows in the
|
||||||
|
top third of the screen, both legible, neither on top. Pre-existing --
|
||||||
|
the same overlap is in a screenshot taken before the move-slot fix -- so
|
||||||
|
it is its own item, most likely the report pane not masking or not
|
||||||
|
claiming its region.
|
||||||
|
|
||||||
|
## Found by P1b (2026-09-06), all with a headless repro
|
||||||
|
|
||||||
|
Each was found by looking at `iris/run-headless.sh transcript -- -p
|
||||||
|
transcript-ui` rather than at a diff, and each is worked around in
|
||||||
|
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
|
||||||
|
has the fuller account.
|
||||||
|
|
||||||
|
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
|
||||||
|
children a slot out of step.** Each child drew its content one sibling's
|
||||||
|
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
|
||||||
|
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
|
||||||
|
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
|
||||||
|
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
|
||||||
|
removing the inner `Span` fixes it, and so does removing the children's
|
||||||
|
own `Pad`; the background `Stack`, the `Sized` wrappers and the
|
||||||
|
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
|
||||||
|
`reposition` fault f5b8893 fixed -- it survives that commit. The
|
||||||
|
workaround costs the group the 4dp inset its Compose counterpart holds
|
||||||
|
its cards off the edge by, so this is worth fixing.
|
||||||
|
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
|
||||||
|
The panel is drawn and the text inside it is not. A markdown fence does
|
||||||
|
the same to a `TextEdit` and is fine, so it is the widget kind rather
|
||||||
|
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
|
||||||
|
fixed, which means a long command is clipped rather than pannable.
|
||||||
|
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
|
||||||
|
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
|
||||||
|
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
|
||||||
|
nothing on screen says it was cut. Whichever end is cut has to be a
|
||||||
|
choice when this lands: a path is identified by its tail, a command by
|
||||||
|
its head.
|
||||||
|
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
|
||||||
|
because a chevron from a font is a glyph a system font may not have --
|
||||||
|
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
|
||||||
|
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
|
||||||
|
monospace face as a result. A real fix needs a line/path primitive;
|
||||||
|
iris has rects, text and textures only.
|
||||||
|
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
|
||||||
|
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
|
||||||
|
a card registers. Compose's `SelectionContainer` covers tool output,
|
||||||
|
which is the text people most want to copy. Needs a key for "the nth
|
||||||
|
text of this row" that a card can mint without colliding with a
|
||||||
|
message's blocks.
|
||||||
|
|
||||||
## Build (for the port)
|
## Build (for the port)
|
||||||
|
|
||||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||||
@@ -486,8 +901,23 @@ do not duplicate it there.
|
|||||||
|
|
||||||
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
||||||
|
|
||||||
- [ ] **Streaming a delta into a long message costs a full text layout of
|
- [x] **Streaming a delta into a long message costs a full text layout of
|
||||||
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
that message.** **Done 2026-09-06** -- a row is a column of one
|
||||||
|
`TextEdit` per markdown block (`client_core::markdown_blocks`,
|
||||||
|
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
|
||||||
|
keeps every earlier block's layout. A block is the selection unit now
|
||||||
|
(`Selection`'s `SelKey`); selection across blocks and rows still works,
|
||||||
|
checked on the emulator with a real long-press drag. Pass condition met
|
||||||
|
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
|
||||||
|
a delta into a 100-paragraph reply redraws the same widget count as one
|
||||||
|
into a one-paragraph reply (30 either way). Emulator stream phase, same
|
||||||
|
AVD before and after: **p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99
|
||||||
|
342.6 -> 137.4ms, worst 403.6 -> 143.0ms**, 202 -> 293 frames in the same
|
||||||
|
21 seconds. docs/RUST.md's Task B box has the detail and the two dead
|
||||||
|
ends. **The phone is the measurement that decides it** -- these are
|
||||||
|
emulator numbers and only the ratio transfers.
|
||||||
|
|
||||||
|
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
||||||
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
||||||
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
||||||
the last row, but that row is the growing message, and replacing it
|
the last row, but that row is the growing message, and replacing it
|
||||||
@@ -501,3 +931,27 @@ do not duplicate it there.
|
|||||||
p50 dropping below Compose's on the phone. Do this after the four bench
|
p50 dropping below Compose's on the phone. Do this after the four bench
|
||||||
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
||||||
closed, since they are what make the run unrepresentative today.
|
closed, since they are what make the run unrepresentative today.
|
||||||
|
|
||||||
|
## From the phone, 2026-09-07 (build from ed04d4c)
|
||||||
|
|
||||||
|
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
|
||||||
|
them."** Two screenshots of the bench app's transcript at the top
|
||||||
|
edge, both wrong in opposite directions: in one, rows scrolled above
|
||||||
|
the viewport are still drawn and bleed *through* the header bar
|
||||||
|
(`version = "0.1.0"` and a paragraph visible behind "Run benchmark /
|
||||||
|
Copy report / Diagnostics"), so the list's mask is not clipping at
|
||||||
|
the header's bottom edge; in the other, scrolled a little further,
|
||||||
|
the row that straddles the top edge is not drawn at all -- black from
|
||||||
|
the header down to "You", where the previous shot showed a paragraph
|
||||||
|
-- so a row is culled as soon as its *top* leaves the viewport rather
|
||||||
|
than when its *bottom* does. Suspects: the list's visible-range test
|
||||||
|
(`iris/src/widget/list.rs`) comparing a row's top against the
|
||||||
|
viewport top; the mask region for the transcript set from the
|
||||||
|
window rather than from the area under the header; and the two-phase
|
||||||
|
provisional/real draw noted in `03c6be8`'s header-duplicate
|
||||||
|
investigation, which was never root-caused and has the same shape.
|
||||||
|
Reproduce at layer 1 of the test rig: a headless screen with a row
|
||||||
|
straddling the top edge must place that row, and a primitive above
|
||||||
|
the header's bottom must be masked. Fix both with one rule: a row is
|
||||||
|
drawn if any part of it intersects the viewport, and the viewport is
|
||||||
|
the list's own region.
|
||||||
@@ -947,3 +947,95 @@ When this lands, copy this entry into `IRIS.md` (newest first):
|
|||||||
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
> design, the move-offset mechanism this shipped alongside, and the file
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
> list.
|
> list.
|
||||||
|
|
||||||
|
## Masks with a shape (decided 2026-09-07, not yet built)
|
||||||
|
|
||||||
|
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."
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Review: iris changes since 0e46293
|
||||||
|
|
||||||
|
Scope: `git diff 0e46293..HEAD -- iris/ client-core/` (58 files, +5224/-226).
|
||||||
|
Read-only review; no source changed. Ordered likely-bug, then invariant
|
||||||
|
guards, then rules, then tests/docs.
|
||||||
|
|
||||||
|
## Likely bugs
|
||||||
|
|
||||||
|
1. **`iris/transcript-ui/src/lib.rs:152-160` (`RowDiff::Rebuild` arm of
|
||||||
|
`TranscriptScreen::apply`) never unregisters the rows it drops from
|
||||||
|
`Selection`, so a stale `WeakWidget<TextEdit>` outlives the widget it
|
||||||
|
points to and the next touch on *any* row panics.**
|
||||||
|
`Selection::rows: BTreeMap<RowKey, WeakWidget<TextEdit>>` documents its
|
||||||
|
own contract at `selection.rs:69-71`: "every addition here needs its
|
||||||
|
removal ... called when `List` evicts the row." The `ReplaceLast` arm
|
||||||
|
above it honours this (`lib.rs:143-145`, `self.selection.borrow_mut()
|
||||||
|
.unregister(old_key)` when the key changes). The `Rebuild` arm calls
|
||||||
|
`(self.list)(rsc).clear()` and rebuilds every row from `new_rows`, but
|
||||||
|
never touches `self.selection` — any key present in `old_rows` and
|
||||||
|
*absent* from `new_rows` (exactly what `group_tool_runs` regrouping two
|
||||||
|
separate tool-call rows into one produces — see `diff_tests::
|
||||||
|
a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback`,
|
||||||
|
which tests the diff decision but not `apply` itself) is left in
|
||||||
|
`self.rows` pointing at a widget `List::clear()` just freed.
|
||||||
|
`TextEditable::edit` (`iris/src/widget/text/edit.rs:582-587`) resolves
|
||||||
|
that handle with `ui.widgets.get_mut(self).unwrap()` — an unconditional
|
||||||
|
panic on the freed slot. `Selection::begin` (`selection.rs:88-101`)
|
||||||
|
iterates *every* registered row (`w.edit(ui).deselect()`) on an
|
||||||
|
ordinary fresh press, so the crash fires on the next tap anywhere in
|
||||||
|
the transcript after a regroup, not only on a tap targeting the
|
||||||
|
orphaned row.
|
||||||
|
Fix: give `Selection` a way to reconcile against the row set that
|
||||||
|
survived a rebuild (e.g. `Selection::retain(&self, keys: &BTreeSet<RowKey>)`
|
||||||
|
removing everything else, called from the `Rebuild` arm before
|
||||||
|
rebuilding), or simplest — call `self.selection.borrow_mut()` cleared
|
||||||
|
the same way `List::clear()` clears the list, then let the rebuild's
|
||||||
|
`push_row` calls re-`register` everything as they already do.
|
||||||
|
|
||||||
|
## Guarded invariants missing
|
||||||
|
|
||||||
|
2. **`iris/src/widget/list.rs:751` (`List::place`) indexes/expects on
|
||||||
|
`slot` with no assertion that it exists.** `slot_widget` (`:563-575`)
|
||||||
|
panics via `.expect(...)` for a sentinel with no widget set, and does
|
||||||
|
an unchecked `&self.items[s as usize]` for a real index — a bare
|
||||||
|
"index out of bounds" with no context if `place` is ever reached with a
|
||||||
|
stale slot. Every current caller happens to derive `slot` from
|
||||||
|
`repair_anchor`/`prev_slot`/`next_slot`, which already check existence,
|
||||||
|
but that invariant is enforced by convention across three call sites,
|
||||||
|
not by the function that depends on it. Add
|
||||||
|
`debug_assert!(self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}");`
|
||||||
|
at the top of `place`.
|
||||||
|
3. **`iris/src/widget/list.rs:426` (`List::fling`) and `sense.rs`'s
|
||||||
|
`FlingCalculator::distance`/`duration`/`position_at` never check that
|
||||||
|
the incoming velocity is finite.** A `NaN`/`inf` velocity (a
|
||||||
|
`VelocityTracker::velocity()` divide-by-near-zero span, or a caller
|
||||||
|
passing a raw device value straight through) propagates through
|
||||||
|
`deceleration_for`'s `.ln()` silently — the fling either never settles
|
||||||
|
(`settled_on_schedule` compares against a `NaN` `duration()`, which is
|
||||||
|
always `false`) or jumps to `NaN` positions with nothing on screen
|
||||||
|
saying why. Add `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||||
|
`List::fling` and `FlingCalculator::new`/`distance`.
|
||||||
|
4. **`iris/src/sense.rs:592-604` (`VelocityTracker::velocity`) has no
|
||||||
|
assertion that samples are chronological.** `add_sample` trusts its
|
||||||
|
caller's `Instant` ordering; a caller that samples out of order (a
|
||||||
|
restored/replayed gesture, a test) would silently produce a negative
|
||||||
|
`span` handled only by the `span <= 0.0 => 0.0` catch-all, masking the
|
||||||
|
bug that produced it rather than surfacing it. Add
|
||||||
|
`debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last))`
|
||||||
|
in `add_sample`.
|
||||||
|
5. **`iris/core/src/render/frame_report.rs:247-252` (`mark_phase`) has no
|
||||||
|
assertion that phases are pushed in non-decreasing `start_index`
|
||||||
|
order.** `phase_stats`'s slicing (`:274`, `idx >= phase.start_index &&
|
||||||
|
idx < end_index`) silently produces an empty or nonsensical slice for
|
||||||
|
an out-of-order phase rather than surfacing the misuse — cheap to add
|
||||||
|
given `self.phases.last()` is already in scope:
|
||||||
|
`debug_assert!(self.phases.last().is_none_or(|p| self.total_frames >= p.start_index));`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
6. **Two mechanisms answer "what row selection points at, still valid?"**
|
||||||
|
`Selection` relies on callers remembering to `unregister` (finding 1);
|
||||||
|
`List` relies on callers deriving slots only from already-checked
|
||||||
|
sources (finding 2). Both are the same class of problem — a derived
|
||||||
|
handle that silently outlives what it points to — solved ad hoc twice
|
||||||
|
rather than once. Not asking for a shared abstraction here, but the two
|
||||||
|
should at minimum cross-reference each other's doc comment so the next
|
||||||
|
caller who adds a third handle-into-`List`-rows type (the code rules'
|
||||||
|
"a rule that governs a set belongs to the set") finds both existing
|
||||||
|
examples.
|
||||||
|
7. **`iris/android-app/src/bench_client.rs:224-225` (`battery_line`)
|
||||||
|
calls `.min().unwrap()`/`.max().unwrap()` on `samples` guarded three
|
||||||
|
lines above by `if samples.is_empty()`, which is fine — but the guard
|
||||||
|
and the two unwraps are two statements apart with a `let mean = ...`
|
||||||
|
in between reading the same slice; a future edit reordering those
|
||||||
|
lines loses the guard's protection silently.** Low severity (this is
|
||||||
|
the bench tool, not the app), but worth a one-line comment tying the
|
||||||
|
unwraps back to the guard, or restructuring as
|
||||||
|
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`
|
||||||
|
pattern so the empty case can't be separated from the check by a future
|
||||||
|
edit.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
8. **No test exercises `TranscriptScreen::apply`'s `Rebuild` arm through
|
||||||
|
`Selection`.** `lib.rs`'s `diff_tests` module (`:284-379`) tests only
|
||||||
|
the pure `diff_rows` decision function, never `apply` itself wired to a
|
||||||
|
real `Selection`; `selection.rs`'s own tests (`a_missed_press_start_
|
||||||
|
recovers_on_the_next_pressing_frame`, `unregister_forgets_the_row_and_
|
||||||
|
clears_a_matching_anchor`) never go through `apply`/`List::clear`
|
||||||
|
either. This is exactly the gap that let finding 1 through: the two
|
||||||
|
pieces (`apply`'s fallback, `Selection`'s registration contract) are
|
||||||
|
each tested in isolation and never together. Add: build a
|
||||||
|
`TranscriptScreen`, force a `RowDiff::Rebuild` (two adjacent tool-call
|
||||||
|
rows regrouping, per the existing `diff_tests` case), then call
|
||||||
|
`selected_text`/simulate a fresh press on a surviving row and assert no
|
||||||
|
panic.
|
||||||
|
9. **`iris/src/widget/list.rs`'s fling tests check total distance and the
|
||||||
|
start/end clamp but not the speed profile in between.**
|
||||||
|
`fling_moves_the_list_and_then_settles`/`fling_distance_is_positive_
|
||||||
|
toward_the_end` only assert the fling started, moved in the right
|
||||||
|
direction, and eventually stopped — none checks that
|
||||||
|
`tick_fling`'s per-tick delta is *monotonically decreasing* once past
|
||||||
|
the fling's peak (the property `fling_calculator_tests::position_at_
|
||||||
|
is_monotonic_and_clamped_past_the_end` already checks one level down,
|
||||||
|
for `FlingCalculator` alone, but never through `List::tick_fling`'s own
|
||||||
|
`scroll`/`anchor.offset` accumulation). A regression that made
|
||||||
|
`tick_fling` apply the *total* distance every tick instead of the
|
||||||
|
incremental one, for instance, would still pass both existing tests
|
||||||
|
(final position and direction are unaffected by how the interior ticks
|
||||||
|
split it up) while being wildly wrong every intermediate frame.
|
||||||
|
10. **`iris/src/widget/list.rs::replacing_the_last_row_stays_pinned_to_
|
||||||
|
the_bottom` and its sibling test `replace_back`'s effect on the
|
||||||
|
displayed row, never that the row it evicted is actually gone from
|
||||||
|
`heights`/`extents`.** Both tests assert the *new* row's position;
|
||||||
|
neither asserts `old.key` is absent from `list_ref.heights`/`extents`
|
||||||
|
after the replace (the "stale primitive" class finding 1 is a
|
||||||
|
production instance of). A cheap addition: assert
|
||||||
|
`!list_ref.heights.contains_key(&old.key)` after `replace_back` in the
|
||||||
|
existing test, since `old.key` is already returned to the test as
|
||||||
|
`evicted`... (`lib.rs` calls it that way; the `list.rs` test would need
|
||||||
|
to capture the key from `old` similarly.)
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
No missing `IRIS.md` entry found for a *public* API change in this diff —
|
||||||
|
`List::fling`/`VelocityTracker`/`FlingCalculator`, `List::
|
||||||
|
anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/
|
||||||
|
`late_at_hz`, `UiRenderNode::new`'s `Result` change, `Len::dp`, and
|
||||||
|
`List::replace_back`/`clear`/`TranscriptScreen::apply` all have entries.
|
||||||
|
The `List::replace_back`/`clear`/`TranscriptScreen::apply` entry
|
||||||
|
(`docs/IRIS.md:526`) predates this review's finding 1 and does not mention
|
||||||
|
`Selection`'s registration contract at all — once finding 1 is fixed,
|
||||||
|
that entry should gain a line noting what the fix requires of a caller
|
||||||
|
that keeps its own row-keyed side table (the same shape `Selection` is),
|
||||||
|
so the next such table doesn't reproduce the same gap.
|
||||||
|
|
||||||
|
## Fixed, 2026-09-06
|
||||||
|
|
||||||
|
All ten findings addressed after the `DragGesture` merge (`selection.rs`
|
||||||
|
was rewritten by that merge, but finding 1's shape and location were
|
||||||
|
unchanged — `TranscriptScreen::apply`'s `Rebuild` arm, `iris/transcript-ui/
|
||||||
|
src/lib.rs`).
|
||||||
|
|
||||||
|
1. **Fixed.** `Selection::clear()` (`selection.rs`) drops `rows` and
|
||||||
|
`anchor`, called from `apply`'s `Rebuild` arm right before
|
||||||
|
`List::clear()` — `push_row` re-`register`s whatever survives as it
|
||||||
|
rebuilds each row, the "simplest" fix option the finding named.
|
||||||
|
2. **Fixed.** `debug_assert!(self.slot_exists(slot), ...)` at the top of
|
||||||
|
`List::place` (`iris/src/widget/list.rs`).
|
||||||
|
3. **Fixed.** `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||||
|
`List::fling`, and `debug_assert!(velocity.is_finite())` in
|
||||||
|
`FlingCalculator::distance`/`duration` (`iris/src/sense.rs`).
|
||||||
|
`position_at` calls both, so it inherits the guard rather than needing
|
||||||
|
its own.
|
||||||
|
4. **Fixed.** `debug_assert!` on chronological sample order in
|
||||||
|
`VelocityTracker::add_sample` (`iris/src/sense.rs`).
|
||||||
|
5. **Fixed.** `debug_assert!` on non-decreasing `start_index` in
|
||||||
|
`FrameReport::mark_phase` (`iris/core/src/render/frame_report.rs`).
|
||||||
|
6. **Fixed (doc cross-reference only, as asked).** `Selection::register`'s
|
||||||
|
doc now points at `List::place`'s `slot_exists` assertion and vice
|
||||||
|
versa isn't needed since finding 2's fix already cites this file in
|
||||||
|
its own comment; both are grep-able on "docs/REVIEW-2026-09-06.md" and
|
||||||
|
on each other's type names.
|
||||||
|
7. **Fixed.** `bench_client.rs::battery_line` restructured to
|
||||||
|
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`,
|
||||||
|
so the empty-guard and the two lookups can no longer be separated by a
|
||||||
|
future edit.
|
||||||
|
8. **Fixed.** `transcript-ui`'s new `apply_tests::
|
||||||
|
a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection`
|
||||||
|
(`lib.rs`) builds a real `TranscriptScreen`, forces the same regroup
|
||||||
|
shape `diff_tests` already covers at the pure-diff level, calls `apply`,
|
||||||
|
and then `Selection::begin` on a surviving row — which panicked before
|
||||||
|
fix 1, resolving a `WeakWidget` `List::clear()` had just freed.
|
||||||
|
9. **Fixed.** `list.rs`'s new `tick_fling_applies_shrinking_incremental_
|
||||||
|
deltas` flings toward the end from `jump_to_start` and asserts each
|
||||||
|
tick's `extents[&0]` delta is no larger than the previous one — would
|
||||||
|
fail against a `tick_fling` that applied the total spline distance
|
||||||
|
every tick instead of the incremental slice, which the two pre-existing
|
||||||
|
fling tests cannot catch.
|
||||||
|
10. **Fixed.** `list.rs`'s new `replace_back_forgets_the_evicted_keys_own_
|
||||||
|
height` replaces row 4 with a row keyed `100` (the two existing
|
||||||
|
`replace_back` tests always reuse the same key, so neither actually
|
||||||
|
exercises the removal) and asserts `heights` no longer contains the
|
||||||
|
evicted key.
|
||||||
|
|
||||||
|
Docs: `docs/IRIS.md`'s 2026-09-05 `List::replace_back`/`clear`/
|
||||||
|
`TranscriptScreen::apply` entry now has a line on what the fix requires of
|
||||||
|
a caller with its own row-keyed side table, naming `Selection` as the
|
||||||
|
example and dating the fix.
|
||||||
|
|
||||||
|
Verification run alongside the rest of this pass's checks: `cargo fmt
|
||||||
|
--all`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`
|
||||||
|
from `iris/` — see docs/RUST.md's plan box for the pass/fail and any
|
||||||
|
caveats from this same session.
|
||||||
@@ -33,3 +33,14 @@ one in place when it turns out to need a decision.
|
|||||||
that would work today, for Claude sessions, and it is the option that was
|
that would work today, for Claude sessions, and it is the option that was
|
||||||
not chosen.
|
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.
|
||||||
@@ -20,6 +20,14 @@ overlapping, and once more below the composer bar: primitives of a
|
|||||||
replaced/removed row surviving in the GPU buffers, the same shape as the
|
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||||
header drawn twice after a keyboard resize.
|
header drawn twice after a keyboard resize.
|
||||||
|
|
||||||
|
**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in
|
||||||
|
that sentence was right and the location was not -- `UiRenderState::
|
||||||
|
draw_inner` read the `needs_redraw` mark without consuming it and skipped
|
||||||
|
the branch that frees a redrawn widget's old primitives. docs/RUST.md's
|
||||||
|
"Stale primitives, the phone's half" box has the full account, the guard
|
||||||
|
(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run
|
||||||
|
that exercises it.
|
||||||
|
|
||||||
```
|
```
|
||||||
iris bench report
|
iris bench report
|
||||||
per phase:
|
per phase:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -150,6 +150,19 @@ pub enum Event {
|
|||||||
ToolEnd {
|
ToolEnd {
|
||||||
id: String,
|
id: String,
|
||||||
output: 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
|
/// An image the session produced or was sent, saved under the session
|
||||||
/// dir and referenced by id; the phone fetches it by URL.
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -964,9 +965,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlib"
|
name = "dlib"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
@@ -2874,9 +2875,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3057,6 +3058,15 @@ version = "0.8.52"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-input"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"iris",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3645,6 +3655,18 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3893,9 +3915,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-backend"
|
name = "wayland-backend"
|
||||||
version = "0.3.12"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
|
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
@@ -3907,9 +3929,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-client"
|
name = "wayland-client"
|
||||||
version = "0.31.12"
|
version = "0.31.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
|
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"rustix 1.1.3",
|
"rustix 1.1.3",
|
||||||
@@ -3941,9 +3963,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols"
|
name = "wayland-protocols"
|
||||||
version = "0.32.10"
|
version = "0.32.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
|
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -3966,9 +3988,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols-wlr"
|
name = "wayland-protocols-wlr"
|
||||||
version = "0.3.10"
|
version = "0.3.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
|
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -3979,9 +4001,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
@@ -3990,9 +4012,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-sys"
|
name = "wayland-sys"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
|
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dlib",
|
"dlib",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ wgpu = { workspace = true }
|
|||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
accesskit = { workspace = true }
|
accesskit = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
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.
|
||||||
|
log = "0.4.28"
|
||||||
|
|
||||||
# winit everywhere except Android; android-view (below) is what stands in
|
# 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
|
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||||
@@ -53,9 +58,6 @@ accesskit_android = "0.8.0"
|
|||||||
# for `android/insets.rs`'s own id -> state map -- the same reason
|
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||||
# android-view's own `PEER_MAP` carries one.
|
# android-view's own `PEER_MAP` carries one.
|
||||||
send_wrapper = "0.6.0"
|
send_wrapper = "0.6.0"
|
||||||
# For diagnostics visible through android_logger, wherever the app crate
|
|
||||||
# installs it -- this crate never installs a logger itself.
|
|
||||||
log = "0.4.28"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
||||||
@@ -64,7 +66,11 @@ log = "0.4.28"
|
|||||||
# default) or virgl's GLES path, without a second env-var plumbing path that
|
# default) or virgl's GLES path, without a second env-var plumbing path that
|
||||||
# nothing on this machine can hand to an already-launched Android process
|
# nothing on this machine can hand to an already-launched Android process
|
||||||
# (there is no `am start` environment and no system-property reader here to
|
# (there is no `am start` environment and no system-property reader here to
|
||||||
# add one). Android-only; `android/render.rs` is the only reader.
|
# add one). Read by `android/render.rs` and, so the GLES path can be
|
||||||
|
# reproduced on a machine with a real GPU rather than only in the emulator,
|
||||||
|
# by `default/render.rs`:
|
||||||
|
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||||
|
# --features iris/force-gles
|
||||||
force-gles = []
|
force-gles = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -83,13 +89,21 @@ name = "message_list"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
|
members = [
|
||||||
|
"core",
|
||||||
|
"macro",
|
||||||
|
"tabs-ui",
|
||||||
|
"transcript-ui",
|
||||||
|
"transcript-fixture",
|
||||||
|
"rig-input",
|
||||||
|
"desktop-app",
|
||||||
|
]
|
||||||
# android-app pulls in android-view, which needs the NDK sysroot to link
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
# workspace, since it has no `[workspace]` table of its own and this
|
# workspace, since it has no `[workspace]` table of its own and this
|
||||||
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||||
# -t x86_64 -P 26 build`.
|
# -t x86_64 -P 29 build`.
|
||||||
exclude = ["android-app"]
|
exclude = ["android-app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -745,6 +745,7 @@ name = "client-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -1773,6 +1774,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tabs-ui",
|
"tabs-ui",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"transcript-fixture",
|
||||||
"transcript-ui",
|
"transcript-ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3863,6 +3865,17 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ log = "0.4.28"
|
|||||||
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
||||||
tabs-ui = { path = "../tabs-ui", optional = true }
|
tabs-ui = { path = "../tabs-ui", optional = true }
|
||||||
transcript-ui = { path = "../transcript-ui", optional = true }
|
transcript-ui = { path = "../transcript-ui", optional = true }
|
||||||
|
# P0's bench build only: the fixture and the folded screen both bench
|
||||||
|
# clients open, shared with the headless harness and the desktop window
|
||||||
|
# (docs/RUST.md's "Three test layers").
|
||||||
|
transcript-fixture = { path = "../transcript-fixture", optional = true }
|
||||||
client-core = { path = "../../client-core", optional = true }
|
client-core = { path = "../../client-core", optional = true }
|
||||||
event-model = { path = "../../event-model", optional = true }
|
event-model = { path = "../../event-model", optional = true }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
||||||
@@ -65,7 +69,7 @@ force-gles = ["iris/force-gles"]
|
|||||||
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
# listed, which is how this crate's build command names both explicitly.
|
# listed, which is how this crate's build command names both explicitly.
|
||||||
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
|
bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|||||||
@@ -13,8 +13,37 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.android.demo"
|
applicationId = "dev.iris.android.demo"
|
||||||
minSdk = 26
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
targetSdk = 34
|
// sample with `MotionEvent.getEventTimeNanos` and
|
||||||
|
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||||
|
// method there is a hard crash on the first touch rather than a
|
||||||
|
// degraded fling. Raised deliberately rather than guarded at
|
||||||
|
// runtime: nothing this app is built for runs below 29, and an
|
||||||
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
|
// `cargo ndk -P` is kept at the same number.
|
||||||
|
minSdk = 29
|
||||||
|
// 37, matching `compileSdk` and the Compose app in `app/` -- which
|
||||||
|
// is the one part of this that is measured rather than reasoned:
|
||||||
|
// that app targets 37 and its keyboard does push the transcript up
|
||||||
|
// on Iris's phone, and this one targeted 34 and does not
|
||||||
|
// (2026-09-07). The emulator here is API 36 and the push-up works
|
||||||
|
// there at either target, so the target is the only difference the
|
||||||
|
// two devices do not share.
|
||||||
|
//
|
||||||
|
// The mechanism, stated as the reading it is: below targetSdk 35
|
||||||
|
// a window keeps the legacy behaviour, where `adjustResize` shrinks
|
||||||
|
// the window for the IME and `getInsets(ime()).bottom` therefore
|
||||||
|
// measures the overlap with an already-shrunk window -- zero, with
|
||||||
|
// nothing left to push up. `MainActivity`'s
|
||||||
|
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
|
||||||
|
// 36 it still takes; Android 16 deprecated it and Android 17 is
|
||||||
|
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
|
||||||
|
// the app is handed the real overlap without relying on a
|
||||||
|
// deprecated call. If the phone still reports `ime_bottom=0` with
|
||||||
|
// a nonzero `dispatches` in the Diagnostics pane, this reading was
|
||||||
|
// wrong and the `WindowInsetsAnimation.Callback` in
|
||||||
|
// `MainActivity` is the other half to look at.
|
||||||
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public final class IrisView extends RustView {
|
|||||||
protected native long newViewPeer(Context context);
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
native void applyWindowInsetsNative(
|
native void applyWindowInsetsNative(
|
||||||
long peer, int left, int top, int right, int bottom, int imeBottom);
|
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||||
|
|
||||||
native void unregisterInsetsNative(long peer);
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
@@ -35,8 +35,9 @@ public final class IrisView extends RustView {
|
|||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
|
void applyWindowInsets(
|
||||||
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
|
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import android.app.Activity;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||||
@@ -31,36 +33,107 @@ public final class MainActivity extends Activity {
|
|||||||
setContentView(layout);
|
setContentView(layout);
|
||||||
view.requestFocus();
|
view.requestFocus();
|
||||||
|
|
||||||
|
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
|
||||||
|
// `logcat` showed the platform's own IME open/resize happening
|
||||||
|
// while `setOnApplyWindowInsetsListener` fired only once, at
|
||||||
|
// attach, and never again for a pure keyboard toggle -- a plain
|
||||||
|
// (non-edge-to-edge) window is only guaranteed that one initial
|
||||||
|
// dispatch; `adjustResize` handling the IME entirely by resizing
|
||||||
|
// the window is not itself a trigger for a fresh one. Opting into
|
||||||
|
// edge-to-edge (a platform call, API 30+, no new dependency) is
|
||||||
|
// what makes the system redeliver insets on every change,
|
||||||
|
// including the ones this activity actually cares about --
|
||||||
|
// `getSystemWindowInset*` below is unaffected by this (it has
|
||||||
|
// always reported the raw system-bar/IME overlap regardless of
|
||||||
|
// who consumes it), so the on-screen bars and the padding Rust
|
||||||
|
// already derives from those four numbers are unchanged; only the
|
||||||
|
// callback's firing became reliable.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The keyboard's height arrives twice, over two different
|
||||||
|
// paths, and the phone needs the second one** (Iris, 2026-09-07:
|
||||||
|
// the emulator pushed the composer up and her Pixel did not).
|
||||||
|
// `setOnApplyWindowInsetsListener` is the platform's *settled*
|
||||||
|
// answer; `WindowInsetsAnimation.Callback` is the running one, and
|
||||||
|
// an IME that animates in delivers every intermediate height
|
||||||
|
// through the callback with the static dispatch arriving only at
|
||||||
|
// the ends -- on some devices only at `onEnd`. Registering both
|
||||||
|
// means neither device depends on the other's timing, and it is
|
||||||
|
// also what makes the push-up *animate* with the keyboard rather
|
||||||
|
// than jump when it lands.
|
||||||
|
//
|
||||||
|
// The two do not disagree, because they are the same call with the
|
||||||
|
// same numbers read out of whichever `WindowInsets` is current.
|
||||||
|
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
|
||||||
|
// nothing keeps the ordinary dispatch running underneath.
|
||||||
|
// `onEnd` re-reads the root's insets rather than trusting the last
|
||||||
|
// `onProgress`: an animation interrupted mid-flight never delivers
|
||||||
|
// its final frame, which is exactly the fault the Compose app hit
|
||||||
|
// (AGENTS.md, "the composer can get stuck floating above the
|
||||||
|
// bottom of the screen").
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||||
|
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||||
|
@Override
|
||||||
|
public WindowInsets onProgress(
|
||||||
|
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||||
|
sendInsets(view, insets);
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnd(WindowInsetsAnimation animation) {
|
||||||
|
WindowInsets settled = view.getRootWindowInsets();
|
||||||
|
if (settled != null) {
|
||||||
|
sendInsets(view, settled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
sendInsets((IrisView) v, insets);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 left = insets.getSystemWindowInsetLeft();
|
||||||
int top = insets.getSystemWindowInsetTop();
|
int top = insets.getSystemWindowInsetTop();
|
||||||
int right = insets.getSystemWindowInsetRight();
|
int right = insets.getSystemWindowInsetRight();
|
||||||
int bottom = insets.getSystemWindowInsetBottom();
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
// The manifest declares adjustResize (AGENTS.md: without it the
|
// **Two separate answers, because they are separate questions**
|
||||||
// keyboard pans the whole window instead of resizing it), and
|
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||||
// under adjustResize the window itself shrinks to make room for
|
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||||
// the keyboard -- which is exactly the condition under which
|
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||||
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
// pass sent the boolean *as* the height (0 or 1) because under
|
||||||
// measures how much of the window the keyboard overlaps, and
|
// plain `adjustResize` the window shrinks to make room and the ime
|
||||||
// resize already made that overlap zero by construction. That
|
// inset therefore measures a zero overlap by construction -- true
|
||||||
// numeric inset is not a usable "is the keyboard open" signal
|
// then, and no longer true now that this is an edge-to-edge window
|
||||||
// here (found while root-causing why bench_client.rs's keyboard
|
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
|
||||||
// phase and auto-diagnostics never fired on the emulator despite
|
// above for the devices below that), which is exactly the case
|
||||||
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
// where the system stops resizing and hands the app the real
|
||||||
// survive adjustResize is the boolean isVisible() answer, set
|
// overlap instead. Sending 1 for it left the Rust side padding the
|
||||||
// from the platform's own start/end of the transition over a
|
// composer by one physical pixel, so the keyboard covered the bar
|
||||||
// different path than the inset amount -- the same fact
|
// and the transcript alike.
|
||||||
// AGENTS.md's "Things that have bitten" already names for the
|
//
|
||||||
// Compose side's identical trap. Passed through as a 0/1 stand-
|
// The visibility is still sent in its own right rather than
|
||||||
// in for the ime_bottom pixel amount, since nothing on the Rust
|
// inferred from `height > 0`: the two disagree during the
|
||||||
// side reads it as a real pixel value -- only `> 0.0`.
|
// keyboard's slide-in and -out (visible, height still climbing),
|
||||||
|
// and "is the IME up" drives the bench's own state machine
|
||||||
|
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||||
|
// reading as "closed" is a miscount.
|
||||||
int imeBottom = 0;
|
int imeBottom = 0;
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
int imeVisible = 0;
|
||||||
&& insets.isVisible(WindowInsets.Type.ime())) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
imeBottom = 1;
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
}
|
}
|
||||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
return insets;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,11 +52,16 @@ if [ -z "$NDK_DIR" ]; then
|
|||||||
fi
|
fi
|
||||||
export ANDROID_NDK_HOME="$NDK_DIR"
|
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||||
|
|
||||||
|
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
|
||||||
|
# whatever earlier builds left here, and Gradle packages every directory it
|
||||||
|
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
|
||||||
|
# 339 MB on 2026-09-06.
|
||||||
|
rm -rf app/src/main/jniLibs
|
||||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||||
else
|
else
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
GRADLE_TASK="assembleDebug"
|
GRADLE_TASK="assembleDebug"
|
||||||
|
|||||||
@@ -51,9 +51,14 @@ ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-benc
|
|||||||
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
||||||
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
||||||
# a longer script than v1's swipe-loop-only run.
|
# a longer script than v1's swipe-loop-only run.
|
||||||
|
# The report's own first line, not the bare "iris bench report:" prefix:
|
||||||
|
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
|
||||||
|
# first", which the app emits at startup), so polling for the prefix
|
||||||
|
# returned instantly and the script printed a report that was never run.
|
||||||
|
REPORT_LINE="iris bench report: iris bench report"
|
||||||
i=0
|
i=0
|
||||||
while [ "$i" -lt 260 ]; do
|
while [ "$i" -lt 260 ]; do
|
||||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
|
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||||
if [ -n "$LINE" ]; then
|
if [ -n "$LINE" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -66,4 +71,4 @@ if [ -z "$LINE" ]; then
|
|||||||
fi
|
fi
|
||||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
||||||
# phases, four lines each) on top of the frames/bench sections v1 had.
|
# phases, four lines each) on top of the frames/bench sections v1 had.
|
||||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "iris bench report:"
|
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
|
||||||
@@ -6,14 +6,11 @@
|
|||||||
//!
|
//!
|
||||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||||
//! network half replaced by the checked-in fixture, embedded with
|
//! network half replaced by the checked-in fixture. Reading that fixture
|
||||||
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
//! and folding it into a screen is **`transcript-fixture`'s** job, not
|
||||||
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
//! this file's -- the same crate the headless harness and the
|
||||||
//! a real transcript (that file's own README). The first 3,200 lines are
|
//! phone-shaped desktop window open, so all three measure one screen
|
||||||
//! the opening backlog, folded once through
|
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
|
||||||
//! `client_core::transcript_fold::fold_page` exactly as a real
|
|
||||||
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
|
||||||
//! same as any first load); the remaining ~400 are the streaming tail,
|
|
||||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||||
@@ -22,7 +19,7 @@
|
|||||||
|
|
||||||
use crate::bench_jni::PlatformHandle;
|
use crate::bench_jni::PlatformHandle;
|
||||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
use client_core::transcript_fold::{TranscriptItem, fold_event};
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -30,13 +27,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// 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.
|
|
||||||
const BACKLOG_COUNT: usize = 3200;
|
|
||||||
|
|
||||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||||
/// implement the identical four phases -- see that box before changing any
|
/// implement the identical four phases -- see that box before changing any
|
||||||
/// constant here, since a mismatch would make the two reports stop
|
/// constant here, since a mismatch would make the two reports stop
|
||||||
@@ -90,7 +80,11 @@ const KEYBOARD_WAIT_MS: u64 = 1_000;
|
|||||||
/// when a later step in the same phase needs to read state back.
|
/// when a later step in the same phase needs to read state back.
|
||||||
const ANIM_STEP_MS: u64 = 16;
|
const ANIM_STEP_MS: u64 = 16;
|
||||||
|
|
||||||
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
/// 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 {
|
pub struct BenchClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
@@ -152,31 +146,6 @@ impl HasAndroidUiState for BenchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the fixture once: `serde_json::Value`s for the backlog
|
|
||||||
/// (`fold_page` takes a page of raw wire JSON, same as a real
|
|
||||||
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
|
|
||||||
/// takes one live wire event at a time, same as a real SSE frame).
|
|
||||||
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
|
|
||||||
let lines: Vec<&str> = FIXTURE_JSONL
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim().is_empty())
|
|
||||||
.collect();
|
|
||||||
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
|
|
||||||
let mut stream_tail = Vec::new();
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
|
||||||
let value: serde_json::Value =
|
|
||||||
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
|
||||||
if i < BACKLOG_COUNT {
|
|
||||||
backlog.push(value);
|
|
||||||
} else {
|
|
||||||
let event: SeqEvent = serde_json::from_value(value)
|
|
||||||
.expect("bench fixture event matches event-model's SeqEvent");
|
|
||||||
stream_tail.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(backlog, stream_tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(Color::WHITE)
|
||||||
@@ -221,8 +190,15 @@ fn battery_line(samples: &[i32]) -> String {
|
|||||||
return " battery current: unavailable on this device".to_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;
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
let min = samples.iter().min().unwrap();
|
// `min`/`max` are guarded by the `is_empty` check above, three lines
|
||||||
let max = samples.iter().max().unwrap();
|
// 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 (docs/
|
||||||
|
// REVIEW-2026-09-06.md finding 7).
|
||||||
|
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||||
|
unreachable!("samples is non-empty, checked above");
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||||
samples.len()
|
samples.len()
|
||||||
@@ -248,10 +224,28 @@ impl AndroidAppState for BenchClient {
|
|||||||
let top_bar = WidgetPtr::new().add(rsc);
|
let top_bar = WidgetPtr::new().add(rsc);
|
||||||
let controls = bench_controls(rsc, 0.0);
|
let controls = bench_controls(rsc, 0.0);
|
||||||
top_bar(rsc).set(controls);
|
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 = (
|
let tree = (
|
||||||
top_bar,
|
top_bar,
|
||||||
content.height(rest(2)),
|
report_display
|
||||||
report_display.height(rest(1)).pad(dp(8)),
|
.pad(dp(8))
|
||||||
|
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||||
|
content.height(rest(1)),
|
||||||
)
|
)
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
@@ -291,12 +285,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
last_top_pad: 0.0,
|
last_top_pad: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (backlog, stream_tail) = parse_fixture();
|
match transcript_fixture::build_screen(rsc) {
|
||||||
client.stream_tail = stream_tail;
|
Ok((opened, tree)) => {
|
||||||
match fold_page(&backlog) {
|
client.items = opened.items;
|
||||||
Ok(items) => {
|
client.stream_tail = opened.stream_tail;
|
||||||
client.items = items;
|
(client.content)(rsc).set(tree);
|
||||||
client.rebuild_transcript(rsc);
|
client.screen = Some(opened.screen);
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
@@ -377,7 +371,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
let ime_visible = insets.ime_bottom > 0.0;
|
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
||||||
|
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
||||||
|
// climbing while the keyboard slides in, so a frame or two of a
|
||||||
|
// real opening reads as "closed" when the boolean is inferred from
|
||||||
|
// it, and `shown_events`/`hidden_events` below count transitions.
|
||||||
|
let ime_visible = insets.ime_visible;
|
||||||
|
|
||||||
let mut ime = self.ime_state.lock().unwrap();
|
let mut ime = self.ime_state.lock().unwrap();
|
||||||
if ime_visible && !ime.visible {
|
if ime_visible && !ime.visible {
|
||||||
@@ -510,8 +509,7 @@ impl BenchClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
let rows = group_tool_runs(&self.items);
|
let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
|
||||||
(self.content)(rsc).set(tree);
|
(self.content)(rsc).set(tree);
|
||||||
self.screen = Some(screen);
|
self.screen = Some(screen);
|
||||||
}
|
}
|
||||||
@@ -523,47 +521,48 @@ impl BenchClient {
|
|||||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||||
/// so it's set here too rather than adding a second copy path.
|
/// so it's set here too rather than adding a second copy path.
|
||||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
|
let report = self.diagnostics_text(rsc);
|
||||||
|
self.report_display.edit(rsc).set(&report);
|
||||||
|
self.last_report = Some(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diagnostics report as text, with no side effect on what is on
|
||||||
|
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||||
|
/// the keyboard-open capture (which only logs it), so the two can
|
||||||
|
/// never drift into reporting different things.
|
||||||
|
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
||||||
let font = rsc.ui.text.font_diagnostics();
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
let frame_report = match self.android_state().frame_report.report() {
|
let frame_report = match self.android_state().frame_report.report() {
|
||||||
Some(stats) => format!("{stats}"),
|
Some(stats) => format!("{stats}"),
|
||||||
None => "no frames recorded yet".to_string(),
|
None => "no frames recorded yet".to_string(),
|
||||||
};
|
};
|
||||||
let report = match &self.android_state().renderer {
|
let renderer = match &self.android_state().renderer {
|
||||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||||
};
|
};
|
||||||
self.report_display.edit(rsc).set(&report);
|
// The insets line goes in the pane, not just the log: Iris has no
|
||||||
self.last_report = Some(report);
|
// 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`).
|
||||||
|
format!("{renderer}\n{}", self.android_state().insets_report())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
|
/// doc comment. **Logged only.** It used to also copy the report to
|
||||||
/// same text the on-screen `Diagnostics` button produces, plus the
|
/// the clipboard unprompted and put it in the shell's overlay view,
|
||||||
/// per-frame log `FrameReport` already keeps around the resize --
|
/// from when the keyboard-inset callback was not firing at all and a
|
||||||
/// `frame_report.report()` above covers "the frames around the
|
/// report could not be got off the phone any other way. Both are gone
|
||||||
/// resize" without a second accounting mechanism), then does three
|
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
|
||||||
/// things the button does not: logs it (so a `logcat` pull gets it
|
/// `MainActivity.java`), and the overlay covered the whole screen on
|
||||||
/// even if nothing on screen does), copies it to the clipboard
|
/// *every* keyboard open with its own Copy/Close buttons underneath
|
||||||
/// unprompted, and shows it in the shell's plain overlay view, which
|
/// the keyboard, so it could not be dismissed -- an interruption for
|
||||||
/// draws independently of iris's own renderer -- the whole point,
|
/// something nobody asked for, over an app you are trying to type
|
||||||
/// since the renderer is exactly what might be in the wiped state
|
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
|
||||||
/// this exists to report on.
|
/// 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) {
|
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
self.show_diagnostics(rsc);
|
let report = self.diagnostics_text(rsc);
|
||||||
let Some(report) = self.last_report.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("iris keyboard diagnostics:\n{report}");
|
log::info!("iris keyboard diagnostics:\n{report}");
|
||||||
let Some(platform) = &self.platform else {
|
|
||||||
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
|
|
||||||
log::info!("iris keyboard diagnostics: copied to clipboard");
|
|
||||||
} else {
|
|
||||||
log::info!("iris keyboard diagnostics: clipboard copy failed");
|
|
||||||
}
|
|
||||||
platform.show_diagnostics_overlay(&report);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_report(&mut self) {
|
fn copy_report(&mut self) {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AOSP's fling spline, transcribed independently of the Rust port.
|
||||||
|
|
||||||
|
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
||||||
|
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
||||||
|
grading its own homework. Every test iris's fling had before 2026-09-07
|
||||||
|
compared the curve with itself -- monotonic, signed, integrates to the closed
|
||||||
|
form -- and all of them passed while `distance_fraction(t)` was returning
|
||||||
|
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
||||||
|
test have to come from somewhere else, and this is the somewhere else.
|
||||||
|
|
||||||
|
Transcribed by hand from, and only from:
|
||||||
|
|
||||||
|
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
||||||
|
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
||||||
|
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
||||||
|
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
||||||
|
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
||||||
|
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
||||||
|
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
||||||
|
line, which is why iris ports one curve rather than two.
|
||||||
|
|
||||||
|
Run it with no arguments; it prints the table entries and the (velocity,
|
||||||
|
density, t) points the Rust tests assert on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NB_SAMPLES = 100
|
||||||
|
INFLEXION = 0.35
|
||||||
|
START_TENSION = 0.5
|
||||||
|
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
|
||||||
|
INCHES_PER_METER = 39.37
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def spline_positions():
|
||||||
|
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
||||||
|
position = [0.0] * (NB_SAMPLES + 1)
|
||||||
|
x_min = 0.0
|
||||||
|
for i in range(NB_SAMPLES):
|
||||||
|
alpha = i / NB_SAMPLES
|
||||||
|
x_max = 1.0
|
||||||
|
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
|
||||||
|
if tx > alpha:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
POSITION = spline_positions()
|
||||||
|
|
||||||
|
|
||||||
|
def fling_sample(t):
|
||||||
|
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
||||||
|
t = min(max(t, 0.0), 1.0)
|
||||||
|
index = int(t * NB_SAMPLES)
|
||||||
|
if index >= NB_SAMPLES:
|
||||||
|
return 1.0, 0.0
|
||||||
|
t_inf = index / NB_SAMPLES
|
||||||
|
t_sup = (index + 1) / NB_SAMPLES
|
||||||
|
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
||||||
|
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
||||||
|
|
||||||
|
|
||||||
|
def physical_coefficient(density):
|
||||||
|
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
||||||
|
|
||||||
|
|
||||||
|
def deceleration(velocity, density):
|
||||||
|
return math.log(
|
||||||
|
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_distance(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return (
|
||||||
|
SCROLL_FRICTION
|
||||||
|
* physical_coefficient(density)
|
||||||
|
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_duration_s(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return math.exp(l / (DECELERATION_RATE - 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def position_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def velocity_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("SPLINE_POSITION at a few indices (index: value)")
|
||||||
|
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
||||||
|
print(f" {i:3}: {POSITION[i]:.6f}")
|
||||||
|
print()
|
||||||
|
print("distance/velocity fraction at time fractions")
|
||||||
|
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
||||||
|
d, v = fling_sample(t)
|
||||||
|
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
||||||
|
print()
|
||||||
|
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||||
|
# 2.75 is this checkout's emulator.
|
||||||
|
for density in (2.55, 2.75):
|
||||||
|
for velocity in (5000.0, 11064.0):
|
||||||
|
dur = fling_duration_s(velocity, density)
|
||||||
|
print(
|
||||||
|
f"density={density} v={velocity}: "
|
||||||
|
f"distance={fling_distance(velocity, density):.3f}px "
|
||||||
|
f"duration={dur:.4f}s"
|
||||||
|
)
|
||||||
|
# Deliberately not round fractions. The velocity coefficient is
|
||||||
|
# piecewise *constant* across each of the 100 samples, so it
|
||||||
|
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
||||||
|
# on which side of a discontinuity the last float landed --
|
||||||
|
# which is genuinely different between Python and Rust and says
|
||||||
|
# nothing about the curve.
|
||||||
|
for frac in (0.125, 0.335, 0.505, 0.755):
|
||||||
|
t = frac * dur
|
||||||
|
print(
|
||||||
|
f" t={frac:>4} of duration ({t:.4f}s): "
|
||||||
|
f"pos={position_at(velocity, density, t):.3f}px "
|
||||||
|
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
||||||
|
)
|
||||||
@@ -142,7 +142,7 @@ fn bench_first_frame(n: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
report(
|
report(
|
||||||
&format!("(a) first frame, N={n}"),
|
&format!("(a) first frame, N={n}"),
|
||||||
elapsed,
|
elapsed,
|
||||||
@@ -177,7 +177,7 @@ fn bench_scroll(n: usize, ticks: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -245,7 +245,7 @@ fn bench_input_grows(n: usize, lines: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -302,7 +302,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
@@ -384,7 +384,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
total += start.elapsed();
|
total += start.elapsed();
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
total_draws += draws;
|
total_draws += draws;
|
||||||
total_rewrites += rewrites;
|
total_rewrites += rewrites;
|
||||||
total_moves += moves;
|
total_moves += moves;
|
||||||
|
|||||||
@@ -147,6 +147,29 @@ impl Len {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
|
||||||
|
/// `rest` survives: `dp` becomes physical pixels and every other
|
||||||
|
/// component is left alone.
|
||||||
|
///
|
||||||
|
/// **A `Len` a widget *reports* must have been through this.** `dp` is
|
||||||
|
/// an input unit -- a number the widget author wrote -- and the
|
||||||
|
/// containers that consume a reported length read `abs`/`rel`/`rest`
|
||||||
|
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
|
||||||
|
/// so a reported `dp` is silently worth zero. That is what made the
|
||||||
|
/// composer's bar collapse to nothing the moment its content grew past
|
||||||
|
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
|
||||||
|
/// so the bar was given a slot of 0 and the field inside it was panned
|
||||||
|
/// out of a container measured at -63px. `UiRenderState::draw_inner`
|
||||||
|
/// debug-asserts the invariant after every `Widget::draw`.
|
||||||
|
pub fn fold_dp(&self, density: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: self.abs + self.dp * density,
|
||||||
|
dp: 0.0,
|
||||||
|
rel: self.rel,
|
||||||
|
rest: self.rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn abs(abs: impl UiNum) -> Self {
|
pub fn abs(abs: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: abs.to_f32(),
|
abs: abs.to_f32(),
|
||||||
|
|||||||
@@ -601,6 +601,11 @@ pub struct RenderedText {
|
|||||||
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||||
pub size: Vec2,
|
pub size: Vec2,
|
||||||
pub color: UiColor,
|
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 {
|
impl TextData {
|
||||||
@@ -619,6 +624,7 @@ impl TextData {
|
|||||||
glyphs: std::sync::Arc::new(glyphs),
|
glyphs: std::sync::Arc::new(glyphs),
|
||||||
size: buffer.size(),
|
size: buffer.size(),
|
||||||
color: attrs.color,
|
color: attrs.color,
|
||||||
|
generation: self.atlas.generation(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,10 @@ struct Page {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GlyphAtlas {
|
pub struct GlyphAtlas {
|
||||||
pages: Vec<Page>,
|
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
|
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
||||||
/// too, so it is not re-rasterised on every layout.
|
/// too, so it is not re-rasterised on every layout.
|
||||||
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
||||||
@@ -166,6 +170,13 @@ impl GlyphAtlas {
|
|||||||
self.entries.insert(key, None);
|
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
|
||||||
|
}
|
||||||
|
|
||||||
pub fn page_count(&self) -> usize {
|
pub fn page_count(&self) -> usize {
|
||||||
self.pages.len()
|
self.pages.len()
|
||||||
}
|
}
|
||||||
@@ -188,9 +199,20 @@ impl GlyphAtlas {
|
|||||||
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||||
/// free message back through their `Textures`; see `Textures::reset`'s
|
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||||
/// doc for why that is harmless here.
|
/// 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) {
|
pub fn clear(&mut self) {
|
||||||
self.pages.clear();
|
self.pages.clear();
|
||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
|
self.generation += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,15 @@ impl FrameReport {
|
|||||||
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||||
/// can slice one whole run's frames by what was happening during each.
|
/// can slice one whole run's frames by what was happening during each.
|
||||||
pub fn mark_phase(&mut self, name: &str) {
|
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
|
||||||
|
// (docs/REVIEW-2026-09-06.md finding 5).
|
||||||
|
debug_assert!(
|
||||||
|
self.phases
|
||||||
|
.last()
|
||||||
|
.is_none_or(|p| self.total_frames >= p.start_index)
|
||||||
|
);
|
||||||
self.phases.push(PhaseMark {
|
self.phases.push(PhaseMark {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
start_index: self.total_frames,
|
start_index: self.total_frames,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
ArrBuf,
|
ArrBuf,
|
||||||
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
||||||
},
|
},
|
||||||
|
util::HashSet,
|
||||||
};
|
};
|
||||||
use bytemuck::Pod;
|
use bytemuck::Pod;
|
||||||
use wgpu::*;
|
use wgpu::*;
|
||||||
@@ -277,6 +278,31 @@ impl Primitives {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many instances are still bound for the GPU -- 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.free.len()) + (self.images.len() - self.image_free.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every instance that is still bound for the GPU, as `(inst_idx,
|
||||||
|
/// owner, is_image)` -- everything except the slots already handed to
|
||||||
|
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
||||||
|
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
|
||||||
|
/// to check that every drawn primitive still belongs to a live widget.
|
||||||
|
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
|
||||||
|
let free: HashSet<usize> = self.free.iter().copied().collect();
|
||||||
|
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
|
||||||
|
let rects = (0..self.instances.len())
|
||||||
|
.filter(move |i| !free.contains(i))
|
||||||
|
.map(|i| (i, self.assoc[i], false));
|
||||||
|
let images = (0..self.images.len())
|
||||||
|
.filter(move |i| !image_free.contains(i))
|
||||||
|
.map(|i| (i, self.image_assoc[i], true));
|
||||||
|
rects.chain(images)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn data(&self) -> &PrimitiveData {
|
pub fn data(&self) -> &PrimitiveData {
|
||||||
&self.data
|
&self.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,11 +80,17 @@ var<storage> masks: array<Mask>;
|
|||||||
@group(3) @binding(1)
|
@group(3) @binding(1)
|
||||||
var<storage> move_offsets: array<MoveOffset>;
|
var<storage> move_offsets: array<MoveOffset>;
|
||||||
|
|
||||||
// A move chain more than this deep means something else is wrong (an
|
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
|
||||||
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in
|
// render_state.rs, which walks the identical chain on the CPU side for
|
||||||
// render_state.rs, which walks the identical bound on the CPU side for
|
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||||
// hit-testing. Bounded so a malformed chain cannot hang the GPU.
|
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||||
const MOVE_CHAIN_LIMIT: u32 = 16u;
|
// 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.
|
||||||
|
const MOVE_CHAIN_LIMIT: u32 = 64u;
|
||||||
|
|
||||||
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
/// 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
|
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
|
|||||||
|
|
||||||
use super::atlas::PAGE;
|
use super::atlas::PAGE;
|
||||||
|
|
||||||
|
/// The fewest layers the glyph atlas array is ever created with. Two, not
|
||||||
|
/// 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
|
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
|
||||||
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
|
/// 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.
|
/// same thing on both sides without a second map to keep in sync.
|
||||||
@@ -360,7 +364,26 @@ 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 {
|
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
|
||||||
|
debug_assert!(
|
||||||
|
capacity >= MIN_ARRAY_LAYERS,
|
||||||
|
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
|
||||||
|
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
|
||||||
|
);
|
||||||
device.create_texture(&TextureDescriptor {
|
device.create_texture(&TextureDescriptor {
|
||||||
label: Some("glyph atlas array"),
|
label: Some("glyph atlas array"),
|
||||||
size: Extent3d {
|
size: Extent3d {
|
||||||
@@ -382,7 +405,7 @@ impl GpuTextures {
|
|||||||
pub fn new(device: &Device, queue: &Queue) -> Self {
|
pub fn new(device: &Device, queue: &Queue) -> Self {
|
||||||
let sampler = default_sampler(device);
|
let sampler = default_sampler(device);
|
||||||
let null_view = null_texture_view(device);
|
let null_view = null_texture_view(device);
|
||||||
let array_capacity = 1;
|
let array_capacity = MIN_ARRAY_LAYERS;
|
||||||
let array_texture = Self::create_array_texture(device, array_capacity);
|
let array_texture = Self::create_array_texture(device, array_capacity);
|
||||||
let array_view = array_texture.create_view(&TextureViewDescriptor {
|
let array_view = array_texture.create_view(&TextureViewDescriptor {
|
||||||
dimension: Some(TextureViewDimension::D2Array),
|
dimension: Some(TextureViewDimension::D2Array),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
|
use crate::{
|
||||||
|
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
|
||||||
|
};
|
||||||
|
|
||||||
/// important non rendering data for retained drawing
|
/// important non rendering data for retained drawing
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -9,7 +11,22 @@ pub struct ActiveData {
|
|||||||
pub textures: Vec<TextureHandle>,
|
pub textures: Vec<TextureHandle>,
|
||||||
pub primitives: Vec<PrimitiveHandle>,
|
pub primitives: Vec<PrimitiveHandle>,
|
||||||
pub children: Vec<WidgetId>,
|
pub children: Vec<WidgetId>,
|
||||||
|
/// The mask this widget was drawn **under** (its parent's), not the
|
||||||
|
/// one it set for itself -- see `own_mask` for that.
|
||||||
pub mask: MaskIdx,
|
pub mask: MaskIdx,
|
||||||
|
/// The mask slot this widget allocated for *itself* with
|
||||||
|
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
|
||||||
|
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
|
||||||
|
/// a fresh slot each draw left every already-drawn descendant --
|
||||||
|
/// which `draw_inner`'s unchanged-region fast path does not revisit --
|
||||||
|
/// clipping to the *old* slot's region, so a composer whose bar had
|
||||||
|
/// since been placed at the bottom of the screen was still being
|
||||||
|
/// clipped to a box at the top of it and drew nothing (measured
|
||||||
|
/// 2026-09-06: four mask entries live, none of them the widget's
|
||||||
|
/// current region). Its path out is the `undraw` branch of
|
||||||
|
/// `UiRenderState::remove`, which drops the self-ownership ref taken
|
||||||
|
/// when the slot was allocated.
|
||||||
|
pub own_mask: MaskIdx,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
/// What `Widget::draw` returned the last time this widget was actually
|
/// What `Widget::draw` returned the last time this widget was actually
|
||||||
/// drawn -- read by a parent placing this widget again without
|
/// drawn -- read by a parent placing this widget again without
|
||||||
@@ -21,4 +38,32 @@ pub struct ActiveData {
|
|||||||
/// so a retained child's `parent` link never goes stale). See
|
/// so a retained child's `parent` link never goes stale). See
|
||||||
/// LAYOUT.md section 2.
|
/// LAYOUT.md section 2.
|
||||||
pub move_slot: MoveIdx,
|
pub move_slot: MoveIdx,
|
||||||
|
/// How much of this widget's own `move_slot` delta is already folded
|
||||||
|
/// into `region` above, in window pixels. The two mechanisms that
|
||||||
|
/// write that slot disagree about this and cannot be told apart from
|
||||||
|
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
|
||||||
|
/// together (the *offered* region genuinely moved), while
|
||||||
|
/// `Painter::reposition` writes only the delta (`region` stays the
|
||||||
|
/// offered box and the delta says where inside it the content was
|
||||||
|
/// placed). So anything that wants the widget's real position --
|
||||||
|
/// `resolved_region`, and through it every hit test -- must subtract
|
||||||
|
/// this from the chain sum. Without it a panned widget's own hit box
|
||||||
|
/// sits at twice the pan while its descendants' are correct, which is
|
||||||
|
/// how it went unnoticed: the composer's field became untappable
|
||||||
|
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
|
||||||
|
/// is really redrawn, since `draw_inner` zeroes the slot then too.
|
||||||
|
pub move_applied: Vec2,
|
||||||
|
/// The offset the last `Painter::reposition` placed this widget's
|
||||||
|
/// content at *within* `region`, in window pixels. The move slot has
|
||||||
|
/// exactly one owner and one meaning:
|
||||||
|
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
|
||||||
|
/// adds to the first, `reposition` overwrites the second (it
|
||||||
|
/// recomputes `from` afresh every call, so repeating it must land on
|
||||||
|
/// the same answer rather than drifting), and both then rewrite the
|
||||||
|
/// slot from the sum -- which is what lets a parent both move a child
|
||||||
|
/// with its own layout and place it inside that moved region in one
|
||||||
|
/// frame. `List::place`'s Bottom-known branch does exactly that once a
|
||||||
|
/// row's blocks wrap. Reset to zero on a real redraw, with
|
||||||
|
/// `move_applied` and the slot itself.
|
||||||
|
pub repositioned: Vec2,
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,46 @@ pub struct UiData {
|
|||||||
/// id (never reallocated), so a retained descendant's `parent` index
|
/// id (never reallocated), so a retained descendant's `parent` index
|
||||||
/// never goes stale -- see LAYOUT.md section 2.
|
/// never goes stale -- see LAYOUT.md section 2.
|
||||||
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||||
|
/// Every widget whose [`crate::Widget::tick`] should run before the
|
||||||
|
/// next frame -- today, a `List` 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiData {
|
||||||
|
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
|
||||||
|
/// says it is done. Idempotent -- registering an already-animating
|
||||||
|
/// widget is the ordinary case (a second fling before the first
|
||||||
|
/// settled) and must not tick it twice per frame.
|
||||||
|
pub fn animate(&mut self, id: WidgetId) {
|
||||||
|
if !self.animating.contains(&id) {
|
||||||
|
self.animating.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
for id in registered {
|
||||||
|
self.animate(id);
|
||||||
|
}
|
||||||
|
!self.animating.is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UiRsc {
|
pub trait UiRsc {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct Painter<'a> {
|
|||||||
pub(super) region: UiRegion,
|
pub(super) region: UiRegion,
|
||||||
pub(super) mask: MaskIdx,
|
pub(super) mask: MaskIdx,
|
||||||
pub(super) move_slot: MoveIdx,
|
pub(super) move_slot: MoveIdx,
|
||||||
|
/// This widget's own mask slot, reused across redraws -- see
|
||||||
|
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
|
||||||
|
/// for the first time in this widget's life.
|
||||||
|
pub(super) own_mask: MaskIdx,
|
||||||
pub(super) textures: Vec<TextureHandle>,
|
pub(super) textures: Vec<TextureHandle>,
|
||||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||||
pub(super) children: Vec<WidgetId>,
|
pub(super) children: Vec<WidgetId>,
|
||||||
@@ -48,12 +52,32 @@ impl<'a> Painter<'a> {
|
|||||||
self.primitive_at(primitive, region.within(&self.region));
|
self.primitive_at(primitive, region.within(&self.region));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clip everything this widget draws, itself and its descendants, to
|
||||||
|
/// `region`. One per widget: a second call would need the two to be
|
||||||
|
/// intersected, which nothing here does.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// so keeps pointing at whichever slot it was drawn under. See
|
||||||
|
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||||
pub fn set_mask(&mut self, region: UiRegion) {
|
pub fn set_mask(&mut self, region: UiRegion) {
|
||||||
assert!(self.mask == MaskIdx::NONE);
|
assert!(self.mask == MaskIdx::NONE);
|
||||||
self.mask = self.rsc.ui_mut().masks.push(Mask {
|
let mask = Mask {
|
||||||
region,
|
region,
|
||||||
move_idx: self.move_slot,
|
move_idx: self.move_slot,
|
||||||
});
|
};
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||||
|
}
|
||||||
|
self.mask = self.own_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a widget within this widget's region, returning the size it
|
/// Draws a widget within this widget's region, returning the size it
|
||||||
@@ -86,6 +110,7 @@ impl<'a> Painter<'a> {
|
|||||||
self.mask,
|
self.mask,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
crate::render::MaskIdx::NONE,
|
||||||
self.rsc,
|
self.rsc,
|
||||||
);
|
);
|
||||||
self.state
|
self.state
|
||||||
@@ -166,17 +191,40 @@ impl<'a> Painter<'a> {
|
|||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
let density = self.state.density;
|
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();
|
let ui = self.rsc.ui_mut();
|
||||||
ui.text
|
ui.text
|
||||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
.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.
|
/// 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
|
/// `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
|
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
||||||
/// and nothing else.
|
/// and nothing else.
|
||||||
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
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
|
||||||
|
// something else. Caught at the submission rather than on screen,
|
||||||
|
// where it reads as fragments of unrelated letters.
|
||||||
|
debug_assert_eq!(
|
||||||
|
text.generation,
|
||||||
|
self.atlas_generation(),
|
||||||
|
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
||||||
|
re-render after the atlas was cleared",
|
||||||
|
text.generation,
|
||||||
|
self.atlas_generation(),
|
||||||
|
);
|
||||||
let flags_for = |is_color| {
|
let flags_for = |is_color| {
|
||||||
if is_color {
|
if is_color {
|
||||||
GlyphPrimitive::IS_COLOR
|
GlyphPrimitive::IS_COLOR
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
||||||
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
||||||
render::MoveOffset,
|
render::{IMAGE_BINDING, MoveOffset},
|
||||||
util::{HashMap, HashSet, Id, Vec2},
|
util::{HashMap, HashSet, Id, Vec2},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,8 +18,35 @@ pub struct UiRenderState {
|
|||||||
|
|
||||||
old_root: Option<WidgetId>,
|
old_root: Option<WidgetId>,
|
||||||
resized: bool,
|
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
|
||||||
|
/// ever drawn and was never emptied.
|
||||||
draw_started: HashSet<WidgetId>,
|
draw_started: HashSet<WidgetId>,
|
||||||
|
|
||||||
|
/// The widget currently holding exclusive pointer input, if any --
|
||||||
|
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
|
||||||
|
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
|
||||||
|
/// `CursorData` reaching this through an async `task_on` handler needs
|
||||||
|
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
|
||||||
|
/// dispatched to, not owned, at that layer) and this render state is
|
||||||
|
/// the one structure both backends (winit, android-view) already hold
|
||||||
|
/// across frames, the same way `old_root`/`resized` are -- see
|
||||||
|
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
|
||||||
|
/// a gesture has committed to panning or selecting, every later sample
|
||||||
|
/// of it must reach the same widget even if the finger has moved off
|
||||||
|
/// whatever hit region first noticed the press. Never held across an
|
||||||
|
/// await or another lock -- every access here is a single get/set.
|
||||||
|
captured: std::sync::Mutex<Option<WidgetId>>,
|
||||||
|
|
||||||
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
||||||
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
||||||
/// stated in terms of these two: an unchanged frame must cost 0 of
|
/// stated in terms of these two: an unchanged frame must cost 0 of
|
||||||
@@ -28,12 +55,22 @@ pub struct UiRenderState {
|
|||||||
draw_count: u64,
|
draw_count: u64,
|
||||||
region_mut_count: u64,
|
region_mut_count: u64,
|
||||||
mov_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A move chain more than this deep would mean something else is wrong
|
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||||
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
|
/// which walks the identical chain and must be kept in step with this
|
||||||
/// the identical bound and must be kept in step with this constant.
|
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
|
||||||
pub const MOVE_CHAIN_LIMIT: usize = 16;
|
/// 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.
|
||||||
|
pub const MOVE_CHAIN_LIMIT: usize = 64;
|
||||||
|
|
||||||
impl UiRenderState {
|
impl UiRenderState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -45,20 +82,29 @@ impl UiRenderState {
|
|||||||
old_root: None,
|
old_root: None,
|
||||||
resized: false,
|
resized: false,
|
||||||
draw_started: Default::default(),
|
draw_started: Default::default(),
|
||||||
|
captured: Default::default(),
|
||||||
draw_count: 0,
|
draw_count: 0,
|
||||||
region_mut_count: 0,
|
region_mut_count: 0,
|
||||||
mov_count: 0,
|
mov_count: 0,
|
||||||
|
shape_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
||||||
/// writes) counters -- call once per frame before `update()` to
|
/// writes, text shapes) counters -- call once per frame before
|
||||||
/// measure exactly that frame, per LAYOUT.md section 8.
|
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
||||||
pub fn take_counters(&mut self) -> (u64, u64, u64) {
|
///
|
||||||
|
/// 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),
|
std::mem::take(&mut self.draw_count),
|
||||||
std::mem::take(&mut self.region_mut_count),
|
std::mem::take(&mut self.region_mut_count),
|
||||||
std::mem::take(&mut self.mov_count),
|
std::mem::take(&mut self.mov_count),
|
||||||
|
std::mem::take(&mut self.shape_count),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +145,11 @@ impl UiRenderState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
|
debug_assert!(
|
||||||
|
self.draw_started.is_empty(),
|
||||||
|
"a previous frame left {} widget(s) marked as mid-draw",
|
||||||
|
self.draw_started.len(),
|
||||||
|
);
|
||||||
if self.needs_redraw_all(root) {
|
if self.needs_redraw_all(root) {
|
||||||
self.redraw_all(root, rsc);
|
self.redraw_all(root, rsc);
|
||||||
self.old_root = root.map(|r| r.id());
|
self.old_root = root.map(|r| r.id());
|
||||||
@@ -106,6 +157,8 @@ impl UiRenderState {
|
|||||||
} else if rsc.widgets().has_updates() {
|
} else if rsc.widgets().has_updates() {
|
||||||
self.redraw_updates(rsc);
|
self.redraw_updates(rsc);
|
||||||
}
|
}
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||||
@@ -121,6 +174,7 @@ impl UiRenderState {
|
|||||||
MaskIdx::NONE,
|
MaskIdx::NONE,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
MaskIdx::NONE,
|
||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,12 +209,27 @@ impl UiRenderState {
|
|||||||
mask: MaskIdx,
|
mask: MaskIdx,
|
||||||
old_children: Option<Vec<WidgetId>>,
|
old_children: Option<Vec<WidgetId>>,
|
||||||
old_move_slot: Option<MoveIdx>,
|
old_move_slot: Option<MoveIdx>,
|
||||||
|
old_own_mask: MaskIdx,
|
||||||
rsc: &mut dyn UiRsc,
|
rsc: &mut dyn UiRsc,
|
||||||
) {
|
) {
|
||||||
let mut old_children = old_children.unwrap_or_default();
|
let mut old_children = old_children.unwrap_or_default();
|
||||||
let mut old_move_slot = old_move_slot;
|
let mut old_move_slot = old_move_slot;
|
||||||
|
let mut own_mask = old_own_mask;
|
||||||
|
// Consumed here, not merely read: this call *is* the redraw the mark
|
||||||
|
// asked for, and leaving the mark set is what stranded a widget's
|
||||||
|
// primitives. `Painter::draw_twice` calls this twice for the same id
|
||||||
|
// in one frame (`List::place`'s measurement pass), and on the second
|
||||||
|
// call the still-set mark took the whole `if let` below -- including
|
||||||
|
// the `remove` that frees the first draw's primitives -- out of play,
|
||||||
|
// so `active.insert` at the end overwrote the only handles that could
|
||||||
|
// ever have freed them. The result is a full second copy of the row,
|
||||||
|
// drawn every frame from then on at the oversized measurement region
|
||||||
|
// and, with `List` setting no mask, outside the list's own bounds:
|
||||||
|
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
||||||
|
// The same shape reaches any dirty widget an ancestor redraws first.
|
||||||
|
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
if let Some(active) = self.active.get_mut(&id)
|
if let Some(active) = self.active.get_mut(&id)
|
||||||
&& !rsc.widgets().needs_redraw.contains(&id)
|
&& !dirty
|
||||||
{
|
{
|
||||||
// check to see if we can skip drawing first
|
// check to see if we can skip drawing first
|
||||||
if active.region == region {
|
if active.region == region {
|
||||||
@@ -187,6 +256,15 @@ impl UiRenderState {
|
|||||||
*r = r.outside(&from).within(®ion);
|
*r = r.outside(&from).within(®ion);
|
||||||
self.region_mut_count += 1;
|
self.region_mut_count += 1;
|
||||||
}
|
}
|
||||||
|
// `move_applied` is deliberately **not** touched here,
|
||||||
|
// unlike in `mov`: it counts the part of this widget's own
|
||||||
|
// move-slot delta that `region` has already absorbed, and
|
||||||
|
// this branch writes no delta at all -- the primitives were
|
||||||
|
// moved directly. Counting one would make
|
||||||
|
// `resolved_region` subtract a distance the chain never
|
||||||
|
// held, putting the hit box short of the drawing by
|
||||||
|
// exactly this step. See `ActiveData::move_applied`, and
|
||||||
|
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
|
||||||
active.region = region;
|
active.region = region;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -194,10 +272,25 @@ impl UiRenderState {
|
|||||||
let active = self.remove(id, false, rsc).unwrap();
|
let active = self.remove(id, false, rsc).unwrap();
|
||||||
old_children = active.children;
|
old_children = active.children;
|
||||||
old_move_slot = Some(active.move_slot);
|
old_move_slot = Some(active.move_slot);
|
||||||
|
own_mask = active.own_mask;
|
||||||
|
} else if dirty && self.active.contains_key(&id) {
|
||||||
|
// Dirty and already drawn: none of the fast paths above may be
|
||||||
|
// taken (the widget's own content changed, so its old primitives
|
||||||
|
// say nothing about its new ones), but they are also the only
|
||||||
|
// thing that frees them. Same two lines, reached the other way.
|
||||||
|
let active = self.remove(id, false, rsc).unwrap();
|
||||||
|
old_children = active.children;
|
||||||
|
old_move_slot = Some(active.move_slot);
|
||||||
|
own_mask = active.own_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw widget
|
// draw widget
|
||||||
self.draw_started.insert(id);
|
let reentrant = !self.draw_started.insert(id);
|
||||||
|
debug_assert!(
|
||||||
|
!reentrant,
|
||||||
|
"widget {id:?} is being drawn while its own draw is already on the stack; \
|
||||||
|
the second draw's primitives would orphan the first's"
|
||||||
|
);
|
||||||
|
|
||||||
let move_slot = match old_move_slot {
|
let move_slot = match old_move_slot {
|
||||||
// Reused across a real redraw of the same id: the fresh
|
// Reused across a real redraw of the same id: the fresh
|
||||||
@@ -226,11 +319,22 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The mask this widget was drawn *under*, kept aside because
|
||||||
|
// `Painter::set_mask` overwrites `painter.mask` with the widget's
|
||||||
|
// own new one -- and `ActiveData::mask`'s only consumer is
|
||||||
|
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
||||||
|
// the set one instead handed a `Masked` its own mask on every
|
||||||
|
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
||||||
|
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
||||||
|
// first time the composer's scroll area was redrawn on the
|
||||||
|
// emulator.
|
||||||
|
let inherited_mask = mask;
|
||||||
let mut painter = Painter {
|
let mut painter = Painter {
|
||||||
state: self,
|
state: self,
|
||||||
region,
|
region,
|
||||||
mask,
|
mask,
|
||||||
move_slot,
|
move_slot,
|
||||||
|
own_mask,
|
||||||
layer,
|
layer,
|
||||||
id,
|
id,
|
||||||
textures: Vec::new(),
|
textures: Vec::new(),
|
||||||
@@ -242,14 +346,26 @@ impl UiRenderState {
|
|||||||
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
||||||
painter.state.draw_count += 1;
|
painter.state.draw_count += 1;
|
||||||
let size = widget.draw(&mut painter);
|
let size = widget.draw(&mut painter);
|
||||||
|
// A reported length is consumed by containers that read `abs`,
|
||||||
|
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
|
||||||
|
// addition), so an unresolved `dp` in one is silently worth zero
|
||||||
|
// -- see `Len::fold_dp`, which is what a widget reporting a
|
||||||
|
// caller-declared size has to put it through.
|
||||||
|
debug_assert!(
|
||||||
|
size.x.dp == 0.0 && size.y.dp == 0.0,
|
||||||
|
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
|
||||||
|
report `Len::fold_dp(painter.density())` instead"
|
||||||
|
);
|
||||||
drop(widget);
|
drop(widget);
|
||||||
|
painter.state.draw_started.remove(&id);
|
||||||
|
|
||||||
let Painter {
|
let Painter {
|
||||||
state: _,
|
state: _,
|
||||||
rsc: _,
|
rsc: _,
|
||||||
region,
|
region,
|
||||||
mask,
|
mask: _,
|
||||||
move_slot,
|
move_slot,
|
||||||
|
own_mask,
|
||||||
textures,
|
textures,
|
||||||
primitives,
|
primitives,
|
||||||
children,
|
children,
|
||||||
@@ -265,10 +381,13 @@ impl UiRenderState {
|
|||||||
textures,
|
textures,
|
||||||
primitives,
|
primitives,
|
||||||
children,
|
children,
|
||||||
mask,
|
mask: inherited_mask,
|
||||||
layer,
|
layer,
|
||||||
size,
|
size,
|
||||||
move_slot,
|
move_slot,
|
||||||
|
own_mask,
|
||||||
|
move_applied: Vec2::ZERO,
|
||||||
|
repositioned: Vec2::ZERO,
|
||||||
};
|
};
|
||||||
|
|
||||||
// remove old children that weren't kept
|
// remove old children that weren't kept
|
||||||
@@ -296,6 +415,7 @@ impl UiRenderState {
|
|||||||
let from_px = from.top_left().to_abs(self.output_size);
|
let from_px = from.top_left().to_abs(self.output_size);
|
||||||
let to_px = to.top_left().to_abs(self.output_size);
|
let to_px = to.top_left().to_abs(self.output_size);
|
||||||
let delta = to_px - from_px;
|
let delta = to_px - from_px;
|
||||||
|
active.move_applied += delta;
|
||||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
||||||
entry.delta[0] += delta.x;
|
entry.delta[0] += delta.x;
|
||||||
entry.delta[1] += delta.y;
|
entry.delta[1] += delta.y;
|
||||||
@@ -330,6 +450,8 @@ impl UiRenderState {
|
|||||||
let Some(active) = self.active.get(&id) else {
|
let Some(active) = self.active.get(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let move_applied = active.move_applied;
|
||||||
|
let repositioned = active.repositioned;
|
||||||
let from = active
|
let from = active
|
||||||
.size
|
.size
|
||||||
.to_uivec2(self.density)
|
.to_uivec2(self.density)
|
||||||
@@ -339,8 +461,27 @@ impl UiRenderState {
|
|||||||
let from_px = from.top_left().to_abs(self.output_size);
|
let from_px = from.top_left().to_abs(self.output_size);
|
||||||
let to_px = to.top_left().to_abs(self.output_size);
|
let to_px = to.top_left().to_abs(self.output_size);
|
||||||
let delta = to_px - from_px;
|
let delta = to_px - from_px;
|
||||||
|
// Not `delta` alone: a parent may have `mov`ed this widget to a
|
||||||
|
// region that itself moved earlier in the same frame, and that
|
||||||
|
// part of the slot is `move_applied`'s, not this call's. Writing
|
||||||
|
// `delta` on its own dropped it and put the content back at the
|
||||||
|
// pre-move position. `from` is computed against `active.region`,
|
||||||
|
// which `mov` already updated, so `delta` is purely the placement
|
||||||
|
// inside the region and the two summands never overlap.
|
||||||
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
||||||
entry.delta = [delta.x, delta.y];
|
debug_assert_eq!(
|
||||||
|
entry.delta,
|
||||||
|
[
|
||||||
|
move_applied.x + repositioned.x,
|
||||||
|
move_applied.y + repositioned.y
|
||||||
|
],
|
||||||
|
"widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \
|
||||||
|
the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`"
|
||||||
|
);
|
||||||
|
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
|
||||||
|
if let Some(active) = self.active.get_mut(&id) {
|
||||||
|
active.repositioned = delta;
|
||||||
|
}
|
||||||
self.mov_count += 1;
|
self.mov_count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +498,13 @@ impl UiRenderState {
|
|||||||
active.textures.clear();
|
active.textures.clear();
|
||||||
rsc.ui_mut().textures.free();
|
rsc.ui_mut().textures.free();
|
||||||
if undraw {
|
if undraw {
|
||||||
|
// A captured widget that goes away mid-gesture (List's
|
||||||
|
// virtualisation retiring a row, a rebuild) must not leave
|
||||||
|
// the pointer permanently captured by an id nothing will
|
||||||
|
// ever draw again -- `captured`'s own path out.
|
||||||
|
if *self.captured.lock().unwrap() == Some(id) {
|
||||||
|
*self.captured.lock().unwrap() = None;
|
||||||
|
}
|
||||||
// Permanent removal: retire this widget's own move slot
|
// Permanent removal: retire this widget's own move slot
|
||||||
// (the self-ownership ref taken when it was allocated) and
|
// (the self-ownership ref taken when it was allocated) and
|
||||||
// the up-link ref it held on its parent's slot -- read from
|
// the up-link ref it held on its parent's slot -- read from
|
||||||
@@ -364,6 +512,11 @@ impl UiRenderState {
|
|||||||
// the parent's own `ActiveData` may already be gone by the
|
// the parent's own `ActiveData` may already be gone by the
|
||||||
// time a deep descendant is retired (see LAYOUT.md
|
// time a deep descendant is retired (see LAYOUT.md
|
||||||
// section 2's lifecycle note).
|
// section 2's lifecycle note).
|
||||||
|
if active.own_mask != MaskIdx::NONE {
|
||||||
|
// The self-ownership ref `Painter::set_mask` took when
|
||||||
|
// it allocated this widget's own mask slot.
|
||||||
|
rsc.ui_mut().masks.remove(active.own_mask);
|
||||||
|
}
|
||||||
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
||||||
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
||||||
if parent_slot != MoveOffset::NONE_PARENT {
|
if parent_slot != MoveOffset::NONE_PARENT {
|
||||||
@@ -429,6 +582,100 @@ impl UiRenderState {
|
|||||||
self.active.len()
|
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. `(layer, inst_idx, owner)`
|
||||||
|
/// each.
|
||||||
|
///
|
||||||
|
/// 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<(usize, usize, WidgetId)> {
|
||||||
|
let mut orphans = Vec::new();
|
||||||
|
for (layer, primitives) in self.layers.iter() {
|
||||||
|
for (inst_idx, owner, is_image) in primitives.live_instances() {
|
||||||
|
let owned = self.active.get(&owner).is_some_and(|a| {
|
||||||
|
a.primitives.iter().any(|h| {
|
||||||
|
h.layer == layer
|
||||||
|
&& h.inst_idx == inst_idx
|
||||||
|
&& (h.binding == IMAGE_BINDING) == is_image
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !owned {
|
||||||
|
orphans.push((layer, inst_idx, owner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
fn primitive_counts_agree(&self) -> bool {
|
||||||
|
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
|
||||||
|
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||||
|
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();
|
||||||
|
let mut lines: Vec<String> = orphans
|
||||||
|
.iter()
|
||||||
|
.take(8)
|
||||||
|
.map(|(layer, idx, owner)| {
|
||||||
|
let alive = self.active.contains_key(owner);
|
||||||
|
format!(
|
||||||
|
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
|
||||||
|
rsc.widgets().label(*owner),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if orphans.len() > lines.len() {
|
||||||
|
lines.push(format!(" ... and {} more", orphans.len() - lines.len()));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{} primitive(s) are drawn but owned by nobody -- a stale copy \
|
||||||
|
nothing will ever move or free:\n{}",
|
||||||
|
orphans.len(),
|
||||||
|
lines.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give `id` exclusive pointer input from the next `run_sensors` call
|
||||||
|
/// on -- see `captured`'s field doc. Overwrites any previous capture
|
||||||
|
/// (a gesture that starts a new one has already decided the old one
|
||||||
|
/// is over).
|
||||||
|
pub fn capture_pointer(&self, id: WidgetId) {
|
||||||
|
*self.captured.lock().unwrap() = Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release exclusive pointer input, if any is held -- called once
|
||||||
|
/// `run_sensors` has delivered the terminal `Drop` to the capturing
|
||||||
|
/// widget, or by that widget itself if it decides the gesture is over
|
||||||
|
/// some other way.
|
||||||
|
pub fn release_pointer(&self) {
|
||||||
|
*self.captured.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget currently holding exclusive pointer input, if any.
|
||||||
|
pub fn captured_pointer(&self) -> Option<WidgetId> {
|
||||||
|
*self.captured.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
|
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
|
||||||
self.active.iter().filter_map(move |(&id, inst)| {
|
self.active.iter().filter_map(move |(&id, inst)| {
|
||||||
let l = widgets.label(id);
|
let l = widgets.label(id);
|
||||||
@@ -455,7 +702,12 @@ impl UiRenderState {
|
|||||||
/// section 2b.
|
/// section 2b.
|
||||||
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
||||||
let active = self.active.get(&id.id())?;
|
let active = self.active.get(&id.id())?;
|
||||||
let delta = self.resolve_move_chain(active.move_slot, rsc);
|
// 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)))
|
Some(active.region.offset(UiVec2::abs(delta)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,26 +715,56 @@ impl UiRenderState {
|
|||||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||||
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||||
/// about where the chain ends.
|
/// about where the chain ends.
|
||||||
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||||
let offsets = &rsc.ui().move_offsets;
|
let offsets = &rsc.ui().move_offsets;
|
||||||
let mut delta = Vec2::ZERO;
|
let mut delta = Vec2::ZERO;
|
||||||
|
let mut at = slot;
|
||||||
for i in 0..MOVE_CHAIN_LIMIT {
|
for i in 0..MOVE_CHAIN_LIMIT {
|
||||||
let entry = &offsets[slot.idx()];
|
let entry = &offsets[at.idx()];
|
||||||
delta.x += entry.delta[0];
|
delta.x += entry.delta[0];
|
||||||
delta.y += entry.delta[1];
|
delta.y += entry.delta[1];
|
||||||
if entry.parent == MoveOffset::NONE_PARENT {
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
return delta;
|
return delta;
|
||||||
}
|
}
|
||||||
slot = Id::preset(entry.parent);
|
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!(
|
debug_assert!(
|
||||||
i + 1 < MOVE_CHAIN_LIMIT,
|
i + 1 < MOVE_CHAIN_LIMIT,
|
||||||
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
|
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
|
||||||
probably cyclic"
|
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
|
||||||
|
nests deeper than shader.wgsl's own walk of the same bound",
|
||||||
|
chain = Self::move_chain_debug(slot, offsets)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
delta
|
delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||||
|
/// `MOVE_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;
|
||||||
|
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
|
||||||
|
let entry = &offsets[at.idx()];
|
||||||
|
parts.push(format!(
|
||||||
|
"{}({}, {})",
|
||||||
|
at.idx(),
|
||||||
|
entry.delta[0],
|
||||||
|
entry.delta[1]
|
||||||
|
));
|
||||||
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at = Id::preset(entry.parent);
|
||||||
|
}
|
||||||
|
parts.join(" -> ")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
||||||
let region = self.resolved_region(id, rsc)?;
|
let region = self.resolved_region(id, rsc)?;
|
||||||
Some(region.to_px(self.output_size))
|
Some(region.to_px(self.output_size))
|
||||||
@@ -491,7 +773,10 @@ impl UiRenderState {
|
|||||||
/// redraws a widget that's currently active (drawn)
|
/// redraws a widget that's currently active (drawn)
|
||||||
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
self.draw_started.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) {
|
if self.draw_started.contains(&id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -515,9 +800,9 @@ impl UiRenderState {
|
|||||||
active.mask,
|
active.mask,
|
||||||
Some(active.children),
|
Some(active.children),
|
||||||
Some(active.move_slot),
|
Some(active.move_slot),
|
||||||
|
active.own_mask,
|
||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If this widget's own reported size changed, its parent's layout
|
// If this widget's own reported size changed, its parent's layout
|
||||||
// (which placed it using the old size) is now stale and needs to
|
// (which placed it using the old size) is now stale and needs to
|
||||||
// relay out too. Checked after the real draw, not before it --
|
// relay out too. Checked after the real draw, not before it --
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ pub trait Widget: Any {
|
|||||||
fn access_role(&self) -> accesskit::Role {
|
fn access_role(&self) -> accesskit::Role {
|
||||||
accesskit::Role::Unknown
|
accesskit::Role::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance whatever this widget is animating to `now`, and say whether
|
||||||
|
/// it is still animating afterwards. Default: nothing is, so a widget
|
||||||
|
/// opts in by overriding this *and* by something calling
|
||||||
|
/// [`crate::UiData::animate`] with its id when the animation starts --
|
||||||
|
/// which is that animation's path out, since the driver
|
||||||
|
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
|
||||||
|
/// answers `false`.
|
||||||
|
///
|
||||||
|
/// Called once per frame, before the frame's draw, by whichever
|
||||||
|
/// backend owns the surface; a `true` answer is what makes that
|
||||||
|
/// backend ask for another frame. So this is the only thing in iris
|
||||||
|
/// that moves without an input event, and a widget that animates
|
||||||
|
/// without registering simply never moves -- which is exactly how a
|
||||||
|
/// finger fling looked on Iris's phone before this existed.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for () {
|
impl Widget for () {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "rig-input"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one
|
||||||
|
# of the `.touch` files the headless tests use into whatever window is
|
||||||
|
# under a Wayland compositor, so the *same recording* drives the
|
||||||
|
# assertion layer and the layer a person looks at.
|
||||||
|
#
|
||||||
|
# It exists because this machine's compositor has no pointer to move.
|
||||||
|
# `run-headless.sh` starts sway on the headless backend with no input
|
||||||
|
# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`),
|
||||||
|
# so `swaymsg seat - cursor press` reports success and nothing reaches
|
||||||
|
# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots
|
||||||
|
# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would
|
||||||
|
# be ignored by a compositor that is not reading libinput. The
|
||||||
|
# virtual-pointer protocol is what is left, and it is a client protocol,
|
||||||
|
# so it needs no devices and no root.
|
||||||
|
|
||||||
|
# Named for what it does rather than for the crate, since the crate may
|
||||||
|
# grow a keyboard replay beside it.
|
||||||
|
[[bin]]
|
||||||
|
name = "replay-touch"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# `TouchScript` -- the same parser the harness uses, so a file that
|
||||||
|
# replays here and one that replays headless can never disagree.
|
||||||
|
iris = { path = ".." }
|
||||||
|
wayland-client = "0.31.15"
|
||||||
|
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Replays a `.touch` file into the compositor as a left-button drag --
|
||||||
|
//! see this crate's `Cargo.toml` for why it exists rather than
|
||||||
|
//! `swaymsg seat - cursor`.
|
||||||
|
//!
|
||||||
|
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
|
||||||
|
//!
|
||||||
|
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
|
||||||
|
//! pointer protocol positions absolutely against an extent rather than
|
||||||
|
//! in pixels; passing the output size makes a script's coordinates mean
|
||||||
|
//! the same pixels they mean in the headless tests.
|
||||||
|
//!
|
||||||
|
//! Replayed in real time (the sleeps between samples are the gaps in the
|
||||||
|
//! file), because winit has no timestamp on a pointer event and dates
|
||||||
|
//! each one when it arrives -- so a 20ms flick has to actually take
|
||||||
|
//! 20ms here, unlike layer 1 where the sample carries its own time.
|
||||||
|
|
||||||
|
use iris::harness::{TouchAction, TouchScript};
|
||||||
|
use std::time::Duration;
|
||||||
|
use wayland_client::protocol::wl_pointer::ButtonState;
|
||||||
|
use wayland_client::protocol::{wl_registry, wl_seat};
|
||||||
|
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
|
||||||
|
use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||||
|
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||||
|
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
|
||||||
|
/// button code, not a wayland enum.
|
||||||
|
const BTN_LEFT: u32 = 0x110;
|
||||||
|
|
||||||
|
/// How long the pointer sits at the gesture's first position before the
|
||||||
|
/// script starts -- see the comment at the pre-step in `main`.
|
||||||
|
const SETTLE: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Globals {
|
||||||
|
seat: Option<wl_seat::WlSeat>,
|
||||||
|
manager: Option<ZwlrVirtualPointerManagerV1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} = event
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match interface.as_str() {
|
||||||
|
"wl_seat" => {
|
||||||
|
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||||
|
}
|
||||||
|
"zwlr_virtual_pointer_manager_v1" => {
|
||||||
|
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate_noop!(Globals: ignore wl_seat::WlSeat);
|
||||||
|
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
|
||||||
|
delegate_noop!(Globals: ZwlrVirtualPointerV1);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let [width, height, path] = args.as_slice() else {
|
||||||
|
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
|
||||||
|
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
|
||||||
|
|
||||||
|
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
|
||||||
|
fail(&format!(
|
||||||
|
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let mut queue = conn.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
let display = conn.display();
|
||||||
|
display.get_registry(&qh, ());
|
||||||
|
let mut globals = Globals::default();
|
||||||
|
queue
|
||||||
|
.roundtrip(&mut globals)
|
||||||
|
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
|
||||||
|
|
||||||
|
let manager = globals.manager.as_ref().unwrap_or_else(|| {
|
||||||
|
fail(
|
||||||
|
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
|
||||||
|
be synthesised; sway and every wlroots compositor do",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
|
||||||
|
|
||||||
|
// Put the pointer where the gesture starts and let the compositor
|
||||||
|
// settle before anything is pressed. Without this the press is
|
||||||
|
// dropped: sway has just learned about this pointer, and a button
|
||||||
|
// sent in the same breath as the motion that first puts it over a
|
||||||
|
// window arrives before there is a focused surface to send it to --
|
||||||
|
// winit sees `CursorEntered`, the moves and the *release*, never the
|
||||||
|
// press, so the gesture reads as a hover and nothing scrolls. Found
|
||||||
|
// by printing winit's own events; the settle is what fixed it.
|
||||||
|
if let Some(first) = script.samples.first() {
|
||||||
|
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
|
||||||
|
pointer.frame();
|
||||||
|
conn.flush()
|
||||||
|
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
|
||||||
|
std::thread::sleep(SETTLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut previous = 0;
|
||||||
|
for sample in &script.samples {
|
||||||
|
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
|
||||||
|
previous = sample.t_ms;
|
||||||
|
let t = sample.t_ms as u32;
|
||||||
|
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
|
||||||
|
// One frame per sample, so the compositor delivers them as
|
||||||
|
// separate pointer frames rather than coalescing the whole
|
||||||
|
// gesture -- the shape the file recorded is the point.
|
||||||
|
pointer.frame();
|
||||||
|
// The button goes in a frame of its own, *after* the motion has
|
||||||
|
// been committed. Sent in the same frame as the motion that
|
||||||
|
// first puts the pointer over the window, sway drops it: the
|
||||||
|
// client sees `CursorEntered` and the moves but never a
|
||||||
|
// `MouseInput { state: Pressed }`, so the whole gesture reads as
|
||||||
|
// a hover and nothing scrolls. Found exactly that way, by
|
||||||
|
// printing winit's events.
|
||||||
|
let state = match sample.action {
|
||||||
|
TouchAction::Down => Some(ButtonState::Pressed),
|
||||||
|
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
|
||||||
|
TouchAction::Move => None,
|
||||||
|
};
|
||||||
|
if let Some(state) = state {
|
||||||
|
pointer.button(t, BTN_LEFT, state);
|
||||||
|
pointer.frame();
|
||||||
|
}
|
||||||
|
conn.flush()
|
||||||
|
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
|
||||||
|
}
|
||||||
|
pointer.destroy();
|
||||||
|
conn.flush().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(text: &str, what: &str) -> u32 {
|
||||||
|
text.parse()
|
||||||
|
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(message: &str) -> ! {
|
||||||
|
eprintln!("replay-touch: {message}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
@@ -3,6 +3,25 @@
|
|||||||
#
|
#
|
||||||
# ./run-headless.sh tabs [-- cargo args]
|
# ./run-headless.sh tabs [-- cargo args]
|
||||||
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
||||||
|
# ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
|
||||||
|
# ./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
|
||||||
|
# --shot /tmp/p.png -- -p transcript-fixture
|
||||||
|
#
|
||||||
|
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
|
||||||
|
# and the window take Iris's phone's own size and density (1080x2424 at
|
||||||
|
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
|
||||||
|
# carried in `transcript_fixture::PHONE_*`), and `IRIS_SCALE` hands that
|
||||||
|
# density to iris the way `DisplayMetrics.density` does on Android
|
||||||
|
# (`iris::default::content_scale`). So a screenshot from here and one
|
||||||
|
# from the phone are the same layout at the same density, and what
|
||||||
|
# differs is only the renderer. Without it the output stays desktop-
|
||||||
|
# shaped, which is what every other example wants.
|
||||||
|
#
|
||||||
|
# `--replay FILE` drives one of the `.touch` recordings the headless
|
||||||
|
# tests use (`iris/transcript-fixture/touch/`) into the window through
|
||||||
|
# `rig-input`'s `replay-touch` -- one recording, both layers. With
|
||||||
|
# `--shot` it also writes `<shot>-before.png` from just before the
|
||||||
|
# gesture, since "the list moved" is a claim about two pictures.
|
||||||
#
|
#
|
||||||
# `--bin` runs a real crate binary instead of an example (E4's
|
# `--bin` runs a real crate binary instead of an example (E4's
|
||||||
# `desktop-app`, which is a window a person runs, not a demo) --
|
# `desktop-app`, which is a window a person runs, not a demo) --
|
||||||
@@ -29,19 +48,31 @@ here=$(cd "$(dirname "$0")" && pwd)
|
|||||||
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
||||||
seconds=3
|
seconds=3
|
||||||
shot=""
|
shot=""
|
||||||
|
replay=""
|
||||||
example=""
|
example=""
|
||||||
kind=example
|
kind=example
|
||||||
|
phone=no
|
||||||
|
|
||||||
|
# The phone Iris runs the bench on. Not typed from memory: these are
|
||||||
|
# `transcript_fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
|
||||||
|
# in turn come from her own reports -- keep the three in step.
|
||||||
|
PHONE_MODE=1080x2424@120Hz
|
||||||
|
PHONE_SCALE=2.55
|
||||||
|
DESKTOP_MODE=1920x1200@60Hz
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--shot) shot=$2; shift 2 ;;
|
--shot) shot=$2; shift 2 ;;
|
||||||
--seconds) seconds=$2; shift 2 ;;
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
--bin) kind=bin; shift ;;
|
--bin) kind=bin; shift ;;
|
||||||
|
--phone) phone=yes; shift ;;
|
||||||
|
--replay) replay=$2; shift 2 ;;
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
*) example=$1; shift ;;
|
*) example=$1; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
||||||
|
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
|
||||||
|
|
||||||
mkdir -p "$run"
|
mkdir -p "$run"
|
||||||
export SWAYSOCK="$run/sway.sock"
|
export SWAYSOCK="$run/sway.sock"
|
||||||
@@ -78,6 +109,27 @@ export WAYLAND_DISPLAY
|
|||||||
|
|
||||||
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
||||||
|
|
||||||
|
# Set every run rather than only when it changes: this compositor is
|
||||||
|
# reused across runs (see the socket comment above), so a desktop-shaped
|
||||||
|
# run after a phone-shaped one would otherwise inherit the phone's output
|
||||||
|
# and silently screenshot the wrong size.
|
||||||
|
if [ "$phone" = yes ]; then
|
||||||
|
mode=$PHONE_MODE
|
||||||
|
export IRIS_SCALE="$PHONE_SCALE"
|
||||||
|
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
|
||||||
|
else
|
||||||
|
mode=$DESKTOP_MODE
|
||||||
|
fi
|
||||||
|
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
|
||||||
|
# The extent `replay-touch` positions against, so a script's coordinates
|
||||||
|
# are the output's own pixels.
|
||||||
|
out_w=${mode%x*}
|
||||||
|
out_h=${mode#*x}; out_h=${out_h%@*}
|
||||||
|
|
||||||
|
# Built before the app starts, so a compile error is not reported as a
|
||||||
|
# window that failed to move.
|
||||||
|
[ -z "$replay" ] || cargo build --bin replay-touch -p rig-input >&2
|
||||||
|
|
||||||
cd "$here"
|
cd "$here"
|
||||||
if [ "$kind" = bin ]; then
|
if [ "$kind" = bin ]; then
|
||||||
cargo build --bin "$example" "$@" >&2
|
cargo build --bin "$example" "$@" >&2
|
||||||
@@ -111,6 +163,18 @@ while [ $i -lt "$((seconds * 2))" ]; do
|
|||||||
i=$((i + 1)); sleep 0.5
|
i=$((i + 1)); sleep 0.5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
if [ -n "$shot" ]; then
|
||||||
|
grim "${shot%.png}-before.png"
|
||||||
|
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
|
||||||
|
fi
|
||||||
|
"$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
|
||||||
|
# A fling outlives the finger: the gesture's own last sample is not
|
||||||
|
# when the list stops. Long enough for Android's spline to settle
|
||||||
|
# (`FlingCalculator::duration` tops out around a second and a half).
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
if kill -0 "$pid" 2>/dev/null; then
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
|
|||||||
@@ -45,16 +45,38 @@ pub struct Insets {
|
|||||||
pub top: i32,
|
pub top: i32,
|
||||||
pub right: i32,
|
pub right: i32,
|
||||||
pub bottom: i32,
|
pub bottom: i32,
|
||||||
/// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate
|
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
|
||||||
/// from `bottom` (the system bars): a layout wants to know about the
|
/// pixels, separate from `bottom` (the system bars): a layout wants to
|
||||||
/// keyboard specifically, since it usually means "make room" rather
|
/// know about the keyboard specifically, since it usually means "make
|
||||||
/// than "stay clear of a corner".
|
/// room" rather than "stay clear of a corner".
|
||||||
pub ime_bottom: i32,
|
pub ime_bottom: i32,
|
||||||
|
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||||
|
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||||
|
/// are carried separately. They disagree for the frames the keyboard
|
||||||
|
/// spends sliding: visible, with a height still on its way to the full
|
||||||
|
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||||
|
/// anything asking "is the keyboard up" reads this. See
|
||||||
|
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||||
|
/// be sent *as* this boolean, which is what left the composer padded by
|
||||||
|
/// one pixel on Iris's phone.
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Shared {
|
pub struct Shared {
|
||||||
pub insets: Insets,
|
pub insets: Insets,
|
||||||
|
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||||
|
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||||
|
/// field of `Insets`, which is compared for equality each frame to
|
||||||
|
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||||
|
/// would make every dispatch look like a change.
|
||||||
|
///
|
||||||
|
/// It exists because "the keyboard does not push anything up" has two
|
||||||
|
/// completely different causes that look identical on screen -- the
|
||||||
|
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||||
|
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||||
|
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||||
|
pub updates: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||||
@@ -89,15 +111,19 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
right: jint,
|
right: jint,
|
||||||
bottom: jint,
|
bottom: jint,
|
||||||
ime_bottom: jint,
|
ime_bottom: jint,
|
||||||
|
ime_visible: jint,
|
||||||
) {
|
) {
|
||||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||||
shared.borrow_mut().insets = Insets {
|
let mut shared = shared.borrow_mut();
|
||||||
|
shared.insets = Insets {
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
right,
|
right,
|
||||||
bottom,
|
bottom,
|
||||||
ime_bottom,
|
ime_bottom,
|
||||||
|
ime_visible: ime_visible != 0,
|
||||||
};
|
};
|
||||||
|
shared.updates += 1;
|
||||||
}
|
}
|
||||||
// Insets can change (the keyboard opening) with no resize and no
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
// touch, so nothing else here would otherwise ask for a frame.
|
// touch, so nothing else here would otherwise ask for a frame.
|
||||||
@@ -115,7 +141,7 @@ pub fn register_native_methods<'local, 'other_local>(
|
|||||||
&[
|
&[
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
name: "applyWindowInsetsNative".into(),
|
name: "applyWindowInsetsNative".into(),
|
||||||
sig: "(JIIIII)V".into(),
|
sig: "(JIIIIII)V".into(),
|
||||||
fn_ptr: apply_window_insets as *mut c_void,
|
fn_ptr: apply_window_insets as *mut c_void,
|
||||||
},
|
},
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod attr;
|
|||||||
mod ime;
|
mod ime;
|
||||||
mod input;
|
mod input;
|
||||||
mod insets;
|
mod insets;
|
||||||
|
mod platform;
|
||||||
mod render;
|
mod render;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{
|
||||||
|
JNIEnv,
|
||||||
|
objects::{JObject, JValue},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::view::HasAndroidUiState;
|
||||||
|
|
||||||
|
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
|
||||||
|
/// real work is a JNI call and this runs deep inside the sensor dispatch
|
||||||
|
/// with no `CallbackCtx` in reach -- so it raises a flag that
|
||||||
|
/// `IrisViewPeer::after_input` consumes, exactly as
|
||||||
|
/// `pending_show_keyboard` does.
|
||||||
|
///
|
||||||
|
/// Last request wins: two links cannot be tapped in one frame, and a URL
|
||||||
|
/// left queued from a frame that somehow never reached `after_input`
|
||||||
|
/// would open at some unrelated later tap, which is worse than dropping
|
||||||
|
/// it.
|
||||||
|
impl<T: HasAndroidUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.android_state_mut().pending_open_url = Some(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||||
|
/// own context.
|
||||||
|
///
|
||||||
|
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||||
|
/// may be an application context rather than the activity's -- Android
|
||||||
|
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||||
|
/// and it is harmless when the context *is* an activity's.
|
||||||
|
///
|
||||||
|
/// Every failure is logged with the URL and returns; there is nothing to
|
||||||
|
/// fall back to, and the reader will see that nothing happened.
|
||||||
|
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||||
|
match try_open_url(env, view, url) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// A pending Java exception makes every later JNI call fail in
|
||||||
|
// ways nowhere near here, so it is cleared at the boundary.
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
log::warn!("could not open {url}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_open_url<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
view: &View<'local>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), android_view::jni::errors::Error> {
|
||||||
|
let context = env
|
||||||
|
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let jurl = env.new_string(url)?;
|
||||||
|
let uri = env.call_static_method(
|
||||||
|
"android/net/Uri",
|
||||||
|
"parse",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri;",
|
||||||
|
&[JValue::Object(jurl.as_ref())],
|
||||||
|
)?;
|
||||||
|
let action = env.new_string("android.intent.action.VIEW")?;
|
||||||
|
let intent = env.new_object(
|
||||||
|
"android/content/Intent",
|
||||||
|
"(Ljava/lang/String;Landroid/net/Uri;)V",
|
||||||
|
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&intent,
|
||||||
|
"addFlags",
|
||||||
|
"(I)Landroid/content/Intent;",
|
||||||
|
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&context,
|
||||||
|
"startActivity",
|
||||||
|
"(Landroid/content/Intent;)V",
|
||||||
|
&[JValue::Object(&JObject::from(intent))],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
|
||||||
|
/// a static-field read: it is part of the platform's stable ABI and
|
||||||
|
/// reading it costs two more JNI calls that can each fail.
|
||||||
|
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
|
||||||
@@ -7,9 +7,9 @@ use android_view::{
|
|||||||
jni::{
|
jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
objects::{GlobalRef, JValue},
|
objects::{GlobalRef, JValue},
|
||||||
sys::jint,
|
sys::{jint, jlong},
|
||||||
},
|
},
|
||||||
ndk::event::{Keycode, MotionAction},
|
ndk::event::{Axis, Keycode, MotionAction},
|
||||||
};
|
};
|
||||||
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
||||||
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
||||||
@@ -20,7 +20,7 @@ use std::{
|
|||||||
marker::{PhantomData, Sized},
|
marker::{PhantomData, Sized},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Instant,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -54,6 +54,10 @@ pub struct AndroidUiState {
|
|||||||
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
|
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
|
||||||
/// in reach.
|
/// in reach.
|
||||||
pub pending_show_keyboard: bool,
|
pub pending_show_keyboard: bool,
|
||||||
|
/// A URL a tapped link asked the platform to open, for the same
|
||||||
|
/// reason `pending_show_keyboard` is a flag rather than a call --
|
||||||
|
/// see `android/platform.rs`.
|
||||||
|
pub pending_open_url: Option<String>,
|
||||||
/// Window insets, filled in from outside the normal `ViewPeer` callback
|
/// Window insets, filled in from outside the normal `ViewPeer` callback
|
||||||
/// path -- see `android/insets.rs` for why they need a registry of
|
/// path -- see `android/insets.rs` for why they need a registry of
|
||||||
/// their own.
|
/// their own.
|
||||||
@@ -113,6 +117,7 @@ impl AndroidUiState {
|
|||||||
last_click: Instant::now(),
|
last_click: Instant::now(),
|
||||||
compose_len: 0,
|
compose_len: 0,
|
||||||
pending_show_keyboard: false,
|
pending_show_keyboard: false,
|
||||||
|
pending_open_url: None,
|
||||||
shared,
|
shared,
|
||||||
access_adapter: Default::default(),
|
access_adapter: Default::default(),
|
||||||
access: AccessTree::new(),
|
access: AccessTree::new(),
|
||||||
@@ -125,6 +130,28 @@ impl AndroidUiState {
|
|||||||
pub fn insets(&self) -> Insets {
|
pub fn insets(&self) -> Insets {
|
||||||
self.shared.borrow().insets
|
self.shared.borrow().insets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The insets state as one line for a diagnostics pane, including how
|
||||||
|
/// many times the platform has delivered any -- see
|
||||||
|
/// `insets::Shared::updates` for why the count is the load-bearing
|
||||||
|
/// part. `dispatches=0` says the listener has never run and the
|
||||||
|
/// numbers beside it are defaults rather than measurements, which is
|
||||||
|
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
|
||||||
|
/// "design the unknown state first").
|
||||||
|
pub fn insets_report(&self) -> String {
|
||||||
|
let shared = self.shared.borrow();
|
||||||
|
let i = shared.insets;
|
||||||
|
if shared.updates == 0 {
|
||||||
|
return "insets: dispatches=0 -- the platform has never called \
|
||||||
|
onApplyWindowInsets, so nothing below was measured"
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \
|
||||||
|
ime_visible={}",
|
||||||
|
shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HasRoot for AndroidUiState {
|
impl HasRoot for AndroidUiState {
|
||||||
@@ -190,7 +217,12 @@ pub struct WindowInsets {
|
|||||||
pub top: f32,
|
pub top: f32,
|
||||||
pub right: f32,
|
pub right: f32,
|
||||||
pub bottom: f32,
|
pub bottom: f32,
|
||||||
|
/// How much of the window the keyboard covers, in physical pixels --
|
||||||
|
/// what a layout pads by. See `insets::Insets::ime_visible` for why
|
||||||
|
/// "is the keyboard up" is a separate field rather than this one
|
||||||
|
/// compared against zero.
|
||||||
pub ime_bottom: f32,
|
pub ime_bottom: f32,
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WindowInsets {
|
impl WindowInsets {
|
||||||
@@ -201,6 +233,7 @@ impl WindowInsets {
|
|||||||
right: insets.right as f32,
|
right: insets.right as f32,
|
||||||
bottom: insets.bottom as f32,
|
bottom: insets.bottom as f32,
|
||||||
ime_bottom: insets.ime_bottom as f32,
|
ime_bottom: insets.ime_bottom as f32,
|
||||||
|
ime_visible: insets.ime_visible,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +312,12 @@ pub struct IrisViewPeer<State: AndroidAppState> {
|
|||||||
pub(super) render: UiRenderState,
|
pub(super) render: UiRenderState,
|
||||||
pub(super) state: State,
|
pub(super) state: State,
|
||||||
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
||||||
|
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
|
||||||
|
/// captured from the first `MotionEvent` this view receives and never
|
||||||
|
/// changed after -- how `on_touch_event` dates every touch sample. Its
|
||||||
|
/// path out is the peer's own drop: it holds nothing but two numbers
|
||||||
|
/// and is meaningless to any other view.
|
||||||
|
input_clock: Option<(Instant, jlong)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
||||||
@@ -302,12 +341,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common tail for every callback that might have changed the cursor,
|
/// One pointer sample through the sensors, plus the platform calls a
|
||||||
/// the text focus, or the widget tree: run the sensors that touch
|
/// handler can only ask for by raising a flag. Split out of
|
||||||
/// input feeds, then ask for a frame if the result needs drawing.
|
/// [`Self::after_input`] because a batched `MotionEvent` carries
|
||||||
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
/// several samples that all belong to the same *frame*
|
||||||
/// android-view's several entry points instead of winit's one.
|
/// (`on_touch_event`): each one is a real input frame the widgets must
|
||||||
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
/// see, but only the last one ends the frame and asks for a redraw.
|
||||||
|
fn run_input_frame(&mut self, ctx: &mut CallbackCtx) {
|
||||||
let window_size = self.window_size();
|
let window_size = self.window_size();
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
let cursor = ui_state.cursor.clone();
|
let cursor = ui_state.cursor.clone();
|
||||||
@@ -324,6 +364,18 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
if std::mem::take(&mut ui_state.pending_show_keyboard) {
|
if std::mem::take(&mut ui_state.pending_show_keyboard) {
|
||||||
show_soft_input(&mut ctx.env, &ctx.view);
|
show_soft_input(&mut ctx.env, &ctx.view);
|
||||||
}
|
}
|
||||||
|
if let Some(url) = ui_state.pending_open_url.take() {
|
||||||
|
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common tail for every callback that might have changed the cursor,
|
||||||
|
/// the text focus, or the widget tree: run the sensors that touch
|
||||||
|
/// input feeds, then ask for a frame if the result needs drawing.
|
||||||
|
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
||||||
|
/// android-view's several entry points instead of winit's one.
|
||||||
|
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
||||||
|
self.run_input_frame(ctx);
|
||||||
|
|
||||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||||
@@ -368,6 +420,23 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
let current_insets = ui_state.insets();
|
let current_insets = ui_state.insets();
|
||||||
if current_insets != ui_state.last_insets {
|
if current_insets != ui_state.last_insets {
|
||||||
let physical = WindowInsets::from_physical(current_insets);
|
let physical = WindowInsets::from_physical(current_insets);
|
||||||
|
// One line per real insets change. Iris's phone is the only
|
||||||
|
// place several of these bugs reproduce and `adb logcat` is
|
||||||
|
// the only instrument there (this-machine-android: system
|
||||||
|
// tracing is broken on that device), so the numbers a layout
|
||||||
|
// is actually fed have to reach the log -- "the composer
|
||||||
|
// floats at launch" is unanswerable from a screenshot alone.
|
||||||
|
log::info!(
|
||||||
|
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
|
||||||
|
ime_visible={} window={:?}",
|
||||||
|
physical.left,
|
||||||
|
physical.top,
|
||||||
|
physical.right,
|
||||||
|
physical.bottom,
|
||||||
|
physical.ime_bottom,
|
||||||
|
physical.ime_visible,
|
||||||
|
self.window_size(),
|
||||||
|
);
|
||||||
self.state.android_state_mut().last_insets = current_insets;
|
self.state.android_state_mut().last_insets = current_insets;
|
||||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||||
}
|
}
|
||||||
@@ -391,6 +460,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
||||||
// what this does and does not measure.
|
// what this does and does not measure.
|
||||||
let frame_start = Instant::now();
|
let frame_start = Instant::now();
|
||||||
|
// Anything moving on its own -- today a `List` coasting through a
|
||||||
|
// fling -- is advanced here, before the draw, and asks for the
|
||||||
|
// next frame at the end of this one. See
|
||||||
|
// `UiData::tick_animations`; `default/mod.rs`'s
|
||||||
|
// `RedrawRequested` arm is the same two lines for winit.
|
||||||
|
let animating = self.rsc.ui.tick_animations(frame_start);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
self.render.update(&ui_state.root, &mut self.rsc);
|
self.render.update(&ui_state.root, &mut self.rsc);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
@@ -423,6 +498,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
.android_state_mut()
|
.android_state_mut()
|
||||||
.frame_report
|
.frame_report
|
||||||
.record_split(frame_start.elapsed(), submit_to_present);
|
.record_split(frame_start.elapsed(), submit_to_present);
|
||||||
|
// A frame callback is one-shot, so an animation that wants
|
||||||
|
// another frame has to say so every frame -- unlike `after_input`,
|
||||||
|
// which only has to ask when input dirtied something.
|
||||||
|
if animating {
|
||||||
|
ctx.view.post_frame_callback(&mut ctx.env);
|
||||||
|
}
|
||||||
let ui_state = self.state.android_state();
|
let ui_state = self.state.android_state();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"render(): after update active={} root_px={:?}",
|
"render(): after update active={} root_px={:?}",
|
||||||
@@ -531,7 +612,67 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||||
let x = event.x(&mut ctx.env);
|
let x = event.x(&mut ctx.env);
|
||||||
let y = event.y(&mut ctx.env);
|
let y = event.y(&mut ctx.env);
|
||||||
|
// The event's own clock, converted through one anchor taken on the
|
||||||
|
// first touch this view ever sees. Android reports sample times in
|
||||||
|
// the `SystemClock.uptimeMillis()` base, which is the same
|
||||||
|
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
|
||||||
|
// `(Instant, nanos)` pair converts every later sample exactly.
|
||||||
|
// Anchoring **once** rather than per event is what keeps the times
|
||||||
|
// ordered: a fresh `Instant::now()` per event, minus each sample's
|
||||||
|
// age inside it, can date a later event's first historical sample
|
||||||
|
// before the previous event's last one whenever delivery jitters by
|
||||||
|
// more than the batch spans -- and `VelocityTracker::add_sample`'s
|
||||||
|
// debug assert would rightly fire on that. See `CursorState::time`.
|
||||||
|
let event_time = event.event_time_nanos(&mut ctx.env);
|
||||||
|
let (anchor_at, anchor_nanos) =
|
||||||
|
*self.input_clock.get_or_insert((Instant::now(), event_time));
|
||||||
|
let at = |sample_time: jlong| {
|
||||||
|
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
|
||||||
|
};
|
||||||
|
|
||||||
|
// **Historical samples first.** A flick on a 120Hz screen is
|
||||||
|
// delivered as one or two `MotionEvent`s with the intermediate
|
||||||
|
// positions batched inside them, so reading only `x()`/`y()` threw
|
||||||
|
// away every sample but the last: the velocity tracker saw one
|
||||||
|
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
|
||||||
|
// 0.0 below two samples, and the release therefore flung at zero --
|
||||||
|
// Iris's phone, twice ("fling still doesn't work"), while a
|
||||||
|
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
|
||||||
|
// Replayed one at a time through the sensors rather than summarised,
|
||||||
|
// so the arbiter, the tracker and any other sensor all see the same
|
||||||
|
// motion the finger actually made; only the last sample ends the
|
||||||
|
// frame (`after_input`).
|
||||||
|
if matches!(action, MotionAction::Move) {
|
||||||
|
let history = event.history_size(&mut ctx.env);
|
||||||
|
// Android documents the historical samples as oldest first and
|
||||||
|
// the event's own sample as the newest of the batch; everything
|
||||||
|
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
||||||
|
// clock) assumes it, so say so here rather than at each reader.
|
||||||
|
let mut previous = anchor_nanos;
|
||||||
|
for pos in 0..history {
|
||||||
|
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
|
||||||
|
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
|
||||||
|
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
||||||
|
debug_assert!(
|
||||||
|
ht >= previous,
|
||||||
|
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
|
||||||
|
sample ahead of it -- the input clock is not what this assumes"
|
||||||
|
);
|
||||||
|
previous = ht;
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
|
ui_state.cursor.pos = vec2(hx, hy);
|
||||||
|
ui_state.cursor.time = at(ht);
|
||||||
|
self.run_input_frame(ctx);
|
||||||
|
}
|
||||||
|
debug_assert!(
|
||||||
|
event_time >= previous,
|
||||||
|
"the event's own sample is dated {event_time}ns, before its last historical \
|
||||||
|
sample at {previous}ns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ui_state = self.state.android_state_mut();
|
||||||
|
ui_state.cursor.time = at(event_time);
|
||||||
match action {
|
match action {
|
||||||
MotionAction::Down => {
|
MotionAction::Down => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
@@ -541,6 +682,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
MotionAction::Move => {
|
MotionAction::Move => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
}
|
}
|
||||||
|
// `Cancel` ends the gesture the same way `Up` does, and must:
|
||||||
|
// a release that never arrives leaves whichever widget took
|
||||||
|
// pointer capture holding it forever, with every later touch
|
||||||
|
// delivered to a drag nobody is performing. Confirmed present
|
||||||
|
// before this pass rather than assumed -- it was one of the
|
||||||
|
// three suspects listed for the phone's missing fling, and it
|
||||||
|
// is not the cause.
|
||||||
MotionAction::Up | MotionAction::Cancel => {
|
MotionAction::Up | MotionAction::Cancel => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
ui_state.cursor.buttons.left.update(false);
|
ui_state.cursor.buttons.left.update(false);
|
||||||
@@ -627,6 +775,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
// backgrounding) still goes through `AndroidRenderer::new` below,
|
// backgrounding) still goes through `AndroidRenderer::new` below,
|
||||||
// since `renderer` is `None` in that case.
|
// since `renderer` is `None` in that case.
|
||||||
let already_live = self.state.android_state().renderer.is_some();
|
let already_live = self.state.android_state().renderer.is_some();
|
||||||
|
log::info!(
|
||||||
|
"iris surface: surface_changed {width}x{height} already_live={already_live} \
|
||||||
|
glyphs_cached={} atlas_pages={}",
|
||||||
|
self.rsc.ui.text.atlas.glyph_count(),
|
||||||
|
self.rsc.ui.text.atlas.page_count(),
|
||||||
|
);
|
||||||
if already_live {
|
if already_live {
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
ui_state
|
ui_state
|
||||||
@@ -671,6 +825,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
// builds a new renderer, exactly where invalidation is
|
// builds a new renderer, exactly where invalidation is
|
||||||
// needed, never on the reuse branch, where it would throw
|
// needed, never on the reuse branch, where it would throw
|
||||||
// away perfectly valid GPU state for nothing.
|
// away perfectly valid GPU state for nothing.
|
||||||
|
log::info!(
|
||||||
|
"iris surface: new renderer built ({:?}), clearing glyph atlas: \
|
||||||
|
glyphs={} pages={}",
|
||||||
|
renderer.adapter_backend,
|
||||||
|
self.rsc.ui.text.atlas.glyph_count(),
|
||||||
|
self.rsc.ui.text.atlas.page_count(),
|
||||||
|
);
|
||||||
self.rsc.ui.text.atlas.clear();
|
self.rsc.ui.text.atlas.clear();
|
||||||
self.rsc.ui.textures.reset();
|
self.rsc.ui.textures.reset();
|
||||||
self.state.android_state_mut().renderer = Some(renderer);
|
self.state.android_state_mut().renderer = Some(renderer);
|
||||||
@@ -707,6 +868,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
_ctx: &mut CallbackCtx<'local>,
|
_ctx: &mut CallbackCtx<'local>,
|
||||||
_holder: &android_view::SurfaceHolder<'local>,
|
_holder: &android_view::SurfaceHolder<'local>,
|
||||||
) {
|
) {
|
||||||
|
log::info!(
|
||||||
|
"iris surface: surface_destroyed, tearing the renderer down \
|
||||||
|
(glyphs_cached={} atlas_pages={})",
|
||||||
|
self.rsc.ui.text.atlas.glyph_count(),
|
||||||
|
self.rsc.ui.text.atlas.page_count(),
|
||||||
|
);
|
||||||
self.state.android_state_mut().renderer = None;
|
self.state.android_state_mut().renderer = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +1019,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
|||||||
render,
|
render,
|
||||||
state,
|
state,
|
||||||
task_recv,
|
task_recv,
|
||||||
|
input_clock: None,
|
||||||
};
|
};
|
||||||
let id = android_view::register_view_peer(peer);
|
let id = android_view::register_view_peer(peer);
|
||||||
super::insets::register(id, shared);
|
super::insets::register(id, shared);
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ pub trait FocusHost {
|
|||||||
/// side effect the way a real double-click timer does.
|
/// side effect the way a real double-click timer does.
|
||||||
fn recent_click(&mut self) -> bool;
|
fn recent_click(&mut self) -> bool;
|
||||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
||||||
/// Called after a `TextEdit` becomes the focus target, with the region
|
/// Called on every tap that should put the IME on `id`: the tap that
|
||||||
/// it was hit in (`None` when the widget could not be located, which
|
/// *makes* a `TextEdit` the focus target, and any later tap on one that
|
||||||
/// happens for one it was just deselected from).
|
/// already is. `region` is where it was hit (`None` when the widget
|
||||||
|
/// could not be located, which happens for one it was just deselected
|
||||||
|
/// from). Implementations must be idempotent -- both backends' calls
|
||||||
|
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
|
||||||
|
/// lets the repeat tap be handled by the same call rather than by a
|
||||||
|
/// second "re-show" entry point beside it.
|
||||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||||
@@ -129,8 +134,59 @@ fn on_press(
|
|||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
) {
|
) {
|
||||||
if state.is_focused(id) {
|
if state.is_focused(id) {
|
||||||
let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click();
|
// Already focused, so there is no keyboard to withhold -- but a
|
||||||
id.edit(rsc).select(pos, size, sense.is_dragging(), recent);
|
// vertical drag still is not a selection. Android's own `EditText`
|
||||||
|
// scrolls its overflowed text on a vertical drag and starts a
|
||||||
|
// selection only from a long press; a scroll area wrapping this
|
||||||
|
// field (`Scroll::drag`) is what actually pans, and it needs the
|
||||||
|
// first frames of the gesture not to have selected anything behind
|
||||||
|
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||||
|
// `press_origin` carries the same meaning here as in the unfocused
|
||||||
|
// branch below -- "this gesture is still eligible", cleared the
|
||||||
|
// moment it becomes a drag -- so there is one flag, not two.
|
||||||
|
match sense {
|
||||||
|
CursorSense::PressStart(_) => {
|
||||||
|
let recent = state.recent_click();
|
||||||
|
id.edit(rsc).text.press_origin = Some(pos);
|
||||||
|
id.edit(rsc).select(pos, size, false, recent);
|
||||||
|
}
|
||||||
|
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
|
||||||
|
let mut ctx = id.edit(rsc);
|
||||||
|
let Some(origin) = ctx.text.press_origin else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
|
||||||
|
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ended = matches!(sense, CursorSense::PressEnd(_));
|
||||||
|
if ended {
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
}
|
||||||
|
ctx.select(pos, size, true, false);
|
||||||
|
// A tap on a field that is *already* focused asks for the
|
||||||
|
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||||
|
// keyboard by tapping on message box after it already
|
||||||
|
// happened once"). Dismissing the IME -- back gesture, or
|
||||||
|
// its own hide button -- takes the keyboard away but leaves
|
||||||
|
// the field focused, so without this the one branch that
|
||||||
|
// requests it (the unfocused one below) never runs again
|
||||||
|
// and the field is permanently unable to summon it.
|
||||||
|
// Android's own `EditText` does exactly this: every tap on
|
||||||
|
// a focused field calls `showSoftInput`, which is a no-op
|
||||||
|
// when the keyboard is already up.
|
||||||
|
//
|
||||||
|
// Gated on the same tap-vs-drag test the unfocused branch
|
||||||
|
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||||
|
// happens to finish inside the field does not summon a
|
||||||
|
// keyboard the reader was not asking for.
|
||||||
|
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||||
|
state.focus_gained(render.window_region(&id, &*rsc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use winit::dpi::{LogicalPosition, LogicalSize};
|
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||||
|
|
||||||
impl<T: HasDefaultUiState> FocusHost for T {
|
impl<T: HasDefaultUiState> FocusHost for T {
|
||||||
fn recent_click(&mut self) -> bool {
|
fn recent_click(&mut self) -> bool {
|
||||||
@@ -18,9 +18,11 @@ impl<T: HasDefaultUiState> FocusHost for T {
|
|||||||
let state = self.default_state_mut();
|
let state = self.default_state_mut();
|
||||||
let Some(region) = region else { return };
|
let Some(region) = region else { return };
|
||||||
state.window.set_ime_allowed(true);
|
state.window.set_ime_allowed(true);
|
||||||
|
// Physical, like everything else this backend hands winit --
|
||||||
|
// `default::content_scale`.
|
||||||
state.window.set_ime_cursor_area(
|
state.window.set_ime_cursor_area(
|
||||||
LogicalPosition::<f32>::from(region.top_left.tuple()),
|
PhysicalPosition::<f32>::from(region.top_left.tuple()),
|
||||||
LogicalSize::<f32>::from(region.size().tuple()),
|
PhysicalSize::<f32>::from(region.size().tuple()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
|
// `CursorState::time` is the sample's own time on every backend. winit
|
||||||
|
// carries no timestamp on a pointer event, so the moment it is handed to
|
||||||
|
// us is the closest measurement available here -- which is also what the
|
||||||
|
// drag code used to do for itself with `Instant::now()`, before Android's
|
||||||
|
// batched samples made the difference matter (see `sense::CursorState`).
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
use winit::{
|
use winit::{
|
||||||
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
||||||
keyboard::{Key, NamedKey},
|
keyboard::{Key, NamedKey},
|
||||||
@@ -11,18 +17,19 @@ pub struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
/// `scale_factor` converts winit's physical-pixel event coordinates
|
/// winit's pointer coordinates are physical pixels, which is the
|
||||||
/// into the same logical units `UiRenderNode`'s window uniform now uses
|
/// space the whole tree is laid out and hit-tested in -- see
|
||||||
/// (`default::render::UiRenderer::new`'s doc comment) -- without it,
|
/// `default::content_scale`. Nothing is converted here; `dp(...)`
|
||||||
/// a cursor position and the widget tree it's tested against would be
|
/// resolves against the density at layout time instead.
|
||||||
/// in two different units on any monitor whose scale factor isn't 1.0.
|
pub fn event(&mut self, event: &WindowEvent) -> bool {
|
||||||
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
|
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CursorMoved { position, .. } => {
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
|
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
|
||||||
self.cursor.exists = true;
|
self.cursor.exists = true;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::MouseInput { state, button, .. } => {
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
let buttons = &mut self.cursor.buttons;
|
let buttons = &mut self.cursor.buttons;
|
||||||
let pressed = state.is_pressed();
|
let pressed = state.is_pressed();
|
||||||
match button {
|
match button {
|
||||||
@@ -35,15 +42,14 @@ impl Input {
|
|||||||
WindowEvent::MouseWheel { delta, .. } => {
|
WindowEvent::MouseWheel { delta, .. } => {
|
||||||
let mut delta = match *delta {
|
let mut delta = match *delta {
|
||||||
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
|
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
|
||||||
MouseScrollDelta::PixelDelta(pos) => {
|
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
|
||||||
Vec2::new(pos.x as f32, pos.y as f32) / scale_factor
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if delta.x == 0.0 && self.modifiers.shift {
|
if delta.x == 0.0 && self.modifiers.shift {
|
||||||
delta.x = delta.y;
|
delta.x = delta.y;
|
||||||
delta.y = 0.0;
|
delta.y = 0.0;
|
||||||
}
|
}
|
||||||
self.cursor.scroll_delta = delta;
|
self.cursor.scroll_delta = delta;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::CursorLeft { .. } => {
|
WindowEvent::CursorLeft { .. } => {
|
||||||
self.cursor.exists = false;
|
self.cursor.exists = false;
|
||||||
@@ -74,14 +80,12 @@ impl Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultUiState {
|
impl DefaultUiState {
|
||||||
|
/// Physical pixels, matching `WindowEvent::Resized` (what
|
||||||
|
/// `UiRenderState::resize` is given) and the swapchain -- see
|
||||||
|
/// `default::content_scale`.
|
||||||
pub fn window_size(&self) -> Vec2 {
|
pub fn window_size(&self) -> Vec2 {
|
||||||
let window = self.renderer.window();
|
let size = self.renderer.window().inner_size();
|
||||||
let size = window.inner_size();
|
Vec2::new(size.width as f32, size.height as f32)
|
||||||
let scale_factor = window.scale_factor() as f32;
|
|
||||||
Vec2::new(
|
|
||||||
size.width as f32 / scale_factor,
|
|
||||||
size.height as f32 / scale_factor,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor_state(&self) -> &CursorState {
|
pub fn cursor_state(&self) -> &CursorState {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod access;
|
|||||||
mod app;
|
mod app;
|
||||||
mod attr;
|
mod attr;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod platform;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
pub use access::*;
|
pub use access::*;
|
||||||
@@ -24,6 +25,38 @@ pub use render::*;
|
|||||||
|
|
||||||
pub type Proxy<Event> = EventLoopProxy<Event>;
|
pub type Proxy<Event> = EventLoopProxy<Event>;
|
||||||
|
|
||||||
|
/// The desktop's `content_scale`: physical pixels per dp, the same
|
||||||
|
/// quantity Android reads from `DisplayMetrics.density` and feeds to
|
||||||
|
/// `UiRenderState::set_density` (`android::view::AndroidUiState::
|
||||||
|
/// content_scale`'s field comment). Everything in this backend is
|
||||||
|
/// physical pixels -- the window size, the pointer, the widget tree --
|
||||||
|
/// and `dp(...)` is what resolves against this at layout time, exactly
|
||||||
|
/// as on the phone. That is a correction from an earlier version that
|
||||||
|
/// divided winit's coordinates into a separate "logical" space instead:
|
||||||
|
/// it left `UiRenderState::resize` (physical, from `WindowEvent::
|
||||||
|
/// Resized`) and the window uniform (logical) disagreeing on any
|
||||||
|
/// display whose scale factor is not 1.0, and it rasterised glyphs at
|
||||||
|
/// one resolution to display them at another -- the blur the phone's own
|
||||||
|
/// stopgap produced before `dp` existed.
|
||||||
|
///
|
||||||
|
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
|
||||||
|
/// window runs the phone's density (`run-headless.sh --phone`,
|
||||||
|
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
|
||||||
|
/// somebody just typed, so it says so and uses the window's own answer
|
||||||
|
/// rather than silently laying out at the wrong density.
|
||||||
|
pub fn content_scale(window: &Window) -> f32 {
|
||||||
|
match std::env::var("IRIS_SCALE") {
|
||||||
|
Err(_) => window.scale_factor() as f32,
|
||||||
|
Ok(text) => match text.trim().parse::<f32>() {
|
||||||
|
Ok(scale) if scale > 0.0 => scale,
|
||||||
|
_ => {
|
||||||
|
log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own");
|
||||||
|
window.scale_factor() as f32
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DefaultUiState {
|
pub struct DefaultUiState {
|
||||||
pub root: Option<StrongWidget>,
|
pub root: Option<StrongWidget>,
|
||||||
pub renderer: UiRenderer,
|
pub renderer: UiRenderer,
|
||||||
@@ -213,8 +246,16 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
window.set_visible(true);
|
window.set_visible(true);
|
||||||
let default_state = DefaultUiState::new(window, access_adapter);
|
let default_state = DefaultUiState::new(window, access_adapter);
|
||||||
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
||||||
|
// Both copies of the density, set before the first widget is
|
||||||
|
// built so text shapes at the right size on the opening frame --
|
||||||
|
// the same pair `android::view::new_peer` sets from
|
||||||
|
// `content_scale`. See `iris_core::TextData::density` for why the
|
||||||
|
// shaper keeps its own.
|
||||||
|
let scale = content_scale(default_state.window.as_ref());
|
||||||
|
rsc.ui.text.density = scale;
|
||||||
let state = State::new(default_state, &mut rsc, proxy);
|
let state = State::new(default_state, &mut rsc, proxy);
|
||||||
let render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
|
render.set_density(scale);
|
||||||
Self {
|
Self {
|
||||||
rsc,
|
rsc,
|
||||||
state,
|
state,
|
||||||
@@ -246,8 +287,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
ui_state
|
ui_state
|
||||||
.access_adapter
|
.access_adapter
|
||||||
.process_event(&ui_state.window, &event);
|
.process_event(&ui_state.window, &event);
|
||||||
let scale_factor = ui_state.renderer.window().scale_factor() as f32;
|
let input_changed = ui_state.input.event(&event);
|
||||||
let input_changed = ui_state.input.event(&event, scale_factor);
|
|
||||||
let cursor_state = ui_state.cursor_state().clone();
|
let cursor_state = ui_state.cursor_state().clone();
|
||||||
let old = ui_state.focus;
|
let old = ui_state.focus;
|
||||||
if cursor_state.buttons.left.is_start() {
|
if cursor_state.buttons.left.is_start() {
|
||||||
@@ -266,9 +306,21 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
match &event {
|
match &event {
|
||||||
WindowEvent::CloseRequested => event_loop.exit(),
|
WindowEvent::CloseRequested => event_loop.exit(),
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
|
// Before the draw, so this frame shows this instant's
|
||||||
|
// position (`UiData::tick_animations`' own doc), and the
|
||||||
|
// window is asked for another frame while anything is
|
||||||
|
// still moving -- the winit half of what
|
||||||
|
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||||
|
// Android. Nothing else in iris moves without an input
|
||||||
|
// event.
|
||||||
|
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
|
||||||
|
let ui_state = state.default_state_mut();
|
||||||
render.update(&ui_state.root, rsc);
|
render.update(&ui_state.root, rsc);
|
||||||
ui_state.renderer.update(&mut rsc.ui, render);
|
ui_state.renderer.update(&mut rsc.ui, render);
|
||||||
ui_state.renderer.draw();
|
ui_state.renderer.draw();
|
||||||
|
if animating {
|
||||||
|
ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||||
// set actually changed this frame -- see `AccessTree`'s doc
|
// set actually changed this frame -- see `AccessTree`'s doc
|
||||||
// comment. `render` reflects the draw that just happened,
|
// comment. `render` reflects the draw that just happened,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use crate::prelude::HasDefaultUiState;
|
||||||
|
|
||||||
|
/// The desktop's URL opener: the platform's own "open this with whatever
|
||||||
|
/// is registered for it" command, detached so a browser starting slowly
|
||||||
|
/// cannot stall the event loop.
|
||||||
|
///
|
||||||
|
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
|
||||||
|
/// such crate shells out to anyway, and this is one call site.
|
||||||
|
impl<T: HasDefaultUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||||
|
("open", &[])
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
// `start` is a shell builtin, and its first argument is the
|
||||||
|
// window title -- an empty one, or a URL containing `&` ends
|
||||||
|
// up split.
|
||||||
|
("cmd", &["/C", "start", ""])
|
||||||
|
} else {
|
||||||
|
("xdg-open", &[])
|
||||||
|
};
|
||||||
|
match std::process::Command::new(program)
|
||||||
|
.args(first)
|
||||||
|
.arg(url)
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
// Named with the command that failed and the link it was for,
|
||||||
|
// since neither is recoverable from the OS error alone.
|
||||||
|
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,13 +66,11 @@ impl UiRenderer {
|
|||||||
self.config.width = size.width;
|
self.config.width = size.width;
|
||||||
self.config.height = size.height;
|
self.config.height = size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
// Logical, matching `new`'s own seed -- see the comment there.
|
// Physical, matching `new`'s own seed -- see the comment there.
|
||||||
let scale_factor = self.window.scale_factor() as f32;
|
self.ui.resize(
|
||||||
let logical = Vec2::new(
|
Vec2::new(size.width as f32, size.height as f32),
|
||||||
size.width as f32 / scale_factor,
|
&self.queue,
|
||||||
size.height as f32 / scale_factor,
|
|
||||||
);
|
);
|
||||||
self.ui.resize(logical, &self.queue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||||
@@ -85,7 +83,16 @@ impl UiRenderer {
|
|||||||
let size = window.inner_size();
|
let size = window.inner_size();
|
||||||
|
|
||||||
let instance = Instance::new(&InstanceDescriptor {
|
let instance = Instance::new(&InstanceDescriptor {
|
||||||
backends: Backends::PRIMARY,
|
// `force-gles` on the desktop too, not just on Android: the
|
||||||
|
// GLES backend has behaviour of its own (a one-layer array
|
||||||
|
// texture is a `GL_TEXTURE_2D` -- see
|
||||||
|
// `GpuTextures::create_array_texture`), and a machine with a
|
||||||
|
// real GPU is where that is cheap to reproduce and screenshot.
|
||||||
|
backends: if cfg!(feature = "force-gles") {
|
||||||
|
Backends::GL
|
||||||
|
} else {
|
||||||
|
Backends::PRIMARY
|
||||||
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,21 +160,12 @@ impl UiRenderer {
|
|||||||
// by:" chain as the message, since `UiRenderNode::new` returns it
|
// by:" chain as the message, since `UiRenderNode::new` returns it
|
||||||
// rather than letting wgpu's own default handler panic first (see
|
// rather than letting wgpu's own default handler panic first (see
|
||||||
// that function's doc comment).
|
// that function's doc comment).
|
||||||
// Logical size (physical / `scale_factor`), matching what the
|
// Physical size, the same units the swapchain, `WindowEvent::
|
||||||
// Android backend now reports too (`android::render::
|
// Resized`, the pointer and the widget tree all use -- see
|
||||||
// AndroidRenderer::new`, `content_scale`) -- the swapchain still
|
// `default::content_scale` for why this backend stopped dividing
|
||||||
// configures at the real physical resolution above; only the
|
// into a separate logical space, and what disagreed while it did.
|
||||||
// window uniform layout/hit-testing agree on is scaled. Without
|
let physical_size = Vec2::new(size.width as f32, size.height as f32);
|
||||||
// this a window on any monitor whose scale factor isn't 1.0 would
|
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
|
||||||
// have the identical "everything too small" bug RUST.md's P0 box
|
|
||||||
// found on Iris's phone, just never noticed here because this
|
|
||||||
// crate's own dev monitors happen to run at 1.0.
|
|
||||||
let scale_factor = window.scale_factor() as f32;
|
|
||||||
let logical_size = Vec2::new(
|
|
||||||
size.width as f32 / scale_factor,
|
|
||||||
size.height as f32 / scale_factor,
|
|
||||||
);
|
|
||||||
let ui = UiRenderNode::new(&device, &queue, &config, logical_size)
|
|
||||||
.expect("Could not create iris render node!");
|
.expect("Could not create iris render node!");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
||||||
|
//! in-process with **no window, no compositor and no GPU**, on an
|
||||||
|
//! explicit clock and a replayed touch stream.
|
||||||
|
//!
|
||||||
|
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
||||||
|
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
||||||
|
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
||||||
|
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
||||||
|
//! phone's size and density, feed it a recorded flick, and assert on
|
||||||
|
//! where the list ended up. What it answers that the emulator cannot:
|
||||||
|
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
||||||
|
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
||||||
|
//! ones -- so the gesture shape a finger actually makes is only
|
||||||
|
//! reproducible from a *file* of timestamped samples.
|
||||||
|
//!
|
||||||
|
//! It is a third backend in the sense `default/` and `android/` are, and
|
||||||
|
//! deliberately the smallest one: the platform half of each of those
|
||||||
|
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
||||||
|
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
||||||
|
//! so a test can assert the platform *was asked*, which is the only
|
||||||
|
//! thing either backend does with those calls anyway.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
||||||
|
//! h.frame(0);
|
||||||
|
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
||||||
|
//! h.frames_until(20, 2_000, 8);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
|
||||||
|
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TouchAction {
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
/// The gesture taken away by the system (a parent view claiming it, a
|
||||||
|
/// call arriving). It ends the press exactly as `Up` does -- a
|
||||||
|
/// release that never arrives leaves pointer capture held forever --
|
||||||
|
/// which is why a replay file can say it.
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchAction {
|
||||||
|
fn parse(word: &str) -> Option<Self> {
|
||||||
|
match word {
|
||||||
|
"down" => Some(Self::Down),
|
||||||
|
"move" => Some(Self::Move),
|
||||||
|
"up" => Some(Self::Up),
|
||||||
|
"cancel" => Some(Self::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct TouchSample {
|
||||||
|
/// Milliseconds since the start of the recording -- the sample's own
|
||||||
|
/// time, which becomes `CursorState::time`. See that field's doc for
|
||||||
|
/// why a replay may not date its samples by when the loop got to
|
||||||
|
/// them.
|
||||||
|
pub t_ms: u64,
|
||||||
|
pub action: TouchAction,
|
||||||
|
pub pos: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
||||||
|
/// blank lines ignored. Deliberately a plain text file rather than a
|
||||||
|
/// serialisation format -- it is written by hand as often as it is
|
||||||
|
/// recorded, and a diff of one has to be readable.
|
||||||
|
pub struct TouchScript {
|
||||||
|
pub samples: Vec<TouchSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchScript {
|
||||||
|
/// Parses a script, naming the line and what was wrong with it: these
|
||||||
|
/// are hand-written files, so a typo is the ordinary case and
|
||||||
|
/// "expected 4 fields" without a line number is not enough to fix it.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, String> {
|
||||||
|
let mut samples: Vec<TouchSample> = Vec::new();
|
||||||
|
for (i, line) in text.lines().enumerate() {
|
||||||
|
let line = line.split('#').next().unwrap_or("").trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
||||||
|
let mut words = line.split_whitespace();
|
||||||
|
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
) else {
|
||||||
|
return Err(at("expected `t_ms action x y`"));
|
||||||
|
};
|
||||||
|
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
||||||
|
let action = TouchAction::parse(action)
|
||||||
|
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
|
||||||
|
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
||||||
|
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
||||||
|
if let Some(last) = samples.last()
|
||||||
|
&& t_ms < last.t_ms
|
||||||
|
{
|
||||||
|
return Err(at("samples must be in time order"));
|
||||||
|
}
|
||||||
|
samples.push(TouchSample {
|
||||||
|
t_ms,
|
||||||
|
action,
|
||||||
|
pos: Vec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self { samples })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last sample's time, i.e. how long the recording runs.
|
||||||
|
pub fn end_ms(&self) -> u64 {
|
||||||
|
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts the frames something asked for without drawing any -- the
|
||||||
|
/// harness's `RequestRedraw`. A `List` coasting through a fling asks for
|
||||||
|
/// the next frame through this (`List::set_redraw_handle`), so a test can
|
||||||
|
/// tell "nothing moved" from "nothing was even asked to move".
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RedrawCounter(AtomicUsize);
|
||||||
|
|
||||||
|
impl RedrawCounter {
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.0.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestRedraw for RedrawCounter {
|
||||||
|
fn request_redraw(&self) {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness's app state: what each real backend keeps for the platform
|
||||||
|
/// half, recorded instead of performed.
|
||||||
|
pub struct HarnessState {
|
||||||
|
pub root: Option<StrongWidget>,
|
||||||
|
pub focus: Option<WeakWidget<TextEdit>>,
|
||||||
|
last_click: Instant,
|
||||||
|
/// How many times a tap asked for the keyboard (`FocusHost::
|
||||||
|
/// focus_gained` with a region -- `showSoftInput` on Android,
|
||||||
|
/// `set_ime_cursor_area` on winit). The platform's own answer is not
|
||||||
|
/// available here, so this says what was *asked*, and a test must not
|
||||||
|
/// read it as "the IME is up".
|
||||||
|
pub keyboard_shown: usize,
|
||||||
|
/// Every URL a tapped link asked the platform to open, in order.
|
||||||
|
pub opened_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HarnessState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
root: None,
|
||||||
|
focus: None,
|
||||||
|
last_click: Instant::now(),
|
||||||
|
keyboard_shown: 0,
|
||||||
|
opened_urls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasRoot for HarnessState {
|
||||||
|
fn set_root(&mut self, root: StrongWidget) {
|
||||||
|
self.root = Some(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusHost for HarnessState {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
crate::attr::recent_click(&mut self.last_click)
|
||||||
|
}
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.focus = id;
|
||||||
|
}
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.focus == Some(id)
|
||||||
|
}
|
||||||
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||||
|
if region.is_some() {
|
||||||
|
self.keyboard_shown += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenUrl for HarnessState {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.opened_urls.push(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
|
||||||
|
/// `AndroidRsc` minus the windowing, for the same reason those two are
|
||||||
|
/// separate types (`AndroidRsc`'s own doc).
|
||||||
|
pub struct HarnessRsc {
|
||||||
|
pub ui: UiData,
|
||||||
|
pub events: EventManager<Self>,
|
||||||
|
pub tasks: Tasks<Self>,
|
||||||
|
pub state: WidgetState,
|
||||||
|
_state: PhantomData<HarnessState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRsc for HarnessRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
self.state.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasState for HarnessRsc {
|
||||||
|
type State = HarnessState;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasEvents for HarnessRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasTasks for HarnessRsc {
|
||||||
|
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
||||||
|
&mut self.tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasWidgetState for HarnessRsc {
|
||||||
|
fn widget_state(&self) -> &WidgetState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
|
||||||
|
type Output = I::Output;
|
||||||
|
fn index(&self, index: I) -> &Self::Output {
|
||||||
|
index.get(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
|
||||||
|
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
||||||
|
index.get_mut(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A screen running with no window: the widget tree, the frame loop and
|
||||||
|
/// the pointer, all advanced by the caller. See the module doc.
|
||||||
|
pub struct Harness {
|
||||||
|
pub rsc: HarnessRsc,
|
||||||
|
pub render: UiRenderState,
|
||||||
|
pub state: HarnessState,
|
||||||
|
task_recv: TaskMsgReceiver<HarnessRsc>,
|
||||||
|
redraws: Arc<RedrawCounter>,
|
||||||
|
cursor: CursorState,
|
||||||
|
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
||||||
|
/// nothing reads the wall clock -- see [`Self::at`].
|
||||||
|
base: Instant,
|
||||||
|
size: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
/// `size` is in physical pixels and `density` is physical pixels per
|
||||||
|
/// dp, the pair Android reads from the surface and
|
||||||
|
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
|
||||||
|
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
|
||||||
|
/// `PHONE_SCALE`.
|
||||||
|
pub fn new(size: Vec2, density: f32) -> Self {
|
||||||
|
let redraws = Arc::new(RedrawCounter::default());
|
||||||
|
let (tasks, task_recv) = Tasks::init(redraws.clone());
|
||||||
|
let mut rsc = HarnessRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
tasks,
|
||||||
|
state: WidgetState::default(),
|
||||||
|
_state: PhantomData,
|
||||||
|
};
|
||||||
|
rsc.ui.text.density = density;
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.set_density(density);
|
||||||
|
render.resize(size);
|
||||||
|
Self {
|
||||||
|
rsc,
|
||||||
|
render,
|
||||||
|
state: HarnessState::new(),
|
||||||
|
task_recv,
|
||||||
|
redraws,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
base: Instant::now(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Instant` this harness means by `t_ms`. Public because a
|
||||||
|
/// caller driving `List::tick_fling` or `DragGesture` by hand needs
|
||||||
|
/// to date those calls on the same clock the touch samples use.
|
||||||
|
pub fn at(&self, t_ms: u64) -> Instant {
|
||||||
|
self.base + Duration::from_millis(t_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
||||||
|
pub fn redraws(&self) -> usize {
|
||||||
|
self.redraws.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame at `t_ms`: drain finished tasks, advance anything
|
||||||
|
/// animating, lay out and "draw". The same three steps
|
||||||
|
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
||||||
|
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
||||||
|
pub fn frame(&mut self, t_ms: u64) {
|
||||||
|
while let Ok(update) = self.task_recv.try_recv() {
|
||||||
|
update(&mut self.state, &mut self.rsc);
|
||||||
|
}
|
||||||
|
let now = self.at(t_ms);
|
||||||
|
self.rsc.ui.tick_animations(now);
|
||||||
|
self.render.update(&self.state.root, &mut self.rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||||
|
/// fling needs, since it moves only while something ticks it
|
||||||
|
/// (`List::fling`'s doc). Returns the time of the last frame run.
|
||||||
|
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||||
|
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||||
|
let mut t = from_ms;
|
||||||
|
while t <= end_ms {
|
||||||
|
self.frame(t);
|
||||||
|
t += step_ms;
|
||||||
|
}
|
||||||
|
t - step_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pointer sample through the sensors, then the frame it belongs
|
||||||
|
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
||||||
|
/// call. Each sample is its own input frame, dated by the sample
|
||||||
|
/// rather than by when this ran.
|
||||||
|
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
||||||
|
self.cursor.time = self.at(t_ms);
|
||||||
|
self.cursor.pos = pos;
|
||||||
|
match action {
|
||||||
|
TouchAction::Down => {
|
||||||
|
self.cursor.exists = true;
|
||||||
|
self.cursor.buttons.left.update(true);
|
||||||
|
}
|
||||||
|
TouchAction::Move => {}
|
||||||
|
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false),
|
||||||
|
}
|
||||||
|
let cursor = self.cursor.clone();
|
||||||
|
self.render
|
||||||
|
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||||
|
self.frame(t_ms);
|
||||||
|
self.cursor.end_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays a whole recorded gesture. Nothing is inserted between the
|
||||||
|
/// samples: a file with three lines produces three input frames, so
|
||||||
|
/// the batched shape a real flick arrives in is preserved exactly as
|
||||||
|
/// recorded rather than smoothed into evenly-spaced motion.
|
||||||
|
pub fn replay(&mut self, script: &TouchScript) {
|
||||||
|
for sample in &script.samples {
|
||||||
|
self.touch(sample.action, sample.pos, sample.t_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
|
|||||||
render.take_counters(); // discard the first, real draw
|
render.take_counters(); // discard the first, real draw
|
||||||
|
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, rewrites, moves) = render.take_counters();
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
assert_eq!((draws, rewrites, moves), (0, 0, 0));
|
assert_eq!((draws, rewrites, moves), (0, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ fn scrolling_moves_in_o1_without_a_redraw() {
|
|||||||
// already clamped) rather than actually moving anything.
|
// already clamped) rather than actually moving anything.
|
||||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, _rewrites, moves) = render.take_counters();
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
|
||||||
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
|
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
|
||||||
// 1 move_offsets write, independent of how many rects are in the
|
// 1 move_offsets write, independent of how many rects are in the
|
||||||
@@ -147,6 +147,37 @@ fn hit_testing_follows_a_scrolled_widget() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
||||||
|
/// it set for itself -- `redraw` feeds it straight back in as the inherited
|
||||||
|
/// mask, so storing the set one hands a `Masked` its own mask the second
|
||||||
|
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
|
||||||
|
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
|
||||||
|
/// the composer's new scroll area was redrawn on the emulator; a targeted
|
||||||
|
/// redraw of a `Masked` is what any real screen does whenever anything
|
||||||
|
/// inside it changes.
|
||||||
|
#[test]
|
||||||
|
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||||
|
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
||||||
|
let masked_id = masked.id();
|
||||||
|
let root = masked.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
render.redraw(masked_id, &mut rsc);
|
||||||
|
render.redraw(masked_id, &mut rsc);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
render.active.get(&masked_id).unwrap().mask,
|
||||||
|
MaskIdx::NONE,
|
||||||
|
"a `Masked` at the root is drawn under no mask of its own"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
@@ -226,6 +257,14 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
|||||||
|
|
||||||
render.resize((1080.0, 2298.0));
|
render.resize((1080.0, 2298.0));
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
|
// Focusing a field is what places its caret on a real tap
|
||||||
|
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
|
||||||
|
// with no caret is a routing bug rather than a state to simulate --
|
||||||
|
// `insert_str`'s own `debug_assert!` says so, and caught this test
|
||||||
|
// typing into an unfocused field when it was added.
|
||||||
|
field
|
||||||
|
.edit(&mut rsc)
|
||||||
|
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
|
||||||
field.edit(&mut rsc).insert("a");
|
field.edit(&mut rsc).insert("a");
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
@@ -260,3 +299,359 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
|||||||
"expected the bar near the bottom of the shorter window: {after_px:?}"
|
"expected the bar near the bottom of the shorter window: {after_px:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Scroll` used to be documented as resolving its own lengths against
|
||||||
|
/// `Painter::output_size` -- the window -- which read as if a scroll area
|
||||||
|
/// smaller than the screen could not work, and cost a session's
|
||||||
|
/// investigation before the composer was wired up (docs/RUST.md,
|
||||||
|
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
|
||||||
|
/// three numbers that follow from the offered box: what it reports
|
||||||
|
/// upward, what its capping parent reports, and how far it can pan.
|
||||||
|
#[test]
|
||||||
|
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(1000.0)),
|
||||||
|
});
|
||||||
|
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
|
||||||
|
let scroll_w = scroll.weak();
|
||||||
|
let scroll_id = scroll.id();
|
||||||
|
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
||||||
|
inner: scroll.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(100.0)),
|
||||||
|
});
|
||||||
|
let capped_id = capped.id();
|
||||||
|
let root = capped.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
// Two passes: the first offers the content a zero-length region
|
||||||
|
// (nothing measured yet) and learns the real content length from what
|
||||||
|
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
|
||||||
|
// that warm-up is deliberate rather than a bug.
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
// Reports the *content*, so the cap above it has something to cap;
|
||||||
|
// reporting the container instead would make the answer a function of
|
||||||
|
// itself, since the container is sized from this very number.
|
||||||
|
assert_eq!(
|
||||||
|
render.active.get(&scroll_id).unwrap().size.y,
|
||||||
|
Len::abs(1000.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render.active.get(&capped_id).unwrap().size.y,
|
||||||
|
Len::abs(100.0),
|
||||||
|
"the cap, not the content and not the window"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Panning is bounded by content minus *container*: 900, not the 400
|
||||||
|
// a 600px window would give.
|
||||||
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
|
||||||
|
assert!(
|
||||||
|
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
|
||||||
|
"amt={}",
|
||||||
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
|
||||||
|
/// checks a *descendant* of the widget `Scroll` actually moves, whose own
|
||||||
|
/// `region` is stale and is corrected entirely by the move chain. The
|
||||||
|
/// moved widget itself had its `region` updated *and* the chain delta
|
||||||
|
/// added on top, so its hit box sat at twice the pan -- which is why a
|
||||||
|
/// finger pan of the composer left its field untappable. See
|
||||||
|
/// `ActiveData::move_applied`.
|
||||||
|
#[test]
|
||||||
|
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(1000.0)),
|
||||||
|
});
|
||||||
|
let tall_w = tall.weak();
|
||||||
|
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
|
||||||
|
let scroll_w = scroll.weak();
|
||||||
|
let root = scroll.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let before = render.window_region(&tall_w, &rsc).unwrap();
|
||||||
|
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let after = render.window_region(&tall_w, &rsc).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01,
|
||||||
|
"the pan was applied twice: before={before:?} after={after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `Masked` used to allocate a **new** mask slot on every draw, and
|
||||||
|
/// `draw_inner`'s unchanged-region fast path means its descendants are
|
||||||
|
/// mostly *not* redrawn with it -- so they went on referencing the slot
|
||||||
|
/// they were first drawn under, whose region had since stopped being the
|
||||||
|
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
|
||||||
|
/// entries, none of them the `Masked`'s current box, and the field it was
|
||||||
|
/// meant to clip drew nothing at all on the emulator. The slot is
|
||||||
|
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
|
||||||
|
/// this pins both halves: one entry, and that entry is the widget's own
|
||||||
|
/// region.
|
||||||
|
#[test]
|
||||||
|
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||||
|
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
||||||
|
let masked_id = masked.id();
|
||||||
|
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
|
||||||
|
// which is what moves the bar away from the provisional slot it is
|
||||||
|
// first drawn at -- the move that left the stale mask behind.
|
||||||
|
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
||||||
|
let filler = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: filler.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(rest(1)),
|
||||||
|
});
|
||||||
|
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
||||||
|
inner: masked.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(60.0)),
|
||||||
|
});
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
span.push(filler.any());
|
||||||
|
span.push(capped.any());
|
||||||
|
let root = rsc.ui.widgets.add_strong(span).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
for _ in 0..3 {
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.redraw(masked_id, &mut rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.masks.iter().count(),
|
||||||
|
1,
|
||||||
|
"one `Masked` must own exactly one mask slot, however often it is redrawn"
|
||||||
|
);
|
||||||
|
let mask = *rsc.ui.masks.iter().next().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mask.region,
|
||||||
|
render.active.get(&masked_id).unwrap().region,
|
||||||
|
"the mask a descendant clips against must be this widget's current box"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `dp` cap that has done its job must be reported in pixels. `Span`
|
||||||
|
/// places a child using the `abs`/`rel` of the length it reported, so a
|
||||||
|
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
|
||||||
|
/// bar a slot of **zero** the moment its content grew past six lines --
|
||||||
|
/// and the `Scroll` inside then measured its container at -63px (the
|
||||||
|
/// padding, subtracted from nothing) and panned the whole message out of
|
||||||
|
/// view. Measured on this checkout's emulator, 2026-09-06:
|
||||||
|
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
|
||||||
|
#[test]
|
||||||
|
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(1000.0)),
|
||||||
|
});
|
||||||
|
let capped = rsc.ui.widgets.add_strong(MaxSize {
|
||||||
|
inner: tall.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::dp(100.0)),
|
||||||
|
});
|
||||||
|
let capped_w = capped.weak();
|
||||||
|
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
||||||
|
let filler = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: filler.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(rest(1)),
|
||||||
|
});
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
span.push(filler.any());
|
||||||
|
span.push(capped.any());
|
||||||
|
let root = rsc.ui.widgets.add_strong(span).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.set_density(2.5);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let box_px = render.window_region(&capped_w, &rsc).unwrap();
|
||||||
|
let height = box_px.bot_right.y - box_px.top_left.y;
|
||||||
|
assert!(
|
||||||
|
(height - 250.0).abs() < 0.01,
|
||||||
|
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
|
||||||
|
/// the branch that fix had no reason to touch: `draw_inner`'s
|
||||||
|
/// size-independent fast path rewrites a widget's primitives *in place*
|
||||||
|
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
|
||||||
|
/// for `region` to have absorbed. Counting one there anyway makes
|
||||||
|
/// `resolved_region` subtract a delta the chain never held, and the
|
||||||
|
/// widget's hit box lands short of where it is drawn by exactly the
|
||||||
|
/// distance it just moved -- with nothing on screen to say so, since the
|
||||||
|
/// primitives are in the right place.
|
||||||
|
#[test]
|
||||||
|
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let spacer = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: top.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(100.0)),
|
||||||
|
});
|
||||||
|
let spacer_w = spacer.weak();
|
||||||
|
// `Rect` is `is_size_independent`, so growing the spacer above it
|
||||||
|
// offers this one a region that changed *both* position and size --
|
||||||
|
// the one shape that reaches the branch under test.
|
||||||
|
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let below_w = below.weak();
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
span.push(spacer.any());
|
||||||
|
span.push(below.any());
|
||||||
|
let root = rsc.ui.widgets.add_strong(span).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// `Span` draws each child once at the full region to measure it and
|
||||||
|
// then places it, so this widget has already been through the branch
|
||||||
|
// once by the end of the very first frame.
|
||||||
|
let first = render.window_region(&below_w, &rsc).unwrap();
|
||||||
|
assert!(
|
||||||
|
(first.top_left.y - 100.0).abs() < 0.01,
|
||||||
|
"hit box at {:?}, drawn at y=100",
|
||||||
|
first.top_left
|
||||||
|
);
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let after = render.window_region(&below_w, &rsc).unwrap();
|
||||||
|
assert!(
|
||||||
|
(after.top_left.y - 250.0).abs() < 0.01,
|
||||||
|
"hit box at {:?}, drawn at y=250",
|
||||||
|
after.top_left
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent that both `mov`s a child (its own layout moved the box it
|
||||||
|
/// offers) and `reposition`s it inside that box in the same frame -- what
|
||||||
|
/// `List::place`'s Bottom-known branch does once a row's cached height
|
||||||
|
/// stops matching what the row reports, which is reachable as soon as a
|
||||||
|
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
|
||||||
|
struct MoveThenPlace {
|
||||||
|
inner: StrongWidget,
|
||||||
|
/// Where the child is *offered* a (constant-size) box, moved between
|
||||||
|
/// frames by the test.
|
||||||
|
offer_top: f32,
|
||||||
|
/// Where the child is then placed within this widget's own region.
|
||||||
|
place_top: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for MoveThenPlace {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
let offer = UiRegion::new(
|
||||||
|
UiSpan::FULL,
|
||||||
|
UiSpan::new(
|
||||||
|
UiScalar::abs(self.offer_top),
|
||||||
|
UiScalar::abs(self.offer_top + 40.0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
painter.widget_within(&self.inner, offer);
|
||||||
|
let place = UiRegion::new(
|
||||||
|
UiSpan::FULL,
|
||||||
|
UiSpan::new(
|
||||||
|
UiScalar::abs(self.place_top),
|
||||||
|
UiScalar::abs(self.place_top + 40.0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
painter.reposition(&self.inner, place);
|
||||||
|
Size::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mov` accumulates a delta onto a widget's move slot and `reposition`
|
||||||
|
/// overwrites it, and both can legitimately land on one widget in one
|
||||||
|
/// frame (see `MoveThenPlace`). `reposition` used to write its own delta
|
||||||
|
/// alone, which dropped the move and put the child back at the position
|
||||||
|
/// the offered box had *before* it moved; a `debug_assert!` that
|
||||||
|
/// `move_applied` was zero hid that behind a panic instead of fixing it.
|
||||||
|
/// The slot has one owner and one meaning now --
|
||||||
|
/// `move_applied + repositioned` -- so the child stays where it was
|
||||||
|
/// placed however its offered box moves. Fails at the offer's position
|
||||||
|
/// (200) rather than the placement's (100) without that.
|
||||||
|
#[test]
|
||||||
|
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let child = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(Len::abs(40.0)),
|
||||||
|
});
|
||||||
|
let child_w = child.weak();
|
||||||
|
let parent = rsc.ui.widgets.add_strong(MoveThenPlace {
|
||||||
|
inner: child.any(),
|
||||||
|
offer_top: 0.0,
|
||||||
|
place_top: 100.0,
|
||||||
|
});
|
||||||
|
let parent_w = parent.weak();
|
||||||
|
let root = parent.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((200.0, 400.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let before = render.window_region(&child_w, &rsc).unwrap();
|
||||||
|
assert!(
|
||||||
|
(before.top_left.y - 100.0).abs() < 0.01,
|
||||||
|
"the child should be drawn where it was placed, not where it was offered: {before:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move the offered box without changing its size (the `mov` fast path)
|
||||||
|
// and place the child at the same spot as before. Marking the parent
|
||||||
|
// dirty is what a real container's own content change does; the child
|
||||||
|
// itself is untouched, which is the case `mov` exists for.
|
||||||
|
{
|
||||||
|
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
|
||||||
|
parent.offer_top = 200.0;
|
||||||
|
}
|
||||||
|
rsc.ui.widgets.needs_redraw.insert(parent_w.id());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let after = render.window_region(&child_w, &rsc).unwrap();
|
||||||
|
assert!(
|
||||||
|
(after.top_left.y - 100.0).abs() < 0.01,
|
||||||
|
"the placement did not change, so neither should the child: before={before:?} \
|
||||||
|
after={after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ pub mod default;
|
|||||||
|
|
||||||
pub mod attr;
|
pub mod attr;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod harness;
|
||||||
|
pub mod platform;
|
||||||
pub mod sense;
|
pub mod sense;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
@@ -47,6 +49,7 @@ pub mod prelude {
|
|||||||
pub use event::*;
|
pub use event::*;
|
||||||
pub use iris_core::*;
|
pub use iris_core::*;
|
||||||
pub use iris_macro::*;
|
pub use iris_macro::*;
|
||||||
|
pub use platform::*;
|
||||||
pub use sense::*;
|
pub use sense::*;
|
||||||
pub use state::*;
|
pub use state::*;
|
||||||
pub use task::*;
|
pub use task::*;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! Capabilities a widget tree needs from whatever is hosting it, that
|
||||||
|
//! neither iris nor the app can perform itself.
|
||||||
|
//!
|
||||||
|
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
|
||||||
|
//! interface is declared here, below, and implemented by each backend
|
||||||
|
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
|
||||||
|
//! ask for the capability by trait bound instead of a caller threading a
|
||||||
|
//! callback down through every builder.
|
||||||
|
|
||||||
|
/// Hand a URL to whatever the platform opens URLs with.
|
||||||
|
///
|
||||||
|
/// One method rather than a general "run an intent"/"exec" surface: the
|
||||||
|
/// only thing a transcript needs is to follow a link a reader tapped, and
|
||||||
|
/// a narrower capability is a narrower thing to get wrong.
|
||||||
|
///
|
||||||
|
/// **Nothing is reported back.** There is no answer worth branching on --
|
||||||
|
/// the platform either shows a browser or does not, and both are outside
|
||||||
|
/// this process -- so failures are logged where they happen (each impl)
|
||||||
|
/// rather than turned into a `Result` every call site would discard.
|
||||||
|
pub trait OpenUrl {
|
||||||
|
fn open_url(&mut self, url: &str);
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ fn cursor_at(pos: Vec2) -> CursorState {
|
|||||||
exists: true,
|
exists: true,
|
||||||
buttons: Default::default(),
|
buttons: Default::default(),
|
||||||
scroll_delta: Vec2::ZERO,
|
scroll_delta: Vec2::ZERO,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,3 +123,197 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
|||||||
"the button on top must still receive an actual click"
|
"the button on top must still receive an actual click"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
|
||||||
|
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
|
||||||
|
/// nothing is registered at -- past the edge of whatever widget noticed
|
||||||
|
/// the press, in a gap, or off the loaded content entirely. Before pointer
|
||||||
|
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
|
||||||
|
/// so a widget mid-drag never saw its release and never got a chance to
|
||||||
|
/// start a fling. `UiRenderState::capture_pointer`/`DragGesture` fix this
|
||||||
|
/// by giving the drag's widget every frame regardless of where the
|
||||||
|
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
|
||||||
|
#[test]
|
||||||
|
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// A small draggable widget in the corner -- the release below lands
|
||||||
|
// far outside it, exactly the "moved off the hit region" case.
|
||||||
|
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||||
|
let draggable_weak = draggable.weak();
|
||||||
|
|
||||||
|
let dropped = Rc::new(Cell::new(false));
|
||||||
|
{
|
||||||
|
let dropped = dropped.clone();
|
||||||
|
rsc.register_event(
|
||||||
|
draggable_weak,
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
|
||||||
|
move |ctx, rsc| match ctx.data.sense {
|
||||||
|
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
|
||||||
|
// Any committed drag takes capture -- a real caller
|
||||||
|
// would gate this on a `DragArbiter`/`DragGesture`
|
||||||
|
// decision, but this test only needs to exercise the
|
||||||
|
// capture-and-release mechanics themselves.
|
||||||
|
ctx.data.render.capture_pointer(draggable_weak.id());
|
||||||
|
let _ = rsc;
|
||||||
|
}
|
||||||
|
CursorSense::Drop => dropped.set(true),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&draggable, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let mut press = cursor_at((5.0, 5.0).into());
|
||||||
|
press.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
|
||||||
|
assert_eq!(
|
||||||
|
render.captured_pointer(),
|
||||||
|
Some(draggable.id()),
|
||||||
|
"the press should have taken capture"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The release lands nowhere near the widget's own region -- the exact
|
||||||
|
// shape of a fast fling's `ACTION_UP`.
|
||||||
|
let mut release = cursor_at((95.0, 95.0).into());
|
||||||
|
release.buttons.left = ActivationState::End;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dropped.get(),
|
||||||
|
"a release outside every widget's hit region must still reach \
|
||||||
|
the widget holding pointer capture"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render.captured_pointer(),
|
||||||
|
None,
|
||||||
|
"Drop must release the capture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget that never registers `CursorSense::Drop` at all must not be
|
||||||
|
/// affected by someone else's capture -- capture is per-gesture, not
|
||||||
|
/// global suppression of the whole input system for widgets that were
|
||||||
|
/// never party to it. (Practically this matters because a captured
|
||||||
|
/// widget's registration list still has to include `Drop` for `should_run`
|
||||||
|
/// to ever match it; this pins that half of the contract.)
|
||||||
|
#[test]
|
||||||
|
fn capturing_one_widget_starves_every_other_widget_of_events() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let a_weak = a.weak();
|
||||||
|
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
|
||||||
|
let b_weak = b.weak();
|
||||||
|
|
||||||
|
let b_hovered = Rc::new(Cell::new(false));
|
||||||
|
{
|
||||||
|
let b_hovered = b_hovered.clone();
|
||||||
|
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
|
||||||
|
b_hovered.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Stack {
|
||||||
|
children: vec![a.any(), b.any()],
|
||||||
|
size: StackSize::default(),
|
||||||
|
})
|
||||||
|
.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.capture_pointer(a_weak.id());
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let cursor = cursor_at((50.0, 50.0).into());
|
||||||
|
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!b_hovered.get(),
|
||||||
|
"while a's drag holds capture, b must see no hover at all"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only
|
||||||
|
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||||
|
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||||
|
/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and
|
||||||
|
/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in
|
||||||
|
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||||
|
/// see.
|
||||||
|
#[test]
|
||||||
|
fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1000px of content in a 100px window: room to pan.
|
||||||
|
let scroll_strong = rect(UiColor::WHITE)
|
||||||
|
.height(Len::abs(1000.0))
|
||||||
|
.scrollable()
|
||||||
|
.add_strong(&mut rsc);
|
||||||
|
let scroll = scroll_strong.weak();
|
||||||
|
let root = scroll_strong.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// `Scroll` reads its content length back from the draw it just did, so
|
||||||
|
// the frame after is the first one that knows there is anything to pan
|
||||||
|
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
|
||||||
|
// how `layout_tests.rs` asks for that second frame, and it also drops
|
||||||
|
// `snap_end`, leaving this parked at the start of the content.
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let mut down = cursor_at((50.0, 80.0).into());
|
||||||
|
down.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
|
0.0,
|
||||||
|
"the touch-down alone must not move anything"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inside the slop: still a tap as far as anything can tell.
|
||||||
|
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||||
|
nudge.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
|
0.0,
|
||||||
|
"a press inside DRAG_SLOP must not scroll"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past it, upward: the content follows the finger up, which for this
|
||||||
|
// widget means more `amt`.
|
||||||
|
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||||
|
drag.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
||||||
|
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
|
assert!(
|
||||||
|
(after - 40.0).abs() < 0.01,
|
||||||
|
"expected the 40px past the slop to pan it, got {after}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the gesture holds the pointer, so the rest of it reaches this
|
||||||
|
// widget even once the finger leaves its box.
|
||||||
|
assert_eq!(render.captured_pointer(), Some(scroll.id()));
|
||||||
|
}
|
||||||
@@ -225,6 +225,21 @@ pub struct List {
|
|||||||
/// (headless tests, a caller driving `tick_fling` by hand as
|
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||||
/// `bench_client.rs`'s scripted phases do).
|
/// `bench_client.rs`'s scripted phases do).
|
||||||
redraw: Option<Arc<dyn RequestRedraw>>,
|
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||||
|
/// Physical pixels per `dp`, copied from the painter on every `draw`
|
||||||
|
/// -- what [`Self::fling`] hands `FlingCalculator`. 1.0 until this
|
||||||
|
/// list has been drawn once, which is also the only state in which a
|
||||||
|
/// fling is impossible (`fling` needs an anchor, and an anchor comes
|
||||||
|
/// from a draw).
|
||||||
|
///
|
||||||
|
/// It has to be the real one: the deceleration constant is
|
||||||
|
/// `GRAVITY * 39.37 * density * 160 * friction`, and the velocity fed
|
||||||
|
/// in is in the same physical pixels the touch events arrive in, so a
|
||||||
|
/// hardcoded 1.0 against a 2.75-density screen does not cancel out --
|
||||||
|
/// it makes the fling last exponentially too long. Measured on this
|
||||||
|
/// checkout's emulator, 2026-09-07, once flings could animate at all:
|
||||||
|
/// a flick that should coast for about a second ran for **45
|
||||||
|
/// seconds**.
|
||||||
|
density: f32,
|
||||||
/// Whether the last `draw` found no more content above the topmost
|
/// Whether the last `draw` found no more content above the topmost
|
||||||
/// visible row (its top edge at or past the viewport's own top, with
|
/// visible row (its top edge at or past the viewport's own top, with
|
||||||
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||||
@@ -244,7 +259,16 @@ pub struct List {
|
|||||||
struct Fling {
|
struct Fling {
|
||||||
calc: FlingCalculator,
|
calc: FlingCalculator,
|
||||||
velocity: f32,
|
velocity: f32,
|
||||||
started_at: Instant,
|
/// When the fling's own curve begins -- **the first `tick_fling`,
|
||||||
|
/// not the release**. It is set there rather than in `fling` so the
|
||||||
|
/// only clock this widget reads is the one its driver hands it: a
|
||||||
|
/// caller running frames on an explicit clock (`iris::harness`, and
|
||||||
|
/// `bench_client.rs`'s scripted phases) would otherwise start every
|
||||||
|
/// fling at the wall clock and advance it on a different one, and a
|
||||||
|
/// fling released at t=500ms would arrive already over. The
|
||||||
|
/// difference in a running app is at most one frame, since that is
|
||||||
|
/// how soon the fling is first ticked.
|
||||||
|
started_at: Option<Instant>,
|
||||||
applied: f32,
|
applied: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +285,7 @@ impl List {
|
|||||||
last_viewport_len: 0.0,
|
last_viewport_len: 0.0,
|
||||||
fling: None,
|
fling: None,
|
||||||
redraw: None,
|
redraw: None,
|
||||||
|
density: 1.0,
|
||||||
at_start: false,
|
at_start: false,
|
||||||
at_end: false,
|
at_end: false,
|
||||||
pending_tap: None,
|
pending_tap: None,
|
||||||
@@ -418,20 +443,48 @@ impl List {
|
|||||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||||
/// relies on the view calling `abortAnimation` for the same reason.
|
/// relies on the view calling `abortAnimation` for the same reason.
|
||||||
///
|
///
|
||||||
/// Density cancels out of the underlying spline as long as velocity
|
/// The density handed to `FlingCalculator` is this list's own
|
||||||
/// and the distance it produces share one pixel space (see
|
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
|
||||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
|
||||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
/// doc, which used to claim the opposite, and the 45-second coast that
|
||||||
/// it is the correct density for a self-consistent unit system.
|
/// claim produced.
|
||||||
|
///
|
||||||
|
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||||
|
/// something calls [`Self::tick_fling`] once per frame, and what does
|
||||||
|
/// that in a running app is `UiData::tick_animations`, over the ids
|
||||||
|
/// `UiData::animate` was given. So a caller starting a fling from a
|
||||||
|
/// gesture registers the list in the same breath:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// list(ui).fling(-velocity);
|
||||||
|
/// let id = list.id();
|
||||||
|
/// ui.ui_mut().animate(id);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Split that way because the two halves have different owners: the
|
||||||
|
/// velocity is the list's business, and whether anything animates at
|
||||||
|
/// all is the frame loop's. Missing the second call is what a finger
|
||||||
|
/// fling did on Iris's phone for two builds -- the velocity was right
|
||||||
|
/// and nothing ever advanced it, which looks exactly like a list that
|
||||||
|
/// stops dead under the finger. A caller driving frames itself
|
||||||
|
/// (`bench_client.rs`'s fling phase, the headless tests) calls
|
||||||
|
/// `tick_fling` directly instead and does not register.
|
||||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||||
|
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
||||||
|
// near-zero span, or a caller passing a raw device value straight
|
||||||
|
// through) would propagate silently into `deceleration_for`'s
|
||||||
|
// `.ln()` -- the fling either never settles or jumps to NaN
|
||||||
|
// positions with nothing on screen saying why (docs/
|
||||||
|
// REVIEW-2026-09-06.md finding 3).
|
||||||
|
debug_assert!(velocity_px_per_s.is_finite());
|
||||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
||||||
self.fling = None;
|
self.fling = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.fling = Some(Fling {
|
self.fling = Some(Fling {
|
||||||
calc: FlingCalculator::new(1.0),
|
calc: FlingCalculator::new(self.density),
|
||||||
velocity: velocity_px_per_s,
|
velocity: velocity_px_per_s,
|
||||||
started_at: Instant::now(),
|
started_at: None,
|
||||||
applied: 0.0,
|
applied: 0.0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -444,6 +497,17 @@ impl List {
|
|||||||
self.fling.is_some()
|
self.fling.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The velocity a fling in progress is coasting at, in this list's
|
||||||
|
/// own pixel space -- `None` when nothing is flinging. What a
|
||||||
|
/// release's decision looks like from the outside: a
|
||||||
|
/// `GestureOutcome::Released(Some(v))` is the only thing that puts a
|
||||||
|
/// value here, so a test (or a diagnostic) can read what the gesture
|
||||||
|
/// measured at the place it landed, rather than re-timing the
|
||||||
|
/// gesture itself.
|
||||||
|
pub fn fling_velocity(&self) -> Option<f32> {
|
||||||
|
self.fling.as_ref().map(|f| f.velocity)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel any fling in progress with no further movement -- the next
|
/// Cancel any fling in progress with no further movement -- the next
|
||||||
/// touch-down's job, per `fling`'s own doc.
|
/// touch-down's job, per `fling`'s own doc.
|
||||||
pub fn cancel_fling(&mut self) {
|
pub fn cancel_fling(&mut self) {
|
||||||
@@ -465,12 +529,26 @@ impl List {
|
|||||||
let Some(f) = &mut self.fling else {
|
let Some(f) = &mut self.fling else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let elapsed = now.saturating_duration_since(f.started_at);
|
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
|
||||||
let target = f.calc.position_at(f.velocity, elapsed);
|
let target = f.calc.position_at(f.velocity, elapsed);
|
||||||
let delta = target - f.applied;
|
let delta = target - f.applied;
|
||||||
f.applied = target;
|
f.applied = target;
|
||||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||||
let velocity = f.velocity;
|
let velocity = f.velocity;
|
||||||
|
// The evidence that the spline is actually being followed, at the
|
||||||
|
// one granularity where a linear coast and a decelerating one look
|
||||||
|
// different: successive `dy` and `speed` shrinking. It was neither
|
||||||
|
// observable nor observed while `distance_fraction` returned `t`
|
||||||
|
// (`android_fling_spline`'s doc), which is why this is here rather
|
||||||
|
// than the total-travel line the release log already carries.
|
||||||
|
log::debug!(
|
||||||
|
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||||
|
elapsed.as_secs_f32(),
|
||||||
|
delta,
|
||||||
|
f.calc.velocity_at(velocity, elapsed),
|
||||||
|
velocity,
|
||||||
|
f.calc.distance(velocity) - target,
|
||||||
|
);
|
||||||
self.scroll(delta);
|
self.scroll(delta);
|
||||||
|
|
||||||
// Clamp: a fling moving toward the start that has already reached
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
@@ -552,6 +630,20 @@ impl List {
|
|||||||
self.extents.get(&key).map(|e| (e.top, e.bottom))
|
self.extents.get(&key).map(|e| (e.top, e.bottom))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The row whose on-screen box (as of the last layout) contains
|
||||||
|
/// `viewport_pos`, or `None` if it falls outside every row currently
|
||||||
|
/// drawn (a gap, a header, or off the loaded content entirely). O
|
||||||
|
/// (visible rows), same as `reanchor_at_tap`. What a caller resolves a
|
||||||
|
/// pointer-captured gesture's row-under-the-finger against once the
|
||||||
|
/// gesture is no longer being delivered through any one row's own hit
|
||||||
|
/// region -- see `iris::sense`'s pointer-capture doc.
|
||||||
|
pub fn key_at(&self, viewport_pos: f32) -> Option<RowKey> {
|
||||||
|
self.extents
|
||||||
|
.iter()
|
||||||
|
.find(|(_, ext)| viewport_pos >= ext.top && viewport_pos <= ext.bottom)
|
||||||
|
.map(|(&key, _)| key)
|
||||||
|
}
|
||||||
|
|
||||||
fn slot_exists(&self, slot: isize) -> bool {
|
fn slot_exists(&self, slot: isize) -> bool {
|
||||||
match slot {
|
match slot {
|
||||||
BEFORE_SLOT => self.more_before.is_some(),
|
BEFORE_SLOT => self.more_before.is_some(),
|
||||||
@@ -749,6 +841,17 @@ impl List {
|
|||||||
/// one-frame lag `Scroll`'s own content-length cache accepts, per
|
/// one-frame lag `Scroll`'s own content-length cache accepts, per
|
||||||
/// LAYOUT.md.
|
/// LAYOUT.md.
|
||||||
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
|
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
|
||||||
|
// Every current caller derives `slot` from `repair_anchor`/
|
||||||
|
// `prev_slot`/`next_slot`, which already check existence -- but
|
||||||
|
// that invariant is enforced by convention across three call
|
||||||
|
// sites, not by this function, which would otherwise fail with a
|
||||||
|
// bare "index out of bounds" and no context (docs/
|
||||||
|
// REVIEW-2026-09-06.md finding 2). `slot_widget`, called from
|
||||||
|
// here, is what actually indexes/`.expect`s on it.
|
||||||
|
debug_assert!(
|
||||||
|
self.slot_exists(slot),
|
||||||
|
"place() called with a slot that doesn't exist: {slot:?}"
|
||||||
|
);
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
let container_len = painter.region().axis(axis).len();
|
let container_len = painter.region().axis(axis).len();
|
||||||
@@ -844,8 +947,20 @@ impl List {
|
|||||||
const GENEROUS_PADDING: f32 = 100_000.0;
|
const GENEROUS_PADDING: f32 = 100_000.0;
|
||||||
|
|
||||||
impl Widget for List {
|
impl Widget for List {
|
||||||
|
/// A `List` animates exactly one thing, a fling
|
||||||
|
/// ([`Self::tick_fling`]). The registration that makes this run is
|
||||||
|
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc.
|
||||||
|
fn tick(&mut self, now: Instant) -> bool {
|
||||||
|
self.tick_fling(now)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
|
// Learned from the frame rather than passed in: a fling's
|
||||||
|
// deceleration is a physical quantity and needs the real display
|
||||||
|
// density, and `draw` is where this widget meets the only thing
|
||||||
|
// that knows it. See `fling`.
|
||||||
|
self.density = painter.density();
|
||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
@@ -1072,7 +1187,7 @@ mod tests {
|
|||||||
.push_front(ListRow::new(key, w));
|
.push_front(ListRow::new(key, w));
|
||||||
}
|
}
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, _rewrites, _moves) = render.take_counters();
|
let (draws, _rewrites, _moves, _shapes) = render.take_counters();
|
||||||
|
|
||||||
// None of the already-visible rows (11, 12) were touched: the
|
// None of the already-visible rows (11, 12) were touched: the
|
||||||
// extents for those keys are numerically unchanged, and the only
|
// extents for those keys are numerically unchanged, and the only
|
||||||
@@ -1210,7 +1325,7 @@ mod tests {
|
|||||||
|
|
||||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, _rewrites, moves) = render.take_counters();
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
|
||||||
// The visible window is a fixed ~10 rows regardless of n; an
|
// The visible window is a fixed ~10 rows regardless of n; an
|
||||||
// O(n) regression would show up as draws/moves scaling with
|
// O(n) regression would show up as draws/moves scaling with
|
||||||
@@ -1273,6 +1388,52 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor
|
||||||
|
/// its sibling below ever asserts the *evicted* key's own bookkeeping
|
||||||
|
/// is actually gone -- both replace row 4 with another row also keyed
|
||||||
|
/// `4`, so `heights.remove(&old.key)` removing and re-inserting the
|
||||||
|
/// same key would pass either test even if it did nothing (docs/
|
||||||
|
/// REVIEW-2026-09-06.md finding 10; this is `Selection`'s finding 1
|
||||||
|
/// class of bug -- a stale handle outliving what it points to --
|
||||||
|
/// production-tested from `List`'s own side). Replacing with a
|
||||||
|
/// **different** key is what actually exercises the removal.
|
||||||
|
#[test]
|
||||||
|
fn replace_back_forgets_the_evicted_keys_own_height() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
|
||||||
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 60.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert!(
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.heights
|
||||||
|
.contains_key(&4)
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
|
||||||
|
let old = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.replace_back(ListRow::new(100, new_row));
|
||||||
|
|
||||||
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
|
assert_eq!(old.map(|o| o.key), Some(4));
|
||||||
|
assert!(
|
||||||
|
!list_ref.heights.contains_key(&4),
|
||||||
|
"the evicted key's cached height must not outlive the row it measured"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The other half of the same fix's contract: replacing a row that is
|
/// The other half of the same fix's contract: replacing a row that is
|
||||||
/// *not* on screen must not move anything that is. `replace_back` only
|
/// *not* on screen must not move anything that is. `replace_back` only
|
||||||
/// touches the last slot's own widget and this file's own `heights`/
|
/// touches the last slot's own widget and this file's own `heights`/
|
||||||
@@ -1332,6 +1493,126 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a
|
||||||
|
/// replaced row's primitives drawn a second time, overlapping the
|
||||||
|
/// replacement. Reproduces the exact path `TranscriptScreen::apply`'s
|
||||||
|
/// `ReplaceLast` case drives up to 400 times during a streamed reply
|
||||||
|
/// (`bench_client.rs`'s stream phase): the last slot's widget is
|
||||||
|
/// swapped for a brand-new one, same key, and (since a fresh widget
|
||||||
|
/// has no cached height) placed via `place`'s `draw_twice` path every
|
||||||
|
/// time -- the provisional-then-real two-draw sequence LAYOUT.md
|
||||||
|
/// documents as the one place in this crate that deliberately draws a
|
||||||
|
/// widget twice. If `draw_inner`'s old-children diffing or
|
||||||
|
/// `UiRenderState::remove`'s primitive freeing ever failed to retire
|
||||||
|
/// the evicted widget (or the provisional draw's own primitives), it
|
||||||
|
/// would show up here as `active_widgets` growing without bound.
|
||||||
|
/// **Passes as written** -- this pins the widget-arena layer as
|
||||||
|
/// correct in isolation; see the P0 box for where the duplicate was
|
||||||
|
/// actually chased to instead (`Span`'s two-phase draw and the
|
||||||
|
/// `redraw_all`-vs-`redraw_updates` split, still open).
|
||||||
|
#[test]
|
||||||
|
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
for key in 0..5u64 {
|
||||||
|
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
||||||
|
list.push_back(ListRow::new(key, row));
|
||||||
|
}
|
||||||
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let before = render.active_widgets();
|
||||||
|
for i in 0..400u32 {
|
||||||
|
// A varying height keeps every replace on the `draw_twice`
|
||||||
|
// (cache-miss) path rather than settling into the O(1)
|
||||||
|
// same-size `mov` fast path once the height happens to repeat.
|
||||||
|
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.replace_back(ListRow::new(4, new_row));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
}
|
||||||
|
let after = render.active_widgets();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
before, after,
|
||||||
|
"400 replaces of the last row must leave exactly the same \
|
||||||
|
number of active widgets as before a leaked id (and the \
|
||||||
|
primitives that live as long as its ActiveData does) would \
|
||||||
|
show up here as growth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The doubled `Compacted:` row from Iris's phone (docs/bench/
|
||||||
|
/// iris-phone-v2-2026-09-06.md), reproduced at its mechanism.
|
||||||
|
///
|
||||||
|
/// `replacing_the_last_row_many_times_does_not_leak_primitives` above
|
||||||
|
/// counts *widgets*, which is why it passed all along: the orphan's
|
||||||
|
/// owner is very much alive -- it is an earlier set of that same
|
||||||
|
/// widget's primitives that got stranded. What strands them is a row
|
||||||
|
/// marked dirty and then reached by its **ancestor's** redraw rather
|
||||||
|
/// than by its own: `draw_inner` only *read* the dirty mark, so the
|
||||||
|
/// whole branch that frees a redrawn widget's previous primitives was
|
||||||
|
/// skipped, and the fresh `ActiveData` overwrote the only handles that
|
||||||
|
/// could ever have freed them. `List` sets no mask, so that copy then
|
||||||
|
/// draws every frame at whatever region it last had -- including,
|
||||||
|
/// where the row was being measured at `GENEROUS_PADDING`, well below
|
||||||
|
/// the list's own box and under the composer.
|
||||||
|
///
|
||||||
|
/// Two rows, two shapes of the same fault: row 2 has a cached height
|
||||||
|
/// (one `widget_within`), row 4 is replaced so it has none (`place`'s
|
||||||
|
/// `draw_twice`, which reaches `draw_inner` twice for one id in one
|
||||||
|
/// frame and so orphans a copy even with no ancestor involved).
|
||||||
|
#[test]
|
||||||
|
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
// Rows that own a primitive *at their own id* (a background rect),
|
||||||
|
// not only through a child: an orphan is a widget's own primitive
|
||||||
|
// outliving its own redraw, so a row whose top-level widget paints
|
||||||
|
// nothing itself cannot show one however broken the path is.
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for key in 0..5u64 {
|
||||||
|
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
|
||||||
|
rows.push((row.id(), bg_id));
|
||||||
|
list.push_back(ListRow::new(key, row));
|
||||||
|
}
|
||||||
|
let (list_weak, root) = add_list(&mut rsc, list);
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert!(render.orphaned_primitives().is_empty());
|
||||||
|
|
||||||
|
// A streamed row's content changing: the row is marked dirty (any
|
||||||
|
// `.set()` on it does this)...
|
||||||
|
let (row2, row2_bg) = rows[2];
|
||||||
|
rsc.ui.widgets.get_dyn_mut(row2).unwrap();
|
||||||
|
rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap();
|
||||||
|
|
||||||
|
// Redraw the *list* by name, so the dirty row is reached by its
|
||||||
|
// ancestor's draw rather than by `redraw_updates` happening to
|
||||||
|
// pick it first -- which is the order `HashSet` iteration makes
|
||||||
|
// arbitrary, and the reason this went unnoticed.
|
||||||
|
render.redraw(list_weak.id(), &mut rsc);
|
||||||
|
|
||||||
|
let orphans = render.orphaned_primitives();
|
||||||
|
assert!(
|
||||||
|
orphans.is_empty(),
|
||||||
|
"{} primitive(s) survived their own widget's redraw: {orphans:?}",
|
||||||
|
orphans.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Enough rows, tall enough, that a fling toward the start has real
|
/// Enough rows, tall enough, that a fling toward the start has real
|
||||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||||
/// tests below.
|
/// tests below.
|
||||||
@@ -1375,6 +1656,56 @@ mod tests {
|
|||||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The half `fling` itself does not do: a registered list is advanced
|
||||||
|
/// by the frame loop's own driver, and unregisters itself when the
|
||||||
|
/// fling settles. Written against `UiData::tick_animations` rather
|
||||||
|
/// than `tick_fling` because the defect it pins is exactly the gap
|
||||||
|
/// between the two -- a fling with a correct velocity that nothing
|
||||||
|
/// ever advanced, which is what a finger fling did on the phone.
|
||||||
|
#[test]
|
||||||
|
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
let before = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.anchor_position_display();
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||||
|
rsc.ui.animate(list_weak.id());
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut animating = true;
|
||||||
|
let mut steps = 0;
|
||||||
|
while animating && steps < 600 {
|
||||||
|
animating = rsc
|
||||||
|
.ui
|
||||||
|
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
assert!(!animating, "the driver never stopped within 600 frames");
|
||||||
|
assert!(steps > 1, "the fling settled without ever moving");
|
||||||
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
assert_ne!(
|
||||||
|
before,
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.anchor_position_display(),
|
||||||
|
"the list is where it started -- the fling was registered but never applied"
|
||||||
|
);
|
||||||
|
// Nothing left registered, so the next frame costs nothing: the
|
||||||
|
// path out of `animate` is the `false` answer, not a caller
|
||||||
|
// remembering to remove it.
|
||||||
|
assert!(!rsc.ui.tick_animations(start));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fling_distance_is_positive_toward_the_end() {
|
fn fling_distance_is_positive_toward_the_end() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
@@ -1409,6 +1740,84 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `fling_moves_the_list_and_then_settles`/
|
||||||
|
/// `fling_distance_is_positive_toward_the_end` only check that a fling
|
||||||
|
/// started, moved the right way and eventually stopped -- both
|
||||||
|
/// unaffected by *how* the interior ticks split up the total travel
|
||||||
|
/// (docs/REVIEW-2026-09-06.md finding 9). A regression that made
|
||||||
|
/// `tick_fling` apply the whole spline distance every tick instead of
|
||||||
|
/// just this tick's incremental slice would still pass both, while
|
||||||
|
/// being wildly wrong every intermediate frame -- this pins the
|
||||||
|
/// per-tick delta to a decelerating curve (`FlingCalculator::
|
||||||
|
/// position_at`'s own monotonic-and-clamped property, one level
|
||||||
|
/// down, already covers the calculator alone; this is the same
|
||||||
|
/// property through `List::tick_fling`'s `scroll`/`extents`
|
||||||
|
/// accumulation).
|
||||||
|
#[test]
|
||||||
|
fn tick_fling_applies_shrinking_incremental_deltas() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
|
||||||
|
let mut deltas = Vec::new();
|
||||||
|
for step in 1..600 {
|
||||||
|
let now = start + std::time::Duration::from_millis(step * 16);
|
||||||
|
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let Some(top) = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.extents
|
||||||
|
.get(&0)
|
||||||
|
.map(|e| e.top)
|
||||||
|
else {
|
||||||
|
break; // row 0 scrolled out of the loaded extents
|
||||||
|
};
|
||||||
|
deltas.push((prev_top - top).abs());
|
||||||
|
prev_top = top;
|
||||||
|
if !still {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
deltas.len() >= 3,
|
||||||
|
"fling settled or left row 0's extent before collecting enough samples"
|
||||||
|
);
|
||||||
|
// Skip the first tick (the slop-transition jump the arbiter
|
||||||
|
// applies is a `List::fling`-adjacent concern, not this curve,
|
||||||
|
// but the very first frame can still carry rounding noise from
|
||||||
|
// `jump_to_start`'s own layout settling).
|
||||||
|
for w in deltas[1..].windows(2) {
|
||||||
|
assert!(
|
||||||
|
w[1] <= w[0] + 0.01,
|
||||||
|
"fling's per-tick delta grew instead of decelerating: {:?} then {:?}",
|
||||||
|
w[0],
|
||||||
|
w[1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Non-increasing is not deceleration: a fling that coasts at a
|
||||||
|
// constant speed and then stops dead satisfies every `<=` above,
|
||||||
|
// and that is exactly what iris shipped until 2026-09-07
|
||||||
|
// (`android_fling_spline`'s doc). Over the samples collected here
|
||||||
|
// -- the earliest part of the curve, since row 0 leaves the loaded
|
||||||
|
// extents soon after -- AOSP's spline has already lost more than
|
||||||
|
// a fifth of its speed.
|
||||||
|
let (first, last) = (deltas[1], *deltas.last().unwrap());
|
||||||
|
assert!(
|
||||||
|
last < first * 0.8,
|
||||||
|
"fling barely slowed across {} ticks: {first} -> {last}",
|
||||||
|
deltas.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancel_fling_stops_it_with_no_further_movement() {
|
fn cancel_fling_stops_it_with_no_further_movement() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ impl MaxSize {
|
|||||||
};
|
};
|
||||||
let len_px = len.apply_rest(density).to_abs(output);
|
let len_px = len.apply_rest(density).to_abs(output);
|
||||||
let max_px = max.apply_rest(density).to_abs(output);
|
let max_px = max.apply_rest(density).to_abs(output);
|
||||||
if len_px > max_px { max } else { len }
|
// `fold_dp`, not the caller's `max` as written: a reported `Len`
|
||||||
|
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
|
||||||
|
// collapsed composer bar this caused.
|
||||||
|
if len_px > max_px {
|
||||||
|
max.fold_dp(density)
|
||||||
|
} else {
|
||||||
|
len
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The span (in this widget's own local, `UiRegion::FULL`-relative
|
/// The span (in this widget's own local, `UiRegion::FULL`-relative
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use crate::sense::{DragGesture, GestureOutcome};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
pub struct Scroll {
|
pub struct Scroll {
|
||||||
inner: StrongWidget,
|
inner: StrongWidget,
|
||||||
@@ -7,6 +9,12 @@ pub struct Scroll {
|
|||||||
snap_end: bool,
|
snap_end: bool,
|
||||||
container_len: f32,
|
container_len: f32,
|
||||||
content_len: f32,
|
content_len: f32,
|
||||||
|
/// Touch panning, from the same `DragGesture` `List` is driven by
|
||||||
|
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
|
||||||
|
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
|
||||||
|
/// `sense.rs` and only what a committed pan *means* is decided here.
|
||||||
|
/// See [`Self::drag`].
|
||||||
|
gesture: DragGesture,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Scroll {
|
impl Widget for Scroll {
|
||||||
@@ -25,10 +33,20 @@ impl Widget for Scroll {
|
|||||||
// length itself (read below from what was actually drawn) is never
|
// length itself (read below from what was actually drawn) is never
|
||||||
// stale, so this self-corrects the next frame and never leaves the
|
// stale, so this self-corrects the next frame and never leaves the
|
||||||
// scroll range wrong for long. See LAYOUT.md section 4.
|
// scroll range wrong for long. See LAYOUT.md section 4.
|
||||||
|
//
|
||||||
|
// Every length here is resolved against the box this widget was
|
||||||
|
// **offered** (`px_size`), never `output_size`: a `Scroll` is
|
||||||
|
// routinely smaller than the window -- the composer's field is
|
||||||
|
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||||
|
// the window instead would make the pan range, and so where the
|
||||||
|
// content sits, a function of the screen rather than of the box.
|
||||||
|
// (What the previous arithmetic here computed came to the same
|
||||||
|
// number by a longer route, through a `within_len` against a
|
||||||
|
// window-relative scalar; it read as if the window were the
|
||||||
|
// container and cost a session working out that it was not.)
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let output_len = painter.output_size().axis(axis);
|
let container_len = painter.px_size().axis(axis);
|
||||||
let container_len = painter.region().axis(axis).len();
|
self.container_len = container_len;
|
||||||
self.container_len = container_len.to_abs(output_len);
|
|
||||||
|
|
||||||
if self.snap_end {
|
if self.snap_end {
|
||||||
self.amt = self.content_len - self.container_len;
|
self.amt = self.content_len - self.container_len;
|
||||||
@@ -41,12 +59,22 @@ impl Widget for Scroll {
|
|||||||
|
|
||||||
let used = painter.widget_within(&self.inner, region);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
|
|
||||||
|
// A child reporting `rel` means "this fraction of what I was
|
||||||
|
// offered", and what it was offered is this scroll area -- so the
|
||||||
|
// container, again, is what that resolves against.
|
||||||
self.content_len = used
|
self.content_len = used
|
||||||
.axis(axis)
|
.axis(axis)
|
||||||
.apply_rest(painter.density())
|
.apply_rest(painter.density())
|
||||||
.within_len(container_len)
|
.to_abs(container_len);
|
||||||
.to_abs(output_len);
|
|
||||||
|
|
||||||
|
// The **content's** size, not the container's. A parent that can
|
||||||
|
// grow (the composer's bar) should hug the text until its own cap
|
||||||
|
// stops it, and reporting the container instead would make this
|
||||||
|
// widget's answer a function of the answer -- the bar is sized
|
||||||
|
// from what is reported here, so it collapses to nothing and
|
||||||
|
// never recovers. What keeps the content inside the offered box
|
||||||
|
// is the mask a caller puts around it (`.scrollable().masked()`),
|
||||||
|
// not this number.
|
||||||
used
|
used
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +88,60 @@ impl Scroll {
|
|||||||
snap_end: true,
|
snap_end: true,
|
||||||
container_len: 0.0,
|
container_len: 0.0,
|
||||||
content_len: 0.0,
|
content_len: 0.0,
|
||||||
|
gesture: DragGesture::on(axis),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one frame of a touch gesture over this scroll area through.
|
||||||
|
/// Wired by `WidgetLike::scrollable`; a caller building a `Scroll` by
|
||||||
|
/// hand registers the same senses and calls this.
|
||||||
|
///
|
||||||
|
/// `id` is this widget's own id, which `DragGesture` takes pointer
|
||||||
|
/// capture on once the gesture commits -- so the rest of the drag
|
||||||
|
/// reaches here even after the finger has left this area, and, just as
|
||||||
|
/// importantly, stops reaching whatever is *inside* it. That is what
|
||||||
|
/// resolves a vertical drag over a focused text field: the field sees
|
||||||
|
/// the first few frames, iris::attr's `on_press` gives up its pending
|
||||||
|
/// selection the moment they pass `DRAG_SLOP` vertically, and this
|
||||||
|
/// takes the gesture over. Android's own `EditText` behaves the same
|
||||||
|
/// way -- a vertical drag scrolls, and only a long press selects.
|
||||||
|
///
|
||||||
|
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate
|
||||||
|
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas
|
||||||
|
/// this wraps today -- a six-line composer, a diagnostics pane -- are
|
||||||
|
/// at most a screenful, where Android does not fling either. The
|
||||||
|
/// released velocity is deliberately dropped rather than approximated.
|
||||||
|
pub fn drag(
|
||||||
|
&mut self,
|
||||||
|
render: &UiRenderState,
|
||||||
|
id: WidgetId,
|
||||||
|
sense: CursorSense,
|
||||||
|
pos_window: Vec2,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
// `already_selected: false` -- a scroll area has no selection of
|
||||||
|
// its own to extend, so a horizontal drag stays `Undecided` and a
|
||||||
|
// vertical one past the slop pans, which is the whole contract
|
||||||
|
// here. A caller that *does* own a selection (the transcript's
|
||||||
|
// `Selection`) drives `DragGesture` itself instead.
|
||||||
|
match self
|
||||||
|
.gesture
|
||||||
|
.handle(render, id, sense, pos_window, now, false)
|
||||||
|
{
|
||||||
|
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||||
|
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
||||||
|
// this widget's `amt` run in *opposite* directions (offset is
|
||||||
|
// where the anchored edge sits; `amt` is how far the content
|
||||||
|
// has been pulled up past the top), even though `List::scroll`'s
|
||||||
|
// own doc claims to mirror this one's convention. The rule that
|
||||||
|
// holds for both, and the one to check a sign against, is that
|
||||||
|
// the content follows the finger.
|
||||||
|
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||||
|
GestureOutcome::Undecided
|
||||||
|
| GestureOutcome::Tapped
|
||||||
|
| GestureOutcome::SelectStart
|
||||||
|
| GestureOutcome::SelectExtend
|
||||||
|
| GestureOutcome::Released(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +152,179 @@ impl Scroll {
|
|||||||
self.snap_end = self.amt == len;
|
self.snap_end = self.amt == len;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far the content has been pulled past the container's leading
|
||||||
|
/// edge, in pixels -- 0 at the start of the content. Read-only, for a
|
||||||
|
/// caller that needs to observe a pan (a test, a scroll indicator).
|
||||||
|
pub fn amt(&self) -> f32 {
|
||||||
|
self.amt
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scroll(&mut self, amt: f32) {
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
self.amt -= amt;
|
self.amt -= amt;
|
||||||
self.update_amt();
|
self.update_amt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::sense::{CursorButton, DRAG_SLOP};
|
||||||
|
use iris_core::UiData;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A scroll area with 1000px of content in a 100px box, already
|
||||||
|
/// settled somewhere in the middle so a drag has room in both
|
||||||
|
/// directions.
|
||||||
|
fn area() -> (UiData, Scroll, WidgetId) {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||||
|
let id = inner.id();
|
||||||
|
let mut s = Scroll::new(inner, Axis::Y);
|
||||||
|
s.content_len = 1000.0;
|
||||||
|
s.container_len = 100.0;
|
||||||
|
s.amt = 400.0;
|
||||||
|
s.snap_end = false;
|
||||||
|
(ui, s, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(
|
||||||
|
s: &mut Scroll,
|
||||||
|
render: &UiRenderState,
|
||||||
|
id: WidgetId,
|
||||||
|
sense: CursorSense,
|
||||||
|
y: f32,
|
||||||
|
t: Instant,
|
||||||
|
) {
|
||||||
|
s.drag(render, id, sense, Vec2::new(0.0, y), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
0.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
// Finger down by well past the slop: the content follows it down,
|
||||||
|
// which for this widget means *less* `amt`.
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
DRAG_SLOP + 30.0,
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.amt - 370.0).abs() < 0.01,
|
||||||
|
"expected the 30px past the slop to be applied downward, got amt={}",
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
// ...and the next frame's motion is a plain per-frame delta.
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
DRAG_SLOP + 50.0,
|
||||||
|
t + Duration::from_millis(40),
|
||||||
|
);
|
||||||
|
assert!((s.amt - 350.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the change had no reason to touch: a press that never
|
||||||
|
/// leaves the slop is a tap, and must move nothing at all -- otherwise
|
||||||
|
/// every tap on a scrollable field nudges its text.
|
||||||
|
#[test]
|
||||||
|
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
0.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
y,
|
||||||
|
t + Duration::from_millis(10 * (i as u64 + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
press(
|
||||||
|
&mut s,
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
DRAG_SLOP - 0.5,
|
||||||
|
t + Duration::from_millis(50),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.amt - 400.0).abs() < 0.01,
|
||||||
|
"a tap scrolled: amt={}",
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal drag is not this widget's gesture: it must stay put
|
||||||
|
/// rather than pick up the vertical noise in a sideways swipe.
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_drag_does_not_scroll() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(120.0, 3.0),
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!((s.amt - 400.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panning stops at the ends of the content rather than running off,
|
||||||
|
/// which is `update_amt`'s clamp -- checked through `drag` so the two
|
||||||
|
/// cannot drift apart.
|
||||||
|
#[test]
|
||||||
|
fn a_pan_past_the_end_clamps_instead_of_running_off() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
let render = UiRenderState::new();
|
||||||
|
let t = Instant::now();
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 5000.0),
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,9 +26,12 @@ impl Widget for Sized {
|
|||||||
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
||||||
}
|
}
|
||||||
let used = painter.widget_within(&self.inner, region);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
|
// `fold_dp` on the way out: a declared size is a `Len` the caller
|
||||||
|
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
|
||||||
|
// unresolved `dp` -- see `Len::fold_dp`.
|
||||||
Size {
|
Size {
|
||||||
x: self.x.unwrap_or(used.x),
|
x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
|
||||||
y: self.y.unwrap_or(used.y),
|
y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,13 @@ use crate::prelude::*;
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
pub color: UiColor,
|
pub color: UiColor,
|
||||||
pub radius: f32,
|
/// A `Len` rather than a raw `f32` so a corner can be written in `dp`
|
||||||
|
/// and come out the same physical size on every display -- resolved
|
||||||
|
/// against `Painter::density` in [`Rect::draw`], the same place every
|
||||||
|
/// other `dp` is resolved. A plain number still works and still means
|
||||||
|
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
|
||||||
|
/// a hairline wants.
|
||||||
|
pub radius: Len,
|
||||||
pub thickness: f32,
|
pub thickness: f32,
|
||||||
pub inner_radius: f32,
|
pub inner_radius: f32,
|
||||||
}
|
}
|
||||||
@@ -12,7 +18,7 @@ impl Rect {
|
|||||||
pub fn new(color: UiColor) -> Self {
|
pub fn new(color: UiColor) -> Self {
|
||||||
Self {
|
Self {
|
||||||
color,
|
color,
|
||||||
radius: 0.0,
|
radius: Len::ZERO,
|
||||||
inner_radius: 0.0,
|
inner_radius: 0.0,
|
||||||
thickness: 0.0,
|
thickness: 0.0,
|
||||||
}
|
}
|
||||||
@@ -21,8 +27,8 @@ impl Rect {
|
|||||||
self.color = color;
|
self.color = color;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
pub fn radius(mut self, radius: impl UiNum) -> Self {
|
pub fn radius(mut self, radius: impl Into<Len>) -> Self {
|
||||||
self.radius = radius.to_f32();
|
self.radius = radius.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,15 +37,40 @@ impl Widget for Rect {
|
|||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.primitive(RectPrimitive {
|
painter.primitive(RectPrimitive {
|
||||||
color: self.color,
|
color: self.color,
|
||||||
radius: self.radius,
|
// `rel` has no meaning for a corner (a rect that fills its
|
||||||
|
// parent has no length of its own to take a fraction of), so
|
||||||
|
// only the `abs`/`dp` halves are folded.
|
||||||
|
radius: self.radius.fold_dp(painter.density()).abs,
|
||||||
thickness: self.thickness,
|
thickness: self.thickness,
|
||||||
inner_radius: self.inner_radius,
|
inner_radius: self.inner_radius,
|
||||||
});
|
});
|
||||||
Size::REST // fills whatever it was given -- used == available
|
Size::REST // fills whatever it was given -- used == available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **No** -- despite drawing one primitive and nothing else.
|
||||||
|
///
|
||||||
|
/// `is_size_independent` asks whether the widget's *content* is
|
||||||
|
/// unaffected by how big a region it was given, so that
|
||||||
|
/// `draw_inner` may keep the primitives it already has and rewrite
|
||||||
|
/// their regions in place. A `Rect`'s content **is** its region: it
|
||||||
|
/// returns `Size::REST` and fills whatever it was handed, so the fast
|
||||||
|
/// path's `r.outside(&from).within(®ion)` remap has to reproduce
|
||||||
|
/// the whole of `draw` -- and it does not, because a region carries
|
||||||
|
/// `rel` and `abs` components that the round trip cannot recover
|
||||||
|
/// separately.
|
||||||
|
///
|
||||||
|
/// What that looked like: a fenced code block's background
|
||||||
|
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
|
||||||
|
/// `Pad` in a `Stack`) kept the height of the *provisional* full-
|
||||||
|
/// region draw `Span` does in its first phase, so one fence's panel
|
||||||
|
/// covered every block below it -- and every row below that -- while
|
||||||
|
/// the text itself was laid out correctly. Visible in
|
||||||
|
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
|
||||||
|
/// crate's `transcript` example. Answering `false` costs a redraw of
|
||||||
|
/// one primitive when a rect is resized, which is what the fast path
|
||||||
|
/// was saving.
|
||||||
fn is_size_independent(&self) -> bool {
|
fn is_size_independent(&self) -> bool {
|
||||||
true // content never depends on region size
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,21 @@ impl<'a> TextEditCtx<'a> {
|
|||||||
self.clear_span();
|
self.clear_span();
|
||||||
let at = match self.text.selection {
|
let at = match self.text.selection {
|
||||||
Some(sel) => sel.focus().index(),
|
Some(sel) => sel.focus().index(),
|
||||||
None => return,
|
// No caret means nowhere to put the text, so this drops the
|
||||||
|
// keystroke -- which is invisible, and was the whole of the
|
||||||
|
// "typed text never appears" defect (see `select`'s comment).
|
||||||
|
// A field the IME is talking to has been focused, and focusing
|
||||||
|
// one places a caret, so reaching here is a bug in whoever
|
||||||
|
// routed the input rather than something to recover from.
|
||||||
|
None => {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"insert into a text field with no caret: '{}' was given input \
|
||||||
|
without being focused, so the keystroke would be dropped silently",
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let at = at.min(self.text.view.buf.text().len());
|
let at = at.min(self.text.view.buf.text().len());
|
||||||
self.text.view.buf.edit().insert_str(at, text);
|
self.text.view.buf.edit().insert_str(at, text);
|
||||||
@@ -350,6 +364,29 @@ impl<'a> TextEditCtx<'a> {
|
|||||||
self.set_caret(index);
|
self.set_caret(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The byte offset in the text that `pos` (in the same window-space
|
||||||
|
/// coordinates a `CursorSense` reports, with `size` the region the
|
||||||
|
/// event was measured against) lands on.
|
||||||
|
///
|
||||||
|
/// The one thing a caller outside this module needs to turn a tap into
|
||||||
|
/// a *range* of the text -- which markdown link is under the finger,
|
||||||
|
/// which inline-code chip was pressed. `layout()` is private because a
|
||||||
|
/// caller holding a parley `Layout` could shape it against stale text;
|
||||||
|
/// this hands back the answer rather than the layout, and does the
|
||||||
|
/// same region-relative transform [`select`](Self::select) does, so
|
||||||
|
/// the two cannot disagree about where a point is.
|
||||||
|
///
|
||||||
|
/// Parley clamps a point outside the laid-out text to the nearest
|
||||||
|
/// cursor position, so a tap in the field's padding answers with the
|
||||||
|
/// nearest offset rather than failing -- a caller wanting "was this
|
||||||
|
/// actually *on* something" checks its own ranges, which is what
|
||||||
|
/// makes a tap in the padding hit no link.
|
||||||
|
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
|
||||||
|
let pos = pos - self.text.region().top_left().to_abs(size);
|
||||||
|
let layout = self.layout();
|
||||||
|
Selection::from_point(layout, pos.x, pos.y).focus().index()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_all(&mut self) {
|
pub fn select_all(&mut self) {
|
||||||
let len = self.text.view.buf.text().len();
|
let len = self.text.view.buf.text().len();
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
@@ -368,14 +405,28 @@ impl<'a> TextEditCtx<'a> {
|
|||||||
|
|
||||||
// The layout borrows `self`, so the whole decision is made in here and
|
// The layout borrows `self`, so the whole decision is made in here and
|
||||||
// only the answer escapes.
|
// only the answer escapes.
|
||||||
|
//
|
||||||
|
// **A press that reaches here has already been hit-tested to this
|
||||||
|
// widget, so there is no "outside" to clear the selection for.**
|
||||||
|
// This used to compare `pos` against the *laid-out text's* box and
|
||||||
|
// set `selection = None` for anything beyond it -- but the laid-out
|
||||||
|
// text is smaller than the field (padding, and for an empty field a
|
||||||
|
// box of literally zero width), so tapping an **empty** composer
|
||||||
|
// granted focus, opened the keyboard, and left `selection` at
|
||||||
|
// `None` -- and `insert_str` returns early on `None`, so every
|
||||||
|
// keystroke after that was silently dropped and nothing ever
|
||||||
|
// appeared. That is RUST.md's P0 box item 2, "composed text never
|
||||||
|
// becomes visible at all": the buffer was empty the whole time, and
|
||||||
|
// Gboard's suggestion strip (its own composing state, not ours) is
|
||||||
|
// what made it look otherwise. Parley's `from_point`/
|
||||||
|
// `extend_to_point` already clamp a point outside the layout to the
|
||||||
|
// nearest cursor position, which is what a tap in a field's padding
|
||||||
|
// should do anyway. Losing focus is a separate path
|
||||||
|
// (`TextEditCtx::deselect`, called from the backend's focus
|
||||||
|
// handling), not this one.
|
||||||
let outcome = {
|
let outcome = {
|
||||||
let layout = self.layout();
|
let layout = self.layout();
|
||||||
let inside =
|
if drag {
|
||||||
pos.x >= 0.0 && pos.y >= 0.0 && pos.x <= layout.width() && pos.y <= layout.height();
|
|
||||||
|
|
||||||
if !inside {
|
|
||||||
if drag { None } else { Some((None, None)) }
|
|
||||||
} else if drag {
|
|
||||||
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
|
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
|
||||||
} else {
|
} else {
|
||||||
let hit = Selection::from_point(layout, pos.x, pos.y);
|
let hit = Selection::from_point(layout, pos.x, pos.y);
|
||||||
@@ -669,6 +720,39 @@ mod tests {
|
|||||||
assert_eq!(t.selection.unwrap().focus().index(), 0);
|
assert_eq!(t.selection.unwrap().focus().index(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The defect itself: an empty field's laid-out text is a zero-sized
|
||||||
|
/// box, so a tap anywhere in it used to land "outside" and clear the
|
||||||
|
/// selection -- leaving a focused composer that silently swallowed
|
||||||
|
/// every keystroke (RUST.md's P0 box item 2).
|
||||||
|
#[test]
|
||||||
|
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
|
||||||
|
let (mut t, mut d) = edit("", EditMode::MultiLine);
|
||||||
|
ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false);
|
||||||
|
assert!(t.selection.is_some(), "a tap must leave a caret behind");
|
||||||
|
ctx(&mut t, &mut d).insert("hi");
|
||||||
|
assert_eq!(content(&t), "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the fix had no reason to touch: a field that *does* hold
|
||||||
|
/// text, tapped past the end of it (a multi-line composer's padding
|
||||||
|
/// below the last line) keeps a caret rather than losing the one it
|
||||||
|
/// had, and the caret lands at the nearest position -- the end.
|
||||||
|
#[test]
|
||||||
|
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
|
||||||
|
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
|
||||||
|
ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false);
|
||||||
|
assert_eq!(t.selection.unwrap().focus().index(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drag still needs something to extend: with no previous selection
|
||||||
|
/// there is nothing to drag from, and one must not be invented.
|
||||||
|
#[test]
|
||||||
|
fn dragging_without_a_previous_selection_selects_nothing() {
|
||||||
|
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
|
||||||
|
ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false);
|
||||||
|
assert!(t.selection.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_single_line_field_refuses_newlines() {
|
fn a_single_line_field_refuses_newlines() {
|
||||||
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
||||||
|
|||||||
@@ -60,8 +60,17 @@ impl TextView {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// The atlas generation is part of the cache key, not a separate
|
||||||
|
// invalidation path: a `RenderedText` is only meaningful against the
|
||||||
|
// atlas its glyphs were placed in, and a renderer rebuild clears
|
||||||
|
// that atlas out from under every widget at once
|
||||||
|
// (`GlyphAtlas::clear`). Without this the text drawn before the
|
||||||
|
// rebuild is re-emitted with the old atlas's coordinates and comes
|
||||||
|
// back as fragments of whatever now occupies them.
|
||||||
|
let generation = painter.atlas_generation();
|
||||||
if width == self.width
|
if width == self.width
|
||||||
&& let Some(tex) = &self.tex
|
&& let Some(tex) = &self.tex
|
||||||
|
&& tex.generation == generation
|
||||||
&& !self.attrs.changed
|
&& !self.attrs.changed
|
||||||
&& !self.buf.changed
|
&& !self.buf.changed
|
||||||
{
|
{
|
||||||
@@ -69,6 +78,12 @@ impl TextView {
|
|||||||
}
|
}
|
||||||
self.width = width;
|
self.width = width;
|
||||||
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||||
|
log::debug!(
|
||||||
|
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
||||||
|
self.buf.text().chars().count(),
|
||||||
|
tex.glyphs.len(),
|
||||||
|
tex.size,
|
||||||
|
);
|
||||||
self.tex = Some(tex.clone());
|
self.tex = Some(tex.clone());
|
||||||
self.attrs.changed = false;
|
self.attrs.changed = false;
|
||||||
self.buf.changed = false;
|
self.buf.changed = false;
|
||||||
@@ -151,3 +166,55 @@ impl DerefMut for TextView {
|
|||||||
&mut self.attrs
|
&mut self.attrs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::layout_tests::TestRsc;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
/// A renderer rebuild empties the glyph atlas under every widget at
|
||||||
|
/// once (`iris_core::GlyphAtlas::clear`, called from
|
||||||
|
/// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything
|
||||||
|
/// still holding a `RenderedText` from before then owns UV rectangles
|
||||||
|
/// into a texture that no longer exists -- what Iris photographed on
|
||||||
|
/// 2026-09-06 as every pre-resume glyph coming back as fragments while
|
||||||
|
/// the text drawn after the resume was perfect.
|
||||||
|
///
|
||||||
|
/// The check is the atlas repopulating: `TextView::render`'s cache
|
||||||
|
/// short-circuits before `TextData::place`, so without the generation
|
||||||
|
/// in its key the second frame rasterises nothing and the atlas stays
|
||||||
|
/// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is
|
||||||
|
/// the same finding from the submission side.)
|
||||||
|
#[test]
|
||||||
|
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let root = wtext("hello there")
|
||||||
|
.size(18)
|
||||||
|
.color(UiColor::WHITE)
|
||||||
|
.add_strong(&mut rsc)
|
||||||
|
.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let rasterised = rsc.ui.text.atlas.glyph_count();
|
||||||
|
assert!(rasterised > 0, "the first frame rasterised no glyphs");
|
||||||
|
|
||||||
|
// Exactly what the new-renderer branch does, in order: empty the
|
||||||
|
// atlas, then redraw everything (`resize` is what marks the tree
|
||||||
|
// for a full redraw, and a real `surface_changed` always calls it).
|
||||||
|
rsc.ui.text.atlas.clear();
|
||||||
|
assert_eq!(rsc.ui.text.atlas.glyph_count(), 0);
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.text.atlas.glyph_count(),
|
||||||
|
rasterised,
|
||||||
|
"the second frame re-emitted its cached glyphs instead of \
|
||||||
|
re-rendering them against the fresh atlas"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,12 +84,35 @@ widget_trait! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||||
|
self.scrollable_on(Axis::Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `scrollable` along `axis`. A code fence pans across its own long
|
||||||
|
// lines exactly the way a transcript pans down its rows, so the two
|
||||||
|
// are one function with the axis passed in rather than a second copy
|
||||||
|
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
|
||||||
|
// is not accepted by `widget_trait!`, which parses its body itself.)
|
||||||
|
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||||
move |state| {
|
move |state| {
|
||||||
Scroll::new(self.add_strong(state), Axis::Y)
|
Scroll::new(self.add_strong(state), axis)
|
||||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
.on(CursorSense::Scroll, move |ctx, rsc| {
|
||||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
|
||||||
ctx.widget(rsc).scroll(delta);
|
ctx.widget(rsc).scroll(delta);
|
||||||
})
|
})
|
||||||
|
// A finger drag, through the same `DragGesture` the
|
||||||
|
// transcript's `List` is panned by -- `Scroll::drag`'s doc
|
||||||
|
// has the arbitration and why there is no fling. The wheel
|
||||||
|
// above and this are the two inputs of one scroll, so they
|
||||||
|
// are registered together rather than left to each caller.
|
||||||
|
.on(
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||||
|
|ctx, rsc| {
|
||||||
|
let id = ctx.widget.id();
|
||||||
|
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||||
|
ctx.widget(rsc)
|
||||||
|
.drag(ctx.data.render, id, sense, pos, ctx.data.cursor.time);
|
||||||
|
},
|
||||||
|
)
|
||||||
.add(state)
|
.add(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# The bench fixture, opened as a real transcript screen with no server --
|
||||||
|
# docs/RUST.md's "Three test layers". It was `iris-android-app`'s
|
||||||
|
# `bench_client.rs` alone until 2026-09-07; the fixture-loading and
|
||||||
|
# fold-driving half moved here so the headless harness (layer 1), the
|
||||||
|
# phone-shaped desktop window (layer 2) and the Android bench (layer 3)
|
||||||
|
# all open the *same* screen from the same bytes, per AGENTS.md's rule
|
||||||
|
# that nothing UI-shaped lives in a platform crate.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iris = { path = ".." }
|
||||||
|
transcript-ui = { path = "../transcript-ui" }
|
||||||
|
client-core = { path = "../../client-core" }
|
||||||
|
event-model = { path = "../../event-model" }
|
||||||
|
# `float_roundtrip` for the same reason `server/Cargo.toml` has it: a `ts`
|
||||||
|
# read back must be the one that was written (AGENTS.md).
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
winit = { workspace = true }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! 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 (`transcript_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.
|
||||||
|
//!
|
||||||
|
//! 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};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DefaultApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for Client {
|
||||||
|
fn window_attributes() -> WindowAttributes {
|
||||||
|
WindowAttributes::default()
|
||||||
|
.with_title("iris transcript (bench fixture)")
|
||||||
|
.with_inner_size(PhysicalSize::new(
|
||||||
|
transcript_fixture::PHONE_WIDTH,
|
||||||
|
transcript_fixture::PHONE_HEIGHT,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let screen = match transcript_fixture::open(rsc, &mut ui_state) {
|
||||||
|
Ok(opened) => {
|
||||||
|
// A fling coasts only while something asks for the next
|
||||||
|
// frame; on the desktop that is the window's own redraw
|
||||||
|
// request (`List::fling`'s doc).
|
||||||
|
let handle = rsc.tasks.redraw_handle();
|
||||||
|
(opened.screen.list)(rsc).set_redraw_handle(handle);
|
||||||
|
Some(opened.screen)
|
||||||
|
}
|
||||||
|
// On screen rather than a panic: this window exists to be
|
||||||
|
// looked at, and "the fixture stopped folding" is something
|
||||||
|
// to read, not a process that vanished (UI_RULES.md).
|
||||||
|
Err(message) => {
|
||||||
|
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(dp(16))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(text);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self { ui_state, screen }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! The checked-in bench fixture, opened as a real transcript screen with
|
||||||
|
//! no server -- shared by every layer of docs/RUST.md's test rig.
|
||||||
|
//!
|
||||||
|
//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760
|
||||||
|
//! bytes, generated by `app/bench-fixture/generate.py`, never a real
|
||||||
|
//! transcript -- that file's own README), embedded with `include_str!`.
|
||||||
|
//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window,
|
||||||
|
//! folded once through `client_core::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 client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
|
||||||
|
/// the opening window; the rest are the streaming tail. Kept in sync with
|
||||||
|
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
|
||||||
|
/// checked-in file, so a mismatch would only mean the two apps' bench
|
||||||
|
/// builds open a different split of it, not a wrong-vs-right answer.
|
||||||
|
pub const BACKLOG_COUNT: usize = 3200;
|
||||||
|
|
||||||
|
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||||
|
|
||||||
|
/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and
|
||||||
|
/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at
|
||||||
|
/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed
|
||||||
|
/// from memory -- every layer of the rig lays out at this size and
|
||||||
|
/// density so a screenshot and a headless assertion are about the same
|
||||||
|
/// screen.
|
||||||
|
pub const PHONE_WIDTH: f32 = 1080.0;
|
||||||
|
pub const PHONE_HEIGHT: f32 = 2424.0;
|
||||||
|
pub const PHONE_SCALE: f32 = 2.55;
|
||||||
|
/// 120Hz, the refresh rate that report ran at: 8.3ms a frame.
|
||||||
|
pub const PHONE_FRAME_MS: u64 = 8;
|
||||||
|
|
||||||
|
pub fn phone_size() -> Vec2 {
|
||||||
|
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture split the way the wire delivers it: raw JSON values for
|
||||||
|
/// the opening page (`fold_page` takes a page of wire JSON, same as a
|
||||||
|
/// real `/transcript` response) and parsed `SeqEvent`s for the tail
|
||||||
|
/// (`fold_event` takes one live event at a time, same as an SSE frame).
|
||||||
|
pub struct Fixture {
|
||||||
|
pub backlog: Vec<serde_json::Value>,
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
/// Parses the whole fixture. Panics on malformed input: this is a
|
||||||
|
/// generated file compiled into the binary, so a parse failure is a
|
||||||
|
/// broken build rather than a condition a caller could recover from
|
||||||
|
/// (CODE_RULES: separate recoverable conditions from programmer
|
||||||
|
/// error).
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
|
||||||
|
let mut stream_tail = Vec::new();
|
||||||
|
for (i, line) in FIXTURE_JSONL
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||||
|
if i < BACKLOG_COUNT {
|
||||||
|
backlog.push(value);
|
||||||
|
} else {
|
||||||
|
stream_tail.push(
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.expect("bench fixture event matches event-model's SeqEvent"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
backlog,
|
||||||
|
stream_tail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening page folded into transcript items -- the same
|
||||||
|
/// `fold_page` a real first load runs. `Err` carries the fold's own
|
||||||
|
/// message, which a caller shows on screen rather than panicking, so
|
||||||
|
/// a fixture that stops folding is visible in the app instead of
|
||||||
|
/// being a crash on launch.
|
||||||
|
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
fold_page(&self.backlog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture's opening page as the rows a screen is built from.
|
||||||
|
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
|
group_tool_runs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a caller needs to run the fixture as an app screen would:
|
||||||
|
/// the screen, the folded items behind it, and the events not yet
|
||||||
|
/// streamed. The tree itself comes back separately from
|
||||||
|
/// [`build_screen`], since whoever takes it owns it.
|
||||||
|
pub struct Opened {
|
||||||
|
pub screen: transcript_ui::TranscriptScreen,
|
||||||
|
pub items: Vec<TranscriptItem>,
|
||||||
|
/// The tail, for a caller that goes on replaying it one event at a
|
||||||
|
/// time through `fold_event`/`TranscriptScreen::apply` -- the
|
||||||
|
/// streaming phase of either app's benchmark.
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the transcript screen over the fixture's opening page, without
|
||||||
|
/// claiming the window's root -- `transcript_ui::build_tree`'s own split,
|
||||||
|
/// for a caller (the Android bench) that puts the screen inside a shell
|
||||||
|
/// of its own.
|
||||||
|
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
let items = fixture.backlog_items()?;
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows(&items));
|
||||||
|
Ok((
|
||||||
|
Opened {
|
||||||
|
screen,
|
||||||
|
items,
|
||||||
|
stream_tail: fixture.stream_tail,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`build_screen`] with the screen as the window's root -- what the
|
||||||
|
/// headless harness and the desktop window open.
|
||||||
|
pub fn open<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result<Opened, String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (opened, tree) = build_screen(rsc)?;
|
||||||
|
ui_state.set_root(tree);
|
||||||
|
Ok(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The split is what both bench clients assume; a fixture that
|
||||||
|
/// stopped having a streaming tail would make the Android bench's
|
||||||
|
/// stream phase silently measure nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
|
||||||
|
assert!(
|
||||||
|
fixture.stream_tail.len() >= 400,
|
||||||
|
"the stream phase replays 400 events; the fixture has {}",
|
||||||
|
fixture.stream_tail.len()
|
||||||
|
);
|
||||||
|
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! 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 iris::harness::{Harness, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
|
||||||
|
/// The screen open on the fixture, framed twice: once to draw, once for
|
||||||
|
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
|
||||||
|
/// anchor, which is what every assertion about scroll position reads.
|
||||||
|
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(name: &str, text: &str) -> TouchScript {
|
||||||
|
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(h: &mut Harness, screen: &transcript_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 `List::fling_velocity`), and the list must then
|
||||||
|
/// actually travel and stop on the spline's own schedule.
|
||||||
|
#[test]
|
||||||
|
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
let velocity = (screen.list)(&mut h.rsc)
|
||||||
|
.fling_velocity()
|
||||||
|
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||||
|
assert!(
|
||||||
|
velocity.abs() > 1_000.0,
|
||||||
|
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android's own spline says how long a fling at this speed runs. The
|
||||||
|
// list learns its density from the painter, so this is the same
|
||||||
|
// curve it is using.
|
||||||
|
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
|
||||||
|
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
|
||||||
|
let mut settled_at = None;
|
||||||
|
let mut t = flick.end_ms();
|
||||||
|
while t <= end {
|
||||||
|
h.frame(t);
|
||||||
|
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||||
|
settled_at = Some(t);
|
||||||
|
}
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = offset(&mut h, &screen);
|
||||||
|
assert_ne!(
|
||||||
|
before, after,
|
||||||
|
"the fling ticks must have moved the list off where the flick left it"
|
||||||
|
);
|
||||||
|
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||||
|
let ran_for = settled_at - flick.end_ms();
|
||||||
|
assert!(
|
||||||
|
ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
|
||||||
|
"the fling ran {ran_for}ms against the spline's own {}ms",
|
||||||
|
expected.as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the flick fix had no reason to touch: a tap must decide
|
||||||
|
/// `Tapped`, which means no velocity anywhere and nothing moved.
|
||||||
|
#[test]
|
||||||
|
fn a_tap_on_a_row_moves_nothing() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a tap must not fling"
|
||||||
|
);
|
||||||
|
// Frames it would have moved in, had anything been moving.
|
||||||
|
h.frames_until(100, 400, PHONE_FRAME_MS);
|
||||||
|
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"no link was under this tap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A press held past `LONG_PRESS` and then dragged selects text rather
|
||||||
|
/// than panning -- the other branch of the same arbiter the flick goes
|
||||||
|
/// through.
|
||||||
|
#[test]
|
||||||
|
fn a_long_press_and_drag_selects_text() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
h.replay(&script(
|
||||||
|
"long-press",
|
||||||
|
include_str!("../touch/long-press.touch"),
|
||||||
|
));
|
||||||
|
|
||||||
|
let selected = screen
|
||||||
|
.selected_text(&mut h.rsc)
|
||||||
|
.expect("a long-press then drag must leave text selected");
|
||||||
|
assert!(
|
||||||
|
!selected.trim().is_empty(),
|
||||||
|
"the selection covered no characters: {selected:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
offset(&mut h, &screen),
|
||||||
|
"a selection must not also pan the list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The composer sits on whatever the platform says the bottom of usable
|
||||||
|
/// space is -- the keyboard's inset while it is open
|
||||||
|
/// (`Composer::set_bottom_inset`, the path Android's
|
||||||
|
/// `on_insets_changed` feeds). Checked here rather than on the emulator
|
||||||
|
/// because it is a layout fact, and the emulator costs minutes.
|
||||||
|
#[test]
|
||||||
|
fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let height = h.size().y;
|
||||||
|
let field_bottom = |h: &mut Harness| {
|
||||||
|
h.render
|
||||||
|
.window_region(&screen.composer.field, &h.rsc)
|
||||||
|
.expect("the composer field is on screen")
|
||||||
|
.bot_right
|
||||||
|
.y
|
||||||
|
};
|
||||||
|
|
||||||
|
let closed = field_bottom(&mut h);
|
||||||
|
assert!(
|
||||||
|
closed <= height,
|
||||||
|
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A Gboard-sized keyboard on this surface. Any real number would do;
|
||||||
|
// what matters is that the bar clears it.
|
||||||
|
let ime = 1000.0;
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
|
||||||
|
let open = field_bottom(&mut h);
|
||||||
|
assert!(
|
||||||
|
open <= height - ime,
|
||||||
|
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
|
||||||
|
height - ime
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(closed - open - ime).abs() < 1.0,
|
||||||
|
"the composer moved {} for a {ime}px inset",
|
||||||
|
closed - open
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# A finger flick the shape Iris's phone delivers one, from
|
||||||
|
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
|
||||||
|
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
|
||||||
|
# as DOWN, one or two MOVEs and UP inside a few frames, with the
|
||||||
|
# intermediate positions batched inside those MOVEs as historical
|
||||||
|
# samples (~4ms apart, the touch digitiser's own rate) rather than
|
||||||
|
# arriving as separate events. Each line here is one such sample, which
|
||||||
|
# is exactly what `IrisViewPeer::on_touch_event` replays through the
|
||||||
|
# sensors one at a time -- so the whole gesture is 20ms and five
|
||||||
|
# samples, and the velocity has to come out of *those*.
|
||||||
|
#
|
||||||
|
# Downward (increasing y) on purpose: the screen opens pinned to the
|
||||||
|
# newest end, so a flick the other way has nothing left to scroll to and
|
||||||
|
# the fling clamps on its first tick -- a pass that would prove nothing.
|
||||||
|
# Coordinates are physical pixels on a 1080x2424 surface.
|
||||||
|
0 down 540 1000
|
||||||
|
4 move 540 1040
|
||||||
|
8 move 540 1086
|
||||||
|
12 move 540 1138
|
||||||
|
16 move 540 1196
|
||||||
|
20 up 540 1196
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# A long-press then a drag across the text: held past LONG_PRESS
|
||||||
|
# (500ms) without moving, which is what starts a selection rather than a
|
||||||
|
# pan, then dragged sideways so the selection actually covers
|
||||||
|
# something. A press alone leaves a collapsed caret and no selected
|
||||||
|
# text (`Selection::begin`), which is why this file does not stop at the
|
||||||
|
# hold.
|
||||||
|
0 down 300 1000
|
||||||
|
520 move 300 1000
|
||||||
|
560 move 700 1000
|
||||||
|
600 move 900 1000
|
||||||
|
640 up 900 1000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# The case the flick had no reason to touch: a press and release in one
|
||||||
|
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
|
||||||
|
# tap -- no pan, no velocity, nothing moved.
|
||||||
|
0 down 540 1000
|
||||||
|
80 up 540 1000
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
||||||
//! hold-the-edge expand).
|
//! hold-the-edge expand).
|
||||||
|
|
||||||
use client_core::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow};
|
use client_core::QuestionOption;
|
||||||
|
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -43,6 +44,75 @@ 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,
|
||||||
|
tool: &str,
|
||||||
|
input: &str,
|
||||||
|
result: Option<(&str, bool)>,
|
||||||
|
) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: id.into(),
|
||||||
|
run_id: run.into(),
|
||||||
|
tool: tool.into(),
|
||||||
|
input: input.into(),
|
||||||
|
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
|
||||||
|
done: result.is_some(),
|
||||||
|
failed: result.is_some_and(|(_, failed)| failed),
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call stopped on the reader: one unanswered permission question.
|
||||||
|
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||||
|
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||||
|
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||||
|
asks.push(QuestionCard {
|
||||||
|
seq: 9,
|
||||||
|
id: format!("{id}-q"),
|
||||||
|
prompt: "Allow this command?".into(),
|
||||||
|
header: None,
|
||||||
|
options: vec![
|
||||||
|
QuestionOption {
|
||||||
|
label: "Allow".into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
QuestionOption {
|
||||||
|
label: "Deny".into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multi_select: false,
|
||||||
|
answers: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
call
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longer than the card's own cap, so the "Show all N lines" control is on
|
||||||
|
/// screen in the expanded shot.
|
||||||
|
fn long_output() -> String {
|
||||||
|
(0..200)
|
||||||
|
.map(|i| format!("test transcript_ui::case_{i} ... ok"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn synthetic_rows() -> Vec<FoldedRow> {
|
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||||
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
"# 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![
|
FoldedRow::Tools(vec![
|
||||||
TranscriptItem::ToolRun {
|
tool_call(
|
||||||
seq: 3,
|
"t1",
|
||||||
id: "t1".into(),
|
"Read",
|
||||||
run_id: "run1".into(),
|
r#"{"file_path": "src/main.rs"}"#,
|
||||||
tool: "Read".into(),
|
Some(("fn main() {}\n", false)),
|
||||||
input: "{\"file\": \"src/main.rs\"}".into(),
|
|
||||||
output: "fn main() {}\n".into(),
|
|
||||||
done: true,
|
|
||||||
asks: Vec::new(),
|
|
||||||
images: Vec::new(),
|
|
||||||
},
|
|
||||||
TranscriptItem::ToolRun {
|
|
||||||
seq: 4,
|
|
||||||
id: "t2".into(),
|
|
||||||
run_id: "run1".into(),
|
|
||||||
tool: "Edit".into(),
|
|
||||||
input: "{\"file\": \"src/main.rs\"}".into(),
|
|
||||||
output: "ok".into(),
|
|
||||||
done: true,
|
|
||||||
asks: Vec::new(),
|
|
||||||
images: Vec::new(),
|
|
||||||
},
|
|
||||||
TranscriptItem::ToolRun {
|
|
||||||
seq: 5,
|
|
||||||
id: "t3".into(),
|
|
||||||
run_id: "run1".into(),
|
|
||||||
tool: "Bash".into(),
|
|
||||||
input: "cargo build".into(),
|
|
||||||
output: "Compiling...\nFinished.".into(),
|
|
||||||
done: true,
|
|
||||||
asks: Vec::new(),
|
|
||||||
images: Vec::new(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
msg(6, true, "Looks good, thanks!"),
|
|
||||||
msg(
|
|
||||||
7,
|
|
||||||
false,
|
|
||||||
"You're welcome. Let me know if you'd like anything else.",
|
|
||||||
),
|
),
|
||||||
|
tool_call(
|
||||||
|
"t2",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||||
|
Some((
|
||||||
|
"error: could not compile `iris`\nCaused by: linker not found",
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||||
|
]),
|
||||||
|
// A lone call is a card too rather than a group of one -- and this
|
||||||
|
// one carries the kilobyte output a collapsed card must not lay
|
||||||
|
// out.
|
||||||
|
FoldedRow::Single(tool_call(
|
||||||
|
"t5",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
|
||||||
|
Some((&long_output(), false)),
|
||||||
|
)),
|
||||||
|
msg(6, true, "Looks good, thanks!"),
|
||||||
|
// Every block kind `client_core::markdown_blocks` names, in one
|
||||||
|
// row, so P1a's appearance can be looked at against the Compose
|
||||||
|
// app's without a server (docs/RUST.md's P1a box). The heading,
|
||||||
|
// paragraph, fence and table are the *same source* the bench
|
||||||
|
// fixture carries (`app/bench-fixture/generate.py`), so the two
|
||||||
|
// screenshots differ only in the renderer; the list and the quote
|
||||||
|
// are extra, because the fixture has neither.
|
||||||
|
msg(7, false, BLOCK_SAMPLER),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One of each markdown block, for the P1a screenshot pair. See
|
||||||
|
/// [`synthetic_rows`].
|
||||||
|
const BLOCK_SAMPLER: &str = "\
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Iris **fold** render measure session window anchor context transcript \
|
||||||
|
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||||
|
[bench](https://example.com/bench) link.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
// a comment worth keeping: this is the fold the app's own screen runs
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| column | value |
|
||||||
|
|---|---|
|
||||||
|
| a | measure place draw tool call token context window anchor |
|
||||||
|
|
||||||
|
- one bullet
|
||||||
|
- another, with `inline code`
|
||||||
|
- nested one level
|
||||||
|
1. first numbered
|
||||||
|
2. second numbered
|
||||||
|
|
||||||
|
> A quoted line, to show the bar and the indent.
|
||||||
|
";
|
||||||
|
|
||||||
impl DefaultAppState for Client {
|
impl DefaultAppState for Client {
|
||||||
fn new(
|
fn new(
|
||||||
mut ui_state: DefaultUiState,
|
mut ui_state: DefaultUiState,
|
||||||
@@ -117,6 +219,52 @@ impl DefaultAppState for Client {
|
|||||||
text: "clear".into(),
|
text: "clear".into(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// A second run at the live end, so the *running* state is on
|
||||||
|
// screen too. It cannot share a row with "no result": the two are
|
||||||
|
// the same events and are told apart only by whether the session
|
||||||
|
// is working, which is a property of the row rather than of the
|
||||||
|
// call (`TranscriptScreen::set_session_working`).
|
||||||
|
screen.push_row(
|
||||||
|
rsc,
|
||||||
|
&FoldedRow::Tools(vec![
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t6",
|
||||||
|
"Read",
|
||||||
|
r#"{"file_path": "docs/RUST.md"}"#,
|
||||||
|
Some(("# Moving the app to Rust\n", false)),
|
||||||
|
),
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t7",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||||
|
Some(("error: unused variable `x`", true)),
|
||||||
|
),
|
||||||
|
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||||
|
// Waiting on a permission, so this card is drawn *open*
|
||||||
|
// whatever the reader last chose -- the command is the
|
||||||
|
// thing being decided, and a row saying only "Bash"
|
||||||
|
// cannot be decided on. It is also how the expanded card
|
||||||
|
// (input block, output block, timeout) gets into the
|
||||||
|
// screenshot without a finger.
|
||||||
|
asking(
|
||||||
|
"t9",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
screen.set_session_working(rsc, true);
|
||||||
|
// The expanded picture has no other way to be looked at on a
|
||||||
|
// machine with no display and no finger -- see `run-headless.sh`
|
||||||
|
// and docs/RUST.md's P1b box.
|
||||||
|
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(rsc, true),
|
||||||
|
"the newest row must be the tool run this flag is about"
|
||||||
|
);
|
||||||
|
}
|
||||||
Self { ui_state, screen }
|
Self { ui_state, screen }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,10 +88,21 @@ where
|
|||||||
// height-capped field -- not a background rect and a field drawn as
|
// height-capped field -- not a background rect and a field drawn as
|
||||||
// two independent siblings, which is what let the two disagree on
|
// two independent siblings, which is what let the two disagree on
|
||||||
// where the bar actually was.
|
// where the bar actually was.
|
||||||
|
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
|
||||||
|
// clip that keeps six lines' worth of a longer message inside the
|
||||||
|
// bar. The mask is the caller's job rather than `Scroll`'s own,
|
||||||
|
// because `Painter::set_mask` allows exactly one mask per widget and
|
||||||
|
// a `Scroll` nested under another masked area would abort on the
|
||||||
|
// second -- `.masked()` is the one mechanism for clipping and this is
|
||||||
|
// one more use of it (tabs-ui's message area is the other).
|
||||||
|
// Without it the overflow paints *above* the bar, over the
|
||||||
|
// transcript: measured before this change at 58px of stray text for a
|
||||||
|
// 475px message in a 417px box.
|
||||||
let content = field
|
let content = field
|
||||||
|
.scrollable()
|
||||||
|
.masked()
|
||||||
.pad(dp(FIELD_PAD_DP))
|
.pad(dp(FIELD_PAD_DP))
|
||||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||||
.scrollable()
|
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The transcript screen, in iris -- RUST.md's I5. Built the same way
|
//! The transcript screen, in iris -- RUST.md's I5. Built the same way
|
||||||
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
|
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
|
||||||
//! `Rsc::State: FocusHost`, so the winit example (`iris/examples/
|
//! `Rsc::State: FocusHost + OpenUrl`, so the winit example (`iris/examples/
|
||||||
//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the
|
//! 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
|
//! 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.
|
//! and is not proved yet, and this doc for the shape.
|
||||||
@@ -47,6 +47,7 @@ pub mod composer;
|
|||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
pub mod row;
|
pub mod row;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod tool;
|
||||||
|
|
||||||
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -66,6 +67,17 @@ pub struct TranscriptScreen {
|
|||||||
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
/// interior mutability, per `push_row`'s existing `&self`). Drained by
|
||||||
/// [`Self::take_rebuilds`].
|
/// [`Self::take_rebuilds`].
|
||||||
rebuilds: std::cell::Cell<usize>,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscriptScreen {
|
impl TranscriptScreen {
|
||||||
@@ -75,10 +87,119 @@ impl TranscriptScreen {
|
|||||||
/// newest content when it already was (I3).
|
/// newest content when it already was (I3).
|
||||||
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row);
|
let (key, widget, tail) = row::build_row(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
self.selection.clone(),
|
||||||
|
row,
|
||||||
|
self.session_working.get(),
|
||||||
|
);
|
||||||
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
(self.list)(rsc).push_back(ListRow::new(key, widget));
|
||||||
|
*self.tail.borrow_mut() = tail.map(|t| (key, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the session this transcript belongs to is still doing
|
||||||
|
/// something (`client_core::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,
|
||||||
|
{
|
||||||
|
if self.session_working.replace(working) == working {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut tail = self.tail.borrow_mut();
|
||||||
|
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
|
||||||
|
let calls = tools.calls();
|
||||||
|
tools.apply_calls(rsc, &calls, working);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
||||||
|
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open or close the newest row's tool run, when it is one -- what a
|
||||||
|
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
|
||||||
|
/// displayless machine, and the tests below). Answers whether there
|
||||||
|
/// was such a row to act on, so a caller that expected one can say so
|
||||||
|
/// rather than silently producing the collapsed picture.
|
||||||
|
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let tail = self.tail.borrow();
|
||||||
|
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
tools.set_group_expanded(rsc, expanded);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
{
|
||||||
|
let mut tail = self.tail.borrow_mut();
|
||||||
|
let Some((tail_key, kept)) = tail.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *tail_key != key {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (kept, row) {
|
||||||
|
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
|
||||||
|
let (sender, markdown_src) = row::item_content(item);
|
||||||
|
// A tool call is drawn as a card, never as markdown, so a
|
||||||
|
// row that kept blocks and now holds one is a different
|
||||||
|
// row -- rebuild it.
|
||||||
|
if matches!(
|
||||||
|
item,
|
||||||
|
client_core::transcript_fold::TranscriptItem::ToolRun { .. }
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
blocks.apply_delta(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
self.selection.clone(),
|
||||||
|
key,
|
||||||
|
sender,
|
||||||
|
&markdown_src,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
|
||||||
|
tools.apply_calls(rsc, calls, self.session_working.get())
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||||
|
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
|
||||||
|
}
|
||||||
|
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the effect of one more folded event without rebuilding the
|
/// Apply the effect of one more folded event without rebuilding the
|
||||||
@@ -117,7 +238,7 @@ impl TranscriptScreen {
|
|||||||
old: &[client_core::transcript_fold::TranscriptItem],
|
old: &[client_core::transcript_fold::TranscriptItem],
|
||||||
new: &[client_core::transcript_fold::TranscriptItem],
|
new: &[client_core::transcript_fold::TranscriptItem],
|
||||||
) where
|
) where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
use client_core::transcript_fold::group_tool_runs;
|
use client_core::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
@@ -134,26 +255,57 @@ impl TranscriptScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RowDiff::ReplaceLast { common } => {
|
RowDiff::ReplaceLast { common } => {
|
||||||
// Only the tail row's content changed -- rebuild that one
|
// Only the tail row's content changed. First try the
|
||||||
// row and swap it in place, keeping every row before it
|
// delta path: the row is a column of one widget per
|
||||||
// untouched.
|
// 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`, and
|
||||||
|
// docs/DECISIONS.md for why the row is shaped that way).
|
||||||
let old_key = row::row_key(&old_rows[common].key());
|
let old_key = row::row_key(&old_rows[common].key());
|
||||||
let (new_key, widget) =
|
let new_key = row::row_key(&new_rows[common].key());
|
||||||
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
|
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||||
if new_key != old_key {
|
for row in &new_rows[common + 1..] {
|
||||||
self.selection.borrow_mut().unregister(old_key);
|
self.push_row(rsc, row);
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
// docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||||||
|
self.selection.borrow_mut().unregister(old_key);
|
||||||
|
let (new_key, widget, kept) = row::build_row(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
self.selection.clone(),
|
||||||
|
&new_rows[common],
|
||||||
|
self.session_working.get(),
|
||||||
|
);
|
||||||
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
|
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
|
||||||
drop(evicted); // frees the old row's widget, same as a pop would
|
drop(evicted); // frees the old row's widget, same as a pop would
|
||||||
|
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
|
||||||
for row in &new_rows[common + 1..] {
|
for row in &new_rows[common + 1..] {
|
||||||
self.push_row(rsc, row);
|
self.push_row(rsc, row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RowDiff::Rebuild => {
|
RowDiff::Rebuild => {
|
||||||
// A row before the tail changed (a regroup) -- nothing
|
// A row before the tail changed (a regroup) -- nothing
|
||||||
// short of a full rebuild expresses that.
|
// short of a full rebuild expresses that. `Selection`
|
||||||
|
// gets cleared the same way `List` 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 (docs/REVIEW-2026-09-06.md 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.rebuilds.set(self.rebuilds.get() + 1);
|
||||||
|
self.selection.borrow_mut().clear();
|
||||||
(self.list)(rsc).clear();
|
(self.list)(rsc).clear();
|
||||||
|
*self.tail.borrow_mut() = None;
|
||||||
for row in &new_rows {
|
for row in &new_rows {
|
||||||
self.push_row(rsc, row);
|
self.push_row(rsc, row);
|
||||||
}
|
}
|
||||||
@@ -181,7 +333,7 @@ pub fn build<Rsc: HasEvents>(
|
|||||||
rows: Vec<FoldedRow>,
|
rows: Vec<FoldedRow>,
|
||||||
) -> TranscriptScreen
|
) -> TranscriptScreen
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (screen, tree) = build_tree(rsc, rows);
|
let (screen, tree) = build_tree(rsc, rows);
|
||||||
ui_state.set_root(tree);
|
ui_state.set_root(tree);
|
||||||
@@ -200,14 +352,27 @@ pub fn build_tree<Rsc: HasEvents>(
|
|||||||
rows: Vec<FoldedRow>,
|
rows: Vec<FoldedRow>,
|
||||||
) -> (TranscriptScreen, StrongWidget)
|
) -> (TranscriptScreen, StrongWidget)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let selection = Rc::new(RefCell::new(Selection::new()));
|
let selection = Rc::new(RefCell::new(Selection::new()));
|
||||||
let list = List::new(Axis::Y).add(rsc);
|
let list = List::new(Axis::Y).add(rsc);
|
||||||
|
|
||||||
|
// The last row's block widgets are kept for the same reason
|
||||||
|
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||||
|
// screen is built takes its next delta through `apply`, and a `None`
|
||||||
|
// here would send that delta down the rebuild path instead -- the
|
||||||
|
// whole message re-shaped, which is exactly what the per-block column
|
||||||
|
// exists to avoid, and nothing on screen or in `take_rebuilds` would
|
||||||
|
// say so.
|
||||||
|
let mut tail = None;
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let (key, widget) = row::build_row(rsc, list, selection.clone(), row);
|
// `false`: a row built here is history until the caller says the
|
||||||
|
// session is working (`TranscriptScreen::set_session_working`),
|
||||||
|
// and claiming a call is running because the screen happens to be
|
||||||
|
// opening is exactly the inferred-as-measured mistake.
|
||||||
|
let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false);
|
||||||
list(rsc).push_back(ListRow::new(key, widget));
|
list(rsc).push_back(ListRow::new(key, widget));
|
||||||
|
tail = kept.map(|t| (key, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
|
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
|
||||||
@@ -220,6 +385,41 @@ where
|
|||||||
})
|
})
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
|
// The continuation of a row-started drag once it has committed and
|
||||||
|
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
|
||||||
|
// only ever the gesture's first frame) -- registered once here, not
|
||||||
|
// once per row, since `DragGesture`'s single shared instance must see
|
||||||
|
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are
|
||||||
|
// already relative to `list`'s own on-screen box (this is what it was
|
||||||
|
// registered against), which is exactly the viewport-pixel space
|
||||||
|
// `List::key_at`/`extent` work in, so the row-under-the-pointer is
|
||||||
|
// resolved from those instead of a per-row hit test.
|
||||||
|
{
|
||||||
|
let selection = selection.clone();
|
||||||
|
list.on(
|
||||||
|
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
|
||||||
|
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);
|
||||||
|
selection.borrow_mut().drag(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
row,
|
||||||
|
ctx.data.cursor.pos,
|
||||||
|
ctx.data.sense,
|
||||||
|
ctx.data.cursor.time,
|
||||||
|
ctx.data.render,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.add(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||||
|
|
||||||
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
||||||
@@ -229,6 +429,8 @@ where
|
|||||||
|
|
||||||
(
|
(
|
||||||
TranscriptScreen {
|
TranscriptScreen {
|
||||||
|
tail: RefCell::new(tail),
|
||||||
|
session_working: std::cell::Cell::new(false),
|
||||||
list,
|
list,
|
||||||
composer,
|
composer,
|
||||||
selection,
|
selection,
|
||||||
@@ -311,6 +513,7 @@ mod diff_tests {
|
|||||||
input: "x".to_string(),
|
input: "x".to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -377,3 +580,523 @@ mod diff_tests {
|
|||||||
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exercises `TranscriptScreen::apply`'s `Rebuild` arm through a real
|
||||||
|
/// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 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 `List::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::*;
|
||||||
|
use client_core::transcript_fold::TranscriptItem;
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusHost for TestFocus {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.focus = id;
|
||||||
|
}
|
||||||
|
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.focus == Some(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestRsc {
|
||||||
|
ui: UiData,
|
||||||
|
events: EventManager<TestRsc>,
|
||||||
|
}
|
||||||
|
impl UiRsc for TestRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl HasState for TestRsc {
|
||||||
|
type State = TestFocus;
|
||||||
|
}
|
||||||
|
impl HasEvents for TestRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user(seq: u64, text: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: format!("id{seq}"),
|
||||||
|
run_id: run_id.to_string(),
|
||||||
|
tool: "grep".to_string(),
|
||||||
|
input: "x".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(seq: u64, text: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
|
||||||
|
}
|
||||||
|
out.push_str(tail);
|
||||||
|
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(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
|
||||||
|
let new_items = vec![assistant(
|
||||||
|
1,
|
||||||
|
&reply(paragraphs, "and the last one is still going."),
|
||||||
|
)];
|
||||||
|
let (screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
client_core::transcript_fold::group_tool_runs(&old_items),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &old_items, &new_items);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
||||||
|
let (draws, _, _, shapes) = render.take_counters();
|
||||||
|
(draws, shapes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pass condition for docs/DECISIONS.md's 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!(
|
||||||
|
reply(100, "").len() > 3_000,
|
||||||
|
"the long case must actually be a long message"
|
||||||
|
);
|
||||||
|
let (short_draws, short_shapes) = cost_of_one_delta(1);
|
||||||
|
let (long_draws, long_shapes) = cost_of_one_delta(100);
|
||||||
|
assert_eq!(
|
||||||
|
short_draws, long_draws,
|
||||||
|
"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),
|
||||||
|
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
|
||||||
|
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
|
||||||
|
use client_core::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
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!(
|
||||||
|
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
|
||||||
|
RowDiff::Rebuild,
|
||||||
|
"test setup must actually exercise the Rebuild arm"
|
||||||
|
);
|
||||||
|
|
||||||
|
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(&client_core::transcript_fold::ItemKey::Seq(4));
|
||||||
|
screen.selection.borrow_mut().begin(
|
||||||
|
&mut rsc,
|
||||||
|
(surviving_key, 0),
|
||||||
|
Vec2::ZERO,
|
||||||
|
Vec2::new(10.0, 10.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 client_core::transcript_fold::group_tool_runs;
|
||||||
|
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
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!(
|
||||||
|
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
|
||||||
|
RowDiff::ReplaceLast { common: 1 },
|
||||||
|
"test setup must actually exercise the ReplaceLast arm"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
|
||||||
|
let tail_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(2));
|
||||||
|
assert_eq!(
|
||||||
|
screen
|
||||||
|
.selection
|
||||||
|
.borrow()
|
||||||
|
.registered_blocks(tail_key)
|
||||||
|
.count(),
|
||||||
|
3,
|
||||||
|
"the fixture must start with more blocks than it ends with"
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &old_items, &new_items);
|
||||||
|
assert_eq!(
|
||||||
|
screen
|
||||||
|
.selection
|
||||||
|
.borrow()
|
||||||
|
.registered_blocks(tail_key)
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"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(&client_core::transcript_fold::ItemKey::Seq(1));
|
||||||
|
screen.selection.borrow_mut().begin(
|
||||||
|
&mut rsc,
|
||||||
|
(surviving_key, 0),
|
||||||
|
Vec2::ZERO,
|
||||||
|
Vec2::new(10.0, 10.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool call with `output` bytes of output, `done` or not.
|
||||||
|
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 1,
|
||||||
|
id: id.to_string(),
|
||||||
|
run_id: "run".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
|
||||||
|
output: output.to_string(),
|
||||||
|
done,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| call(&format!("t{i}"), output, done))
|
||||||
|
.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],
|
||||||
|
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
|
||||||
|
let (screen, tree) = build_tree(rsc, client_core::transcript_fold::group_tool_runs(items));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, rsc);
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(rsc, true),
|
||||||
|
"the fixture's only row must be the tool run"
|
||||||
|
);
|
||||||
|
render.update(&tree, rsc);
|
||||||
|
render.take_counters();
|
||||||
|
(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(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let items = run_of(3, output, true);
|
||||||
|
let (screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
client_core::transcript_fold::group_tool_runs(&items),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(&mut rsc, true),
|
||||||
|
"the fixture's only row must be the tool run"
|
||||||
|
);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
let (_, _, _, shapes) = render.take_counters();
|
||||||
|
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();
|
||||||
|
assert!(long.len() > 80_000, "the long case must actually be long");
|
||||||
|
|
||||||
|
let short_shapes = shapes_to_open("ok\n");
|
||||||
|
let long_shapes = shapes_to_open(&long);
|
||||||
|
assert!(
|
||||||
|
short_shapes > 0,
|
||||||
|
"opening a group must shape something, or this compares two zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
short_shapes, long_shapes,
|
||||||
|
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
|
||||||
|
against {short_shapes} over three bytes -- a collapsed card is laying out \
|
||||||
|
something it does not draw"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(count, "", false);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after[0] = call("t0", "the result", true);
|
||||||
|
|
||||||
|
let (screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"a result arriving must not rebuild the whole screen"
|
||||||
|
);
|
||||||
|
let (draws, _, _, _) = render.take_counters();
|
||||||
|
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);
|
||||||
|
let large = cost_of_one_result(12);
|
||||||
|
assert!(
|
||||||
|
small > 0,
|
||||||
|
"a result must redraw *something*, or this compares two zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
small, large,
|
||||||
|
"one result redrew {large} widgets in a twelve-call run against {small} in a \
|
||||||
|
three-call one -- the other cards are being rebuilt with it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(3, "", false);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after[1] = call("t1", "done", true);
|
||||||
|
|
||||||
|
let (screen, tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
client_core::transcript_fold::group_tool_runs(&before),
|
||||||
|
);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
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);
|
||||||
|
assert_eq!(
|
||||||
|
screen.tail_card_count(),
|
||||||
|
3,
|
||||||
|
"the group closed under a result"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(screen.expand_tail_tools(&mut rsc, false));
|
||||||
|
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 {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(2, "ok", true);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.push(call("t2", "", false));
|
||||||
|
|
||||||
|
let (screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||||
|
assert_eq!(screen.tail_card_count(), 2);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"an appended call is not a rebuild"
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = vec![user(1, "stable"), call("t0", "", false)];
|
||||||
|
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
|
||||||
|
let (screen, _tree) = build_tree(
|
||||||
|
&mut rsc,
|
||||||
|
client_core::transcript_fold::group_tool_runs(&before),
|
||||||
|
);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"this is a ReplaceLast, not a whole-screen rebuild"
|
||||||
|
);
|
||||||
|
// The row that replaced it is a message, so it keeps blocks rather
|
||||||
|
// than cards -- and nothing panicked on the way.
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, for I5's row
|
//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered
|
||||||
//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's
|
//! for display: the plain text to draw, the [`SpanStyle`]s that style it,
|
||||||
//! answer to RUST.md's E2 finding against Masonry ("rich inline text --
|
//! the links inside it, and the [`BlockFrame`] the row builder puts around
|
||||||
//! block-level yes, inline no, and both for the same reason": `TextArea`'s
|
//! it.
|
||||||
//! `StyleSet` is one style for the whole editor,
|
//!
|
||||||
|
//! 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`
|
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
|
||||||
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`, added for
|
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
|
||||||
//! this box) is per-range, so bold/italic/inline-code/links/headings inside
|
//! per-range, so bold/italic/inline-code/links inside one wrapped
|
||||||
//! one wrapped paragraph render in their own style *and* the paragraph
|
//! paragraph render in their own style *and* the paragraph still wraps and
|
||||||
//! still wraps and selects as one buffer -- there is no second widget per
|
//! selects as one buffer.
|
||||||
//! span the way E2's block-level `Prose`-per-heading was.
|
//!
|
||||||
|
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
|
||||||
|
//! A heading, a paragraph and a list are all *text with spans*; a fence
|
||||||
|
//! and a table are *verbatim text on a dark surface that pans sideways*;
|
||||||
|
//! a quote is *text behind a coloured bar*. Everything else markdown can
|
||||||
|
//! say is expressed in the spans, which cost no widgets and no layout
|
||||||
|
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
|
||||||
|
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
|
||||||
|
//! this deliberately differs.
|
||||||
//!
|
//!
|
||||||
//! **What this deliberately does not attempt**, each for a reason recorded
|
//! **What this deliberately does not attempt**, each for a reason recorded
|
||||||
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
|
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
|
||||||
@@ -16,52 +27,190 @@
|
|||||||
//! - **No background chip behind inline code.** Drawing one needs the
|
//! - **No background chip behind inline code.** Drawing one needs the
|
||||||
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
|
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
|
||||||
//! highlight uses `selection.geometry(layout)`,
|
//! highlight uses `selection.geometry(layout)`,
|
||||||
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal and
|
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
|
||||||
//! not exposed to a caller building spans externally. `SpanStyle` gives
|
//! `SpanStyle` gives the code range a monospace family and the
|
||||||
//! the code range a monospace family and a dimmer text colour instead --
|
//! palette's code colour instead -- visually distinct, just not
|
||||||
//! visually distinct, just not chip-shaped.
|
//! chip-shaped.
|
||||||
//! - **A link is styled (colour + underline) but not tappable.** Following
|
//! - **A list's indent is written in spaces**, not measured. Compose lays
|
||||||
//! it needs the same kind of per-range hit-testing a chip's background
|
//! an item out as a marker column beside a text column, which keeps a
|
||||||
//! would (which byte range did the tap land in, then look up its URL),
|
//! wrapped second line aligned under the first; here the marker is part
|
||||||
//! which is exactly the same missing primitive.
|
//! of the same buffer, so a wrapped line returns to the left margin.
|
||||||
//! - **Tables render as plain paragraphs of their cell text**, no columns.
|
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
|
||||||
//! `pulldown_cmark::Tag::Table` is walked but not laid out -- a real grid
|
//! wants yet.
|
||||||
//! needs its own widget, out of scope for a row builder.
|
|
||||||
//! - **A fenced code block's language is not syntax-highlighted.**
|
|
||||||
//! `client-core::highlight` exists and could feed per-token `SpanStyle`s,
|
|
||||||
//! but wiring it in is real work belonging to whoever needs it next
|
|
||||||
//! (IRIS_TODO.md).
|
|
||||||
//!
|
//!
|
||||||
//! A heading's `SpanStyle::font_size` override does not also raise its
|
//! 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
|
//! `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
|
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
|
||||||
//! paragraph's -- visible, not incorrect, and not fixed here since it needs
|
//! paragraph's -- visible, not incorrect, and not fixed here since it
|
||||||
//! `SpanStyle` to carry line-height too, which nothing in this crate needed
|
//! needs `SpanStyle` to carry line-height too.
|
||||||
//! badly enough yet to justify.
|
|
||||||
|
|
||||||
|
use client_core::highlight::{self, Kind, Language};
|
||||||
|
use client_core::markdown_blocks::{Block, BlockKind};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
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
|
// `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.
|
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
|
||||||
pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255);
|
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
|
||||||
pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255);
|
// Material's roles, so a block drawn here and the same block drawn by the
|
||||||
const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255);
|
// Compose app are the same colour rather than nearly.
|
||||||
|
const fn mocha(hex: u32) -> UiColor {
|
||||||
|
UiColor::new(
|
||||||
|
((hex >> 16) & 0xff) as u8,
|
||||||
|
((hex >> 8) & 0xff) as u8,
|
||||||
|
(hex & 0xff) as u8,
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// A block-level separator: two blocks never run into each other with no
|
/// Body text: Mocha Text, the Compose app's `onSurface`.
|
||||||
/// gap, but an empty `out` (the very first block) gets no leading blank.
|
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
|
||||||
|
/// Inline code, and a fence with no language to highlight it by.
|
||||||
|
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
|
||||||
|
/// A link. "Blue is what a link is on every Catppuccin surface, and the
|
||||||
|
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
|
||||||
|
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
|
||||||
|
/// A list's bullets and numbers: structure rather than words, so the
|
||||||
|
/// items of a list can be counted without reading them (`listMarkerColor`).
|
||||||
|
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
|
||||||
|
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
|
||||||
|
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
|
||||||
|
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
|
||||||
|
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
|
||||||
|
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
|
||||||
|
/// A quote's bar and its text: the bar carries the structure, and the
|
||||||
|
/// words step back one shade from body text so a quote reads as quoted
|
||||||
|
/// without being hard to read.
|
||||||
|
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
|
||||||
|
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
|
||||||
|
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
|
||||||
|
|
||||||
|
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
|
||||||
|
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
|
||||||
|
/// colours in both apps.
|
||||||
|
fn syntax_color(kind: Kind) -> UiColor {
|
||||||
|
match kind {
|
||||||
|
Kind::Keyword => mocha(0xCBA6F7),
|
||||||
|
Kind::String => mocha(0xA6E3A1),
|
||||||
|
Kind::Literal => mocha(0xFAB387),
|
||||||
|
Kind::Comment => mocha(0x6C7086),
|
||||||
|
Kind::Metadata => mocha(0xF9E2AF),
|
||||||
|
Kind::Punctuation => mocha(0xA6ADC8),
|
||||||
|
Kind::Mark => mocha(0x89DCEB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a row builder puts *around* a block's text widget. Three, not one
|
||||||
|
/// per markdown feature -- see the module doc.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BlockFrame {
|
||||||
|
/// Text and nothing else: a paragraph, a heading, a list, a rule.
|
||||||
|
Plain,
|
||||||
|
/// A dark rounded panel whose text does not wrap -- long lines pan
|
||||||
|
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
|
||||||
|
/// its own fill, since a fence and a table are drawn on different
|
||||||
|
/// ones.
|
||||||
|
Verbatim { fill: UiColor },
|
||||||
|
/// A coloured bar down the left edge and an indent past it.
|
||||||
|
Quote,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame a block kind is drawn in. Pure, and the *only* place the
|
||||||
|
/// mapping is written: a new `BlockKind` shows up here as a compile error
|
||||||
|
/// rather than silently taking prose's appearance.
|
||||||
|
pub fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||||
|
match kind {
|
||||||
|
BlockKind::Code => BlockFrame::Verbatim {
|
||||||
|
fill: VERBATIM_BACKGROUND,
|
||||||
|
},
|
||||||
|
BlockKind::Table => BlockFrame::Verbatim {
|
||||||
|
fill: TABLE_BACKGROUND,
|
||||||
|
},
|
||||||
|
BlockKind::Quote => BlockFrame::Quote,
|
||||||
|
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||||
|
BlockFrame::Plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tappable range of a block's text and where it points.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Link {
|
||||||
|
/// Byte range into [`Rendered::text`].
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
|
||||||
|
/// it there for this would be a change to iris for a test's benefit.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Rendered {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<SpanStyle>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rendered {
|
||||||
|
/// The link `byte` falls inside, if any -- what a tap resolves
|
||||||
|
/// through. Half-open, so the offset one past a link's last character
|
||||||
|
/// (where a tap just after it lands) is *not* in it.
|
||||||
|
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||||
|
self.links.iter().find(|l| l.range.contains(&byte))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||||
|
/// text and descends, because these are headings inside a chat message
|
||||||
|
/// rather than the top of a document. The numbers are Material's
|
||||||
|
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||||
|
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||||
|
/// -- kept as literals rather than derived from `base_size` so the two
|
||||||
|
/// apps agree exactly.
|
||||||
|
fn heading_size(level: HeadingLevel) -> f32 {
|
||||||
|
match level {
|
||||||
|
HeadingLevel::H1 => 24.0,
|
||||||
|
HeadingLevel::H2 => 22.0,
|
||||||
|
HeadingLevel::H3 => 16.0,
|
||||||
|
HeadingLevel::H4 => 14.0,
|
||||||
|
HeadingLevel::H5 => 12.0,
|
||||||
|
HeadingLevel::H6 => 11.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bullet at each depth, cycling past the third: a disc, a ring, a
|
||||||
|
/// square -- the ladder a browser draws, so a nested list is told from its
|
||||||
|
/// parent by the glyph as well as by the indent. Same three
|
||||||
|
/// `MarkdownPieces.kt` uses.
|
||||||
|
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||||
|
|
||||||
|
/// A block-level separator inside one block's own text (a list item's
|
||||||
|
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||||
|
/// an empty `out` gets no leading blank.
|
||||||
fn ensure_blank_line(out: &mut String) {
|
fn ensure_blank_line(out: &mut String) {
|
||||||
if !out.is_empty() && !out.ends_with("\n\n") {
|
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
out.push_str("\n\n");
|
out.push_str("\n\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn heading_size(level: HeadingLevel) -> f32 {
|
fn ensure_line(out: &mut String) {
|
||||||
match level {
|
if !out.is_empty() && !out.ends_with('\n') {
|
||||||
HeadingLevel::H1 => 28.0,
|
out.push('\n');
|
||||||
HeadingLevel::H2 => 24.0,
|
}
|
||||||
HeadingLevel::H3 => 21.0,
|
}
|
||||||
_ => 19.0,
|
|
||||||
|
/// 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,19 +218,27 @@ fn heading_size(level: HeadingLevel) -> f32 {
|
|||||||
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
||||||
/// only so a heading's override is relative to it rather than a hardcoded
|
/// only so a heading's override is relative to it rather than a hardcoded
|
||||||
/// absolute the caller cannot retune.
|
/// absolute the caller cannot retune.
|
||||||
pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||||
let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later
|
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
let mut links = Vec::new();
|
||||||
// Stack of start byte offsets for whatever inline/block styling is
|
// Stack of start byte offsets for whatever inline/block styling is
|
||||||
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||||
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||||
// plain offset stack (rather than a tree, or repeating the kind here
|
// plain offset stack (rather than a tree, or repeating the kind here
|
||||||
// too) is enough.
|
// too) is enough. A link's destination rides along beside its offset,
|
||||||
let mut open: Vec<usize> = Vec::new();
|
// since `TagEnd::Link` does not carry it.
|
||||||
let mut list_depth: u32 = 0;
|
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||||
|
// One entry per open list: `Some(next number)` for an ordered list,
|
||||||
|
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||||
|
// what picks the bullet glyph.
|
||||||
|
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||||
|
// The language of the fence currently open, so `TagEnd::CodeBlock` can
|
||||||
|
// highlight what was collected between the two.
|
||||||
|
let mut fence_language: Option<Language> = None;
|
||||||
|
|
||||||
let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES);
|
let parser = Parser::new_ext(src, options());
|
||||||
for event in parser {
|
for event in parser {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(tag) => match tag {
|
Event::Start(tag) => match tag {
|
||||||
@@ -89,16 +246,35 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
| Tag::Emphasis
|
| Tag::Emphasis
|
||||||
| Tag::Strong
|
| Tag::Strong
|
||||||
| Tag::Strikethrough
|
| Tag::Strikethrough
|
||||||
| Tag::Link { .. } => open.push(out.len()),
|
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||||
Tag::CodeBlock(_) => {
|
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||||
|
Tag::CodeBlock(kind) => {
|
||||||
|
fence_language = match &kind {
|
||||||
|
CodeBlockKind::Fenced(info) => {
|
||||||
|
// Only the first word: "rust,ignore" and
|
||||||
|
// "console session" are both written.
|
||||||
|
highlight::fence_language(info.split_whitespace().next())
|
||||||
|
}
|
||||||
|
CodeBlockKind::Indented => None,
|
||||||
|
};
|
||||||
ensure_blank_line(&mut out);
|
ensure_blank_line(&mut out);
|
||||||
open.push(out.len());
|
open.push((out.len(), None));
|
||||||
}
|
}
|
||||||
Tag::Item => {
|
Tag::Item => {
|
||||||
out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize));
|
ensure_line(&mut out);
|
||||||
out.push_str("\u{2022} ");
|
let depth = lists.len().max(1);
|
||||||
|
out.push_str(&" ".repeat(depth - 1));
|
||||||
|
let start = out.len();
|
||||||
|
match lists.last_mut() {
|
||||||
|
Some(Some(n)) => {
|
||||||
|
out.push_str(&format!("{n}. "));
|
||||||
|
*n += 1;
|
||||||
}
|
}
|
||||||
Tag::List(_) => list_depth += 1,
|
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||||
|
}
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
||||||
|
}
|
||||||
|
Tag::List(first) => lists.push(first),
|
||||||
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
@@ -113,11 +289,19 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
| TagEnd::Strong
|
| TagEnd::Strong
|
||||||
| TagEnd::Strikethrough
|
| TagEnd::Strikethrough
|
||||||
| TagEnd::Link
|
| TagEnd::Link
|
||||||
|
| TagEnd::Image
|
||||||
| TagEnd::CodeBlock),
|
| TagEnd::CodeBlock),
|
||||||
) => {
|
) => {
|
||||||
let Some(start) = open.pop() else {
|
let Some((start, dest)) = open.pop() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||||
|
// A fence's trailing newline is the fence marker's, not
|
||||||
|
// the code's -- kept and it draws an empty last line.
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
let range = start..out.len();
|
let range = start..out.len();
|
||||||
if range.is_empty() {
|
if range.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -131,15 +315,27 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
TagEnd::Strikethrough => {
|
TagEnd::Strikethrough => {
|
||||||
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
|
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
|
||||||
}
|
}
|
||||||
TagEnd::Link => {
|
// An image draws as its alt text until the port has a
|
||||||
spans.push(SpanStyle::new(range).color(LINK_COLOR).underline());
|
// transcript image widget (IRIS_TODO's "scaled
|
||||||
|
// thumbnail"); marked as a link so it is at least
|
||||||
|
// followable rather than silently inert.
|
||||||
|
TagEnd::Link | TagEnd::Image => {
|
||||||
|
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
|
||||||
|
if let Some(url) = dest {
|
||||||
|
links.push(Link { range, url });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TagEnd::CodeBlock => {
|
TagEnd::CodeBlock => {
|
||||||
spans.push(
|
spans.push(
|
||||||
SpanStyle::new(range)
|
SpanStyle::new(range.clone())
|
||||||
.family(Family::Monospace)
|
.family(Family::Monospace)
|
||||||
.color(CODE_COLOR),
|
.color(CODE_COLOR),
|
||||||
);
|
);
|
||||||
|
// After the monospace span, so the per-token
|
||||||
|
// colours win where they overlap it.
|
||||||
|
if let Some(language) = fence_language.take() {
|
||||||
|
highlight_into(&mut spans, &out, range, language);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => unreachable!("filtered by the outer match arm"),
|
_ => unreachable!("filtered by the outer match arm"),
|
||||||
}
|
}
|
||||||
@@ -160,64 +356,432 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
|
|||||||
Event::SoftBreak => out.push(' '),
|
Event::SoftBreak => out.push(' '),
|
||||||
Event::HardBreak => out.push('\n'),
|
Event::HardBreak => out.push('\n'),
|
||||||
Event::Rule => {
|
Event::Rule => {
|
||||||
if !out.ends_with('\n') {
|
ensure_line(&mut out);
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||||
}
|
}
|
||||||
Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
|
Event::TaskListMarker(done) => {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::List(_)) => {
|
||||||
|
lists.pop();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(out, spans)
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
// A span left pointing past the text a later trim shortened would draw
|
||||||
|
// against nothing; markdown that ends inside an open emphasis is
|
||||||
|
// ordinary mid-stream input, not a defect.
|
||||||
|
spans.retain(|s| s.range.end <= out.len());
|
||||||
|
links.retain(|l| l.range.end <= out.len());
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same option set `client_core::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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
|
||||||
|
/// appended to `spans`.
|
||||||
|
///
|
||||||
|
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
|
||||||
|
/// (`highlight`'s module doc), so the offsets are walked once rather than
|
||||||
|
/// converted per span -- a fence is scanned on every delta that lands in
|
||||||
|
/// it, and it is the only block a delta re-renders.
|
||||||
|
pub(crate) fn highlight_into(
|
||||||
|
spans: &mut Vec<SpanStyle>,
|
||||||
|
text: &str,
|
||||||
|
range: Range<usize>,
|
||||||
|
language: Language,
|
||||||
|
) {
|
||||||
|
let code = &text[range.clone()];
|
||||||
|
// char index -> byte offset within `code`, plus the end, so a span's
|
||||||
|
// `end` is always in range.
|
||||||
|
let bytes: Vec<usize> = code
|
||||||
|
.char_indices()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.chain(std::iter::once(code.len()))
|
||||||
|
.collect();
|
||||||
|
for span in highlight::spans_of(code, language) {
|
||||||
|
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"highlight span {}..{} outside {} chars of code",
|
||||||
|
span.start,
|
||||||
|
span.end,
|
||||||
|
bytes.len() - 1
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.start + start..range.start + end)
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(syntax_color(span.kind)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widest a table column is allowed to get before its cells wrap
|
||||||
|
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
|
||||||
|
/// `tableCellWidth` was -- what fits three columns across a phone -- but
|
||||||
|
/// counted in monospace characters, which is the unit a padded table has:
|
||||||
|
/// three 28-character columns plus separators is about 90 characters,
|
||||||
|
/// which is what a 16pt mono face gives on a 1080px phone before the
|
||||||
|
/// sideways pan starts.
|
||||||
|
const TABLE_MAX_COL: usize = 28;
|
||||||
|
|
||||||
|
/// A GFM table as **padded monospace columns**, with the header bold and a
|
||||||
|
/// rule under it.
|
||||||
|
///
|
||||||
|
/// iris has no grid widget, and building one for the one block kind that
|
||||||
|
/// needs it would be a widget per markdown feature -- what this crate's
|
||||||
|
/// module doc says it will not do. A monospace face makes character counts
|
||||||
|
/// and pixel widths the same thing, so padding each cell to its column's
|
||||||
|
/// width *is* alignment, the column widths are measured from the cells,
|
||||||
|
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
|
||||||
|
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
|
||||||
|
pub fn table_text(src: &str) -> Rendered {
|
||||||
|
let rows = table_cells(src);
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Rendered::default();
|
||||||
|
}
|
||||||
|
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||||
|
// Each cell wrapped to the cap first, so a column's width is the
|
||||||
|
// widest *line* it will actually draw rather than the longest cell.
|
||||||
|
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||||
|
.collect();
|
||||||
|
let widths: Vec<usize> = (0..columns)
|
||||||
|
.map(|c| {
|
||||||
|
wrapped
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.get(c))
|
||||||
|
.flat_map(|lines| lines.iter())
|
||||||
|
.map(|l| l.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (r, row) in wrapped.iter().enumerate() {
|
||||||
|
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||||
|
let start = out.len();
|
||||||
|
for line in 0..height {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for (c, width) in widths.iter().enumerate() {
|
||||||
|
if c > 0 {
|
||||||
|
out.push_str(" ");
|
||||||
|
}
|
||||||
|
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||||
|
let text = text.unwrap_or("");
|
||||||
|
out.push_str(text);
|
||||||
|
// The last column is not padded: trailing spaces widen
|
||||||
|
// the block's measured width for nothing.
|
||||||
|
if c + 1 < widths.len() {
|
||||||
|
for _ in text.chars().count()..*width {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||||
|
out.push('\n');
|
||||||
|
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||||
|
let rule_start = out.len();
|
||||||
|
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||||
|
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cells of a GFM table, row by row, as their plain text.
|
||||||
|
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||||
|
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||||
|
let mut cell = String::new();
|
||||||
|
let mut in_cell = false;
|
||||||
|
for event in Parser::new_ext(src, options()) {
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||||
|
Event::Start(Tag::TableCell) => {
|
||||||
|
cell.clear();
|
||||||
|
in_cell = true;
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::TableCell) => {
|
||||||
|
in_cell = false;
|
||||||
|
if let Some(row) = rows.last_mut() {
|
||||||
|
row.push(cell.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||||
|
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.retain(|r| !r.is_empty());
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` broken onto lines of at most `width` characters, at spaces where
|
||||||
|
/// there are any. A word longer than the column is left over-long rather
|
||||||
|
/// than cut mid-word: the column then widens for it, which is visible and
|
||||||
|
/// correct, where cutting would silently lose characters.
|
||||||
|
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
let extra = if line.is_empty() { 0 } else { 1 };
|
||||||
|
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||||
|
lines.push(std::mem::take(&mut line));
|
||||||
|
}
|
||||||
|
if !line.is_empty() {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
line.push_str(word);
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use client_core::markdown_blocks::split_blocks;
|
||||||
|
|
||||||
|
fn block(src: &str) -> Rendered {
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||||
|
render_block(&blocks[0], 16.0)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_paragraph_has_no_spans() {
|
fn plain_paragraph_has_no_spans() {
|
||||||
let (text, spans) = render_markdown("just some words", 16.0);
|
let r = render_markdown("just some words", 16.0);
|
||||||
assert_eq!(text, "just some words");
|
assert_eq!(r.text, "just some words");
|
||||||
assert!(spans.is_empty());
|
assert!(r.spans.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bold_and_italic_produce_spans_over_the_right_range() {
|
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||||
let (text, spans) = render_markdown("a **bold** and *italic* word", 16.0);
|
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||||
assert_eq!(text, "a bold and italic word");
|
assert_eq!(r.text, "a bold and italic word");
|
||||||
let bold = spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||||
assert_eq!(&text[bold.range.clone()], "bold");
|
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||||
let italic = spans.iter().find(|s| s.italic).unwrap();
|
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||||
assert_eq!(&text[italic.range.clone()], "italic");
|
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn heading_gets_a_bigger_font_size_span() {
|
fn heading_gets_a_bigger_font_size_span() {
|
||||||
let (text, spans) = render_markdown("# A Title\n\nbody text", 16.0);
|
let r = render_markdown("# A Title", 16.0);
|
||||||
assert!(text.starts_with("A Title"));
|
assert!(r.text.starts_with("A Title"));
|
||||||
let heading = spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||||
assert_eq!(&text[heading.range.clone()], "A Title");
|
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||||
assert_eq!(heading.font_size, Some(28.0));
|
assert_eq!(heading.font_size, Some(24.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every level draws at its own size, so two levels of nesting are
|
||||||
|
/// never the same -- `Markdown.kt`'s reason for the ladder.
|
||||||
|
#[test]
|
||||||
|
fn every_heading_level_is_a_different_size() {
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for level in 1..=6 {
|
||||||
|
let src = format!("{} h", "#".repeat(level));
|
||||||
|
let r = render_markdown(&src, 16.0);
|
||||||
|
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||||
|
}
|
||||||
|
let mut sorted = sizes.clone();
|
||||||
|
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn link_is_styled_and_keeps_its_visible_text() {
|
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||||
let (text, spans) = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||||
assert!(text.contains("the docs"));
|
assert!(r.text.contains("the docs"));
|
||||||
assert!(
|
assert!(
|
||||||
!text.contains("example.com"),
|
!r.text.contains("example.com"),
|
||||||
"the URL should not leak into the visible text"
|
"the URL should not leak into the visible text"
|
||||||
);
|
);
|
||||||
let link = spans.iter().find(|s| s.underline).unwrap();
|
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||||
assert_eq!(&text[link.range.clone()], "the docs");
|
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||||
|
let at = r.text.find("docs").unwrap();
|
||||||
|
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||||
|
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||||
|
let past = r.text.find("for").unwrap();
|
||||||
|
assert!(r.link_at(past).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fenced_code_block_is_monospaced() {
|
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||||
let (text, spans) = render_markdown("before\n\n```\nlet x = 1;\n```\n\nafter", 16.0);
|
let r = block("```rust\nlet x = 1; // note\n```");
|
||||||
let code = spans.iter().find(|s| s.family.is_some()).unwrap();
|
assert_eq!(r.text, "let x = 1; // note");
|
||||||
assert!(text[code.range.clone()].contains("let x = 1;"));
|
let keyword = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
.expect("a rust fence colours its keywords");
|
||||||
|
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||||
|
let comment = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||||
|
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the change had no reason to touch: a fence in a language
|
||||||
|
/// the highlighter has no rules for must be plain rather than
|
||||||
|
/// coloured by the nearest language's (`CodeFence.kt`'s
|
||||||
|
/// `fenceLanguage` doc).
|
||||||
|
#[test]
|
||||||
|
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||||
|
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert_eq!(r.spans.len(), 1);
|
||||||
|
// `Family` is not `Debug`, so this is `assert!` rather than
|
||||||
|
// `assert_eq!`.
|
||||||
|
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||||
|
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-byte characters are where a char-indexed highlighter and a
|
||||||
|
/// byte-indexed span list disagree if the conversion is missing.
|
||||||
|
#[test]
|
||||||
|
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||||
|
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||||
|
for span in &r.spans {
|
||||||
|
assert!(
|
||||||
|
r.text.is_char_boundary(span.range.start)
|
||||||
|
&& r.text.is_char_boundary(span.range.end),
|
||||||
|
"span {:?} is not on a char boundary of {:?}",
|
||||||
|
span.range,
|
||||||
|
r.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let string = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||||
|
let r = block("```rust\nlet x = 1;");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert!(
|
||||||
|
r.spans
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||||
|
let r = block("- one\n- two\n - deep");
|
||||||
|
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(MARKER_COLOR))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||||
|
let r = block("3. three\n4. four");
|
||||||
|
assert_eq!(r.text, "3. three\n4. four");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(MARKER_COLOR))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["3. ", "4. "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||||
|
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||||
|
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||||
|
let r = render_block(&blocks[0], 16.0);
|
||||||
|
assert_eq!(r.text, "quoted words still quoted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||||
|
use BlockKind::*;
|
||||||
|
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||||
|
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||||
|
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||||
|
assert_ne!(
|
||||||
|
frame_of(Code),
|
||||||
|
frame_of(Table),
|
||||||
|
"a fence and a table sit on different fills"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||||
|
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||||
|
let lines: Vec<&str> = r.text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a bb");
|
||||||
|
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||||
|
assert_eq!(lines[2], "cccc d");
|
||||||
|
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||||
|
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture's own table shape: a long cell wraps inside its column
|
||||||
|
/// instead of making the row one enormous line.
|
||||||
|
#[test]
|
||||||
|
fn a_long_table_cell_wraps_inside_its_column() {
|
||||||
|
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||||
|
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||||
|
for line in r.text.lines() {
|
||||||
|
assert!(
|
||||||
|
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||||
|
"line too wide: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(r.text.contains("twelve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_list_marks_its_boxes() {
|
||||||
|
let r = block("- [x] done\n- [ ] not");
|
||||||
|
assert!(r.text.contains("[x] done"));
|
||||||
|
assert!(r.text.contains("[ ] not"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
//! One `iris::widget::list::ListRow` per folded transcript row
|
//! One `iris::widget::list::ListRow` per folded transcript row
|
||||||
//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text
|
//! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
|
||||||
//! -- headings, paragraphs, inline styling -- goes through `markdown` into
|
//! one `TextEdit` per top-level markdown block** (paragraph, heading,
|
||||||
//! **one** `TextEdit`, which is what makes it one thing `Selection`
|
//! fence, list, table -- `client_core::markdown_blocks`), each rendered
|
||||||
//! (`selection.rs`) can select and what lets it wrap and scroll as a
|
//! with `markdown`'s inline spans, so that RUST.md's "hard to get back"
|
||||||
//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich
|
//! behaviour 2 (rich inline text) still holds within a block and
|
||||||
//! inline text) and half of behaviour 1 (selectable within a row; across
|
//! behaviour 1 (selection) runs across blocks and rows alike through
|
||||||
//! rows is `selection.rs`'s job).
|
//! `selection.rs`.
|
||||||
|
//!
|
||||||
|
//! It was one `TextEdit` for the whole message until 2026-09-06, which
|
||||||
|
//! meant a streamed delta re-shaped every paragraph of a long reply
|
||||||
|
//! through parley again -- the stream phase was the one place iris trailed
|
||||||
|
//! Compose on Iris's phone. [`RowBlocks::apply_delta`] is the other half
|
||||||
|
//! of the fix; docs/DECISIONS.md's entry has what the alternative shapes
|
||||||
|
//! were and why this one.
|
||||||
//!
|
//!
|
||||||
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
|
||||||
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves
|
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves
|
||||||
@@ -16,11 +23,19 @@
|
|||||||
//! collapsed summary and the full detail -- the same two-step contract
|
//! collapsed summary and the full detail -- the same two-step contract
|
||||||
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
|
||||||
|
|
||||||
use crate::markdown::render_markdown;
|
use crate::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||||
use crate::selection::Selection;
|
use crate::selection::{SelKey, Selection};
|
||||||
|
use crate::tool::ToolRow;
|
||||||
|
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
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
|
/// 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
|
/// inside a row scale relative to a fixed set of sizes rather than this one
|
||||||
@@ -51,7 +66,7 @@ pub fn row_key(key: &client_core::transcript_fold::ItemKey) -> RowKey {
|
|||||||
|
|
||||||
/// The sender label shown above a row's text, and the markdown source to
|
/// The sender label shown above a row's text, and the markdown source to
|
||||||
/// render below it. `None` for a system-style note that has no sender.
|
/// render below it. `None` for a system-style note that has no sender.
|
||||||
fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
||||||
match item {
|
match item {
|
||||||
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
||||||
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
|
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
|
||||||
@@ -107,12 +122,188 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build one `TextEdit` from a sender label plus markdown source, register
|
/// The per-block text widgets of one row, kept by `TranscriptScreen` for
|
||||||
/// it with `selection` under `key`, and wire the pointer handlers that
|
/// the row a reply is streaming into, so a delta can replace the block it
|
||||||
/// drive `Selection::drag` -- shared by every row variant below, since a
|
/// lands in instead of re-shaping the whole message
|
||||||
/// selectable row is always "one TextEdit plus this wiring" regardless of
|
/// (docs/DECISIONS.md, 2026-09-06). Nothing else needs it: a row that is
|
||||||
/// what folded it. `list` is threaded through so that same drag can pan
|
/// not the tail never changes.
|
||||||
/// the list instead of selecting, per `Selection::drag`'s own doc.
|
pub struct RowBlocks {
|
||||||
|
/// What each field was built from, in order -- compared against a
|
||||||
|
/// fresh split to decide what may be kept. See
|
||||||
|
/// `client_core::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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split for display: never empty, so a row with nothing in it yet is
|
||||||
|
/// still one (empty) text widget rather than no widget at all -- an empty
|
||||||
|
/// column reports a zero size and the row would vanish from the list.
|
||||||
|
fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
||||||
|
let blocks = split_blocks(markdown_src);
|
||||||
|
if blocks.is_empty() {
|
||||||
|
vec![Block {
|
||||||
|
kind: BlockKind::Paragraph,
|
||||||
|
source: markdown_src.to_string(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<List>,
|
||||||
|
selection: Rc<RefCell<Selection>>,
|
||||||
|
key: SelKey,
|
||||||
|
block: &Block,
|
||||||
|
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let frame = frame_of(block.kind);
|
||||||
|
let rendered = render_block(block, BASE_SIZE);
|
||||||
|
let links = Rc::new(RefCell::new(rendered.links));
|
||||||
|
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
|
||||||
|
let field = wtext(rendered.text)
|
||||||
|
.spans(rendered.spans)
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
// A fence and a table say what they mean by where their
|
||||||
|
// characters sit, so they pan sideways rather than wrap
|
||||||
|
// (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded
|
||||||
|
// in *characters*, which only lines up in a monospace face.
|
||||||
|
.wrap(!verbatim)
|
||||||
|
.family(if verbatim {
|
||||||
|
Family::Monospace
|
||||||
|
} else {
|
||||||
|
Family::SansSerif
|
||||||
|
})
|
||||||
|
.size(BASE_SIZE)
|
||||||
|
.color(match frame {
|
||||||
|
BlockFrame::Quote => crate::markdown::QUOTE_TEXT_COLOR,
|
||||||
|
_ => crate::markdown::TEXT_COLOR,
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
selection.borrow_mut().register(key, field);
|
||||||
|
|
||||||
|
let tap_links = links.clone();
|
||||||
|
field
|
||||||
|
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
||||||
|
// -- this block's own registration only ever needs to see a
|
||||||
|
// gesture's *first* frame (`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 `List` can retire mid-
|
||||||
|
// drag as content scrolls.
|
||||||
|
.on(
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||||
|
move |ctx, rsc| {
|
||||||
|
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
|
||||||
|
let outcome = selection.borrow_mut().drag(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
Some((key, pos, size)),
|
||||||
|
cursor,
|
||||||
|
ctx.data.sense,
|
||||||
|
ctx.data.cursor.time,
|
||||||
|
ctx.data.render,
|
||||||
|
);
|
||||||
|
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||||
|
// the selection are: a gesture that panned the list past
|
||||||
|
// this link, or held long enough to select, must not also
|
||||||
|
// follow it (`GestureOutcome::Tapped`'s doc).
|
||||||
|
if outcome == GestureOutcome::Tapped {
|
||||||
|
let byte = field.edit(rsc).byte_at(cursor, size);
|
||||||
|
let url = tap_links
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.range.contains(&byte))
|
||||||
|
.map(|l| l.url.clone());
|
||||||
|
if let Some(url) = url {
|
||||||
|
log::info!("iris link: opening {url}");
|
||||||
|
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// The column holds the *framed* widget; the field is what
|
||||||
|
// `apply_delta` writes into and what `Selection` resolves. Keeping
|
||||||
|
// the two apart is what lets a fence gain a background without the
|
||||||
|
// delta path knowing anything about frames.
|
||||||
|
let framed = match frame {
|
||||||
|
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||||
|
BlockFrame::Verbatim { fill } => field
|
||||||
|
.scrollable_on(Axis::X)
|
||||||
|
.masked()
|
||||||
|
.pad(dp(FRAME_PAD_DP))
|
||||||
|
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
// A `Stack` (through `background`) rather than a two-child
|
||||||
|
// `Span(Dir::RIGHT)`: the bar is drawn behind text padded past
|
||||||
|
// it, which is the same picture with one widget fewer and
|
||||||
|
// without `Span`'s provisional full-region pass. That pass is
|
||||||
|
// also what first surfaced the `mov`-then-`reposition` assert
|
||||||
|
// docs/RUST.md's P1a box records as still open, so the shape
|
||||||
|
// with fewer passes is the one to prefer here.
|
||||||
|
BlockFrame::Quote => field
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(Padding {
|
||||||
|
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
|
||||||
|
..Padding::ZERO
|
||||||
|
})
|
||||||
|
.background(rect(crate::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
};
|
||||||
|
(field, framed, links)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a row from a sender label plus markdown source: a column of one
|
||||||
|
/// `TextEdit` per top-level markdown block, under the sender's own label.
|
||||||
|
///
|
||||||
|
/// One widget per block rather than one per message is what makes a
|
||||||
|
/// streamed delta cost the last block instead of the whole reply -- see
|
||||||
|
/// [`RowBlocks::apply_delta`] for the other half, and
|
||||||
|
/// `client_core::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`).
|
||||||
fn build_text_row<Rsc: HasEvents>(
|
fn build_text_row<Rsc: HasEvents>(
|
||||||
rsc: &mut Rsc,
|
rsc: &mut Rsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
@@ -120,41 +311,22 @@ fn build_text_row<Rsc: HasEvents>(
|
|||||||
key: RowKey,
|
key: RowKey,
|
||||||
sender: Option<&str>,
|
sender: Option<&str>,
|
||||||
markdown_src: &str,
|
markdown_src: &str,
|
||||||
) -> StrongWidget
|
) -> (StrongWidget, RowBlocks)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (text, spans) = render_markdown(markdown_src, BASE_SIZE);
|
let blocks = display_blocks(markdown_src);
|
||||||
let field = wtext(text)
|
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
||||||
.spans(spans)
|
let mut fields = Vec::with_capacity(blocks.len());
|
||||||
.editable(EditMode::MultiLine)
|
let mut links = Vec::with_capacity(blocks.len());
|
||||||
.text_align(Align::LEFT)
|
for (i, block) in blocks.iter().enumerate() {
|
||||||
.wrap(true)
|
let (field, framed, block_links) =
|
||||||
.size(BASE_SIZE)
|
build_block(rsc, list, selection.clone(), (key, i as u32), block);
|
||||||
.color(UiColor::WHITE)
|
fields.push(field);
|
||||||
.add(rsc);
|
links.push(block_links);
|
||||||
selection.borrow_mut().register(key, field);
|
column.push(framed);
|
||||||
|
}
|
||||||
field
|
let column = column.add(rsc);
|
||||||
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
|
||||||
// -- the arbiter inside `Selection::drag` needs the release too,
|
|
||||||
// to go back to idle for the next press (`DragArbiter::release`).
|
|
||||||
.on(
|
|
||||||
CursorSense::click_or_drag() | CursorSense::unclick(),
|
|
||||||
move |ctx, rsc| {
|
|
||||||
selection.borrow_mut().drag(
|
|
||||||
rsc,
|
|
||||||
list,
|
|
||||||
key,
|
|
||||||
ctx.data.pos,
|
|
||||||
ctx.data.size,
|
|
||||||
ctx.data.cursor.pos,
|
|
||||||
ctx.data.sense,
|
|
||||||
Instant::now(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
|
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
|
||||||
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
|
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
|
||||||
@@ -172,12 +344,108 @@ where
|
|||||||
None => Span::empty(Dir::DOWN).add(rsc),
|
None => Span::empty(Dir::DOWN).add(rsc),
|
||||||
};
|
};
|
||||||
|
|
||||||
(header, field.width(rest(1)))
|
let widget = (header, column.width(rest(1)))
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.gap(dp(4))
|
.gap(dp(4))
|
||||||
.pad(dp(10))
|
.pad(dp(10))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any();
|
||||||
|
(
|
||||||
|
widget,
|
||||||
|
RowBlocks {
|
||||||
|
blocks,
|
||||||
|
fields,
|
||||||
|
links,
|
||||||
|
column,
|
||||||
|
sender: sender.map(str::to_string),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowBlocks {
|
||||||
|
/// Bring this row up to date with `markdown_src` **without** re-laying
|
||||||
|
/// out the blocks that did not change, and say whether that was
|
||||||
|
/// possible. `false` means the caller must rebuild the row the
|
||||||
|
/// ordinary way: an earlier block was rewritten (markdown allows it --
|
||||||
|
/// a trailing `---` turns the paragraph above into a heading), the
|
||||||
|
/// sender changed, or the message got shorter.
|
||||||
|
///
|
||||||
|
/// This is the whole point of the per-block column: a delta arriving
|
||||||
|
/// in a 3,000-character reply touches one `set_with_spans` on the last
|
||||||
|
/// block, so parley re-shapes that block and nothing else.
|
||||||
|
pub fn apply_delta<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
selection: Rc<RefCell<Selection>>,
|
||||||
|
key: RowKey,
|
||||||
|
sender: Option<&str>,
|
||||||
|
markdown_src: &str,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.sender.as_deref() != sender {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let new_blocks = display_blocks(markdown_src);
|
||||||
|
let common = common_prefix(&self.blocks, &new_blocks);
|
||||||
|
// Everything already drawn must either be kept whole (`common ==
|
||||||
|
// len`, a pure append) or be kept except for the last block, which
|
||||||
|
// is the one a delta lands in. Anything else means an already
|
||||||
|
// laid-out block is no longer what it was.
|
||||||
|
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A block's *frame* is built around its widget once and never
|
||||||
|
// rewritten, so a block whose kind changed under the delta (the
|
||||||
|
// paragraph that a `|---|` line turns into a table) cannot take
|
||||||
|
// this path -- it would keep prose's appearance with a table's
|
||||||
|
// text in it. Only the last block can differ at all, by the check
|
||||||
|
// above.
|
||||||
|
if new_blocks.len() == self.blocks.len()
|
||||||
|
&& common < self.blocks.len()
|
||||||
|
&& new_blocks[common].kind != self.blocks[common].kind
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
debug_assert!(
|
||||||
|
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
|
||||||
|
"one field and one link list per block: {} fields, {} links, {} blocks",
|
||||||
|
self.fields.len(),
|
||||||
|
self.links.len(),
|
||||||
|
self.blocks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
||||||
|
match (self.fields.get(i), self.links.get(i)) {
|
||||||
|
(Some(field), Some(links)) => {
|
||||||
|
let rendered = render_block(block, BASE_SIZE);
|
||||||
|
field
|
||||||
|
.edit(rsc)
|
||||||
|
.set_with_spans(&rendered.text, rendered.spans);
|
||||||
|
// Replaced together with the text: a link range left
|
||||||
|
// over from the previous delta points into a string
|
||||||
|
// that no longer exists.
|
||||||
|
*links.borrow_mut() = rendered.links;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let (field, framed, links) =
|
||||||
|
build_block(rsc, list, selection.clone(), (key, i as u32), block);
|
||||||
|
self.fields.push(field);
|
||||||
|
self.links.push(links);
|
||||||
|
// `get_mut` marks the column dirty, which is what gets
|
||||||
|
// the new block drawn; its removal half is the row's
|
||||||
|
// own, since the column owns the child strongly.
|
||||||
|
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||||
|
column.push(framed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.blocks = new_blocks;
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_single<Rsc: HasEvents>(
|
fn build_single<Rsc: HasEvents>(
|
||||||
@@ -186,106 +454,28 @@ fn build_single<Rsc: HasEvents>(
|
|||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
key: RowKey,
|
key: RowKey,
|
||||||
item: &TranscriptItem,
|
item: &TranscriptItem,
|
||||||
) -> StrongWidget
|
) -> (StrongWidget, RowBlocks)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
let (sender, markdown_src) = item_content(item);
|
let (sender, markdown_src) = item_content(item);
|
||||||
build_text_row(rsc, list, selection, key, sender, &markdown_src)
|
build_text_row(rsc, list, selection, key, sender, &markdown_src)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run of adjacent tool calls: collapsed to a one-line summary by
|
/// What a row keeps so the next event can change part of it instead of
|
||||||
/// default, expanding in place to every call's own tool/input/output on
|
/// all of it -- one variant per kind of row that has such a path.
|
||||||
/// tap -- see the module doc for the hold-the-edge contract this wires
|
///
|
||||||
/// against `list`.
|
/// Two mechanisms would have been two answers to the same question ("what
|
||||||
fn build_tools<Rsc: HasEvents>(
|
/// can this row do cheaply?"), so the caller holds one of these for its
|
||||||
rsc: &mut Rsc,
|
/// tail row and asks it, rather than holding a `RowBlocks` and a
|
||||||
list: WeakWidget<List>,
|
/// `ToolRow` and choosing between them at each call site.
|
||||||
selection: Rc<RefCell<Selection>>,
|
pub enum TailRow {
|
||||||
key: RowKey,
|
/// A message: a column of one text widget per markdown block, so a
|
||||||
calls: Vec<TranscriptItem>,
|
/// streamed delta costs the last block.
|
||||||
) -> StrongWidget
|
Blocks(RowBlocks),
|
||||||
where
|
/// A tool call or a run of them: a column of cards, so an arriving
|
||||||
Rsc::State: FocusHost,
|
/// result costs one card.
|
||||||
{
|
Tools(ToolRow),
|
||||||
let expanded = Rc::new(RefCell::new(false));
|
|
||||||
// `.add_strong` (not `.add`) because nothing else in the tree holds a
|
|
||||||
// strong reference to this `WidgetPtr` the way a container's own
|
|
||||||
// `add_strong`-on-its-children does for an ordinary child -- this row
|
|
||||||
// *is* the top of its own subtree, so it has to own itself.
|
|
||||||
let ptr_strong = WidgetPtr::new().add_strong(rsc);
|
|
||||||
let ptr = ptr_strong.weak();
|
|
||||||
|
|
||||||
let summary_text = format!("\u{25b8} {} tool calls", calls.len());
|
|
||||||
let full_text = calls
|
|
||||||
.iter()
|
|
||||||
.map(|c| match c {
|
|
||||||
TranscriptItem::ToolRun {
|
|
||||||
tool,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
..
|
|
||||||
} => tool_call_markdown(tool, input, output),
|
|
||||||
other => item_content(other).1,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
fn build_content<Rsc: HasEvents>(
|
|
||||||
rsc: &mut Rsc,
|
|
||||||
list: WeakWidget<List>,
|
|
||||||
selection: Rc<RefCell<Selection>>,
|
|
||||||
key: RowKey,
|
|
||||||
expanded: bool,
|
|
||||||
summary: &str,
|
|
||||||
full: &str,
|
|
||||||
) -> StrongWidget
|
|
||||||
where
|
|
||||||
Rsc::State: FocusHost,
|
|
||||||
{
|
|
||||||
let text = if expanded { full } else { summary };
|
|
||||||
build_text_row(rsc, list, selection, key, Some("Tools"), text)
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = build_content(
|
|
||||||
rsc,
|
|
||||||
list,
|
|
||||||
selection.clone(),
|
|
||||||
key,
|
|
||||||
false,
|
|
||||||
&summary_text,
|
|
||||||
&full_text,
|
|
||||||
);
|
|
||||||
ptr(rsc).set(content);
|
|
||||||
|
|
||||||
ptr.on(CursorSense::click(), move |ctx, rsc| {
|
|
||||||
// `List::note_tap` wants a viewport-relative position, but the
|
|
||||||
// click event only knows where inside *this row* it landed
|
|
||||||
// (`ctx.data.pos`) -- `List::extent` (last frame's on-screen box
|
|
||||||
// for this row's key) is what turns the two into the position
|
|
||||||
// `list.rs`'s hold-the-edge layout pass resolves against, per the
|
|
||||||
// module doc's contract.
|
|
||||||
let (top, _bottom) = list(rsc).extent(key).unwrap_or((0.0, 0.0));
|
|
||||||
list(rsc).note_tap(top + ctx.data.pos.y);
|
|
||||||
|
|
||||||
let was_expanded = *expanded.borrow();
|
|
||||||
*expanded.borrow_mut() = !was_expanded;
|
|
||||||
let content = build_content(
|
|
||||||
rsc,
|
|
||||||
list,
|
|
||||||
selection.clone(),
|
|
||||||
key,
|
|
||||||
!was_expanded,
|
|
||||||
&summary_text,
|
|
||||||
&full_text,
|
|
||||||
);
|
|
||||||
// The old content's `StrongWidget` is freed when this drops --
|
|
||||||
// the removal half of the row this click just replaced.
|
|
||||||
let _old = ptr(rsc).replace(content);
|
|
||||||
})
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
ptr_strong.any()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_row<Rsc: HasEvents>(
|
pub fn build_row<Rsc: HasEvents>(
|
||||||
@@ -293,18 +483,32 @@ pub fn build_row<Rsc: HasEvents>(
|
|||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
selection: Rc<RefCell<Selection>>,
|
selection: Rc<RefCell<Selection>>,
|
||||||
row: &FoldedRow,
|
row: &FoldedRow,
|
||||||
) -> (RowKey, StrongWidget)
|
working: bool,
|
||||||
|
) -> (RowKey, StrongWidget, Option<TailRow>)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
{
|
{
|
||||||
match row {
|
// A lone tool call is a card too, not a message with markdown in it:
|
||||||
FoldedRow::Single(item) => {
|
// `group_tool_runs` leaves one call as a `Single` because "Called 1
|
||||||
let key = row_key(&item.key());
|
// tool" hides a card to say the same thing in more words, and the
|
||||||
(key, build_single(rsc, list, selection, key, item))
|
// *card* is what both cases draw (`ToolRows.kt`).
|
||||||
|
let calls = match row {
|
||||||
|
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
|
||||||
|
Some(std::slice::from_ref(item))
|
||||||
}
|
}
|
||||||
FoldedRow::Tools(calls) => {
|
FoldedRow::Tools(calls) => Some(calls.as_slice()),
|
||||||
|
FoldedRow::Single(_) => None,
|
||||||
|
};
|
||||||
|
if let Some(calls) = calls {
|
||||||
let key = row_key(&calls[0].key());
|
let key = row_key(&calls[0].key());
|
||||||
(key, build_tools(rsc, list, selection, key, calls.clone()))
|
let (widget, tools) =
|
||||||
}
|
crate::tool::build_tool_row(rsc, list, selection, key, calls.to_vec(), working);
|
||||||
|
return (key, widget, Some(TailRow::Tools(tools)));
|
||||||
}
|
}
|
||||||
|
let FoldedRow::Single(item) = row else {
|
||||||
|
unreachable!("every Tools row took the branch above");
|
||||||
|
};
|
||||||
|
let key = row_key(&item.key());
|
||||||
|
let (widget, blocks) = build_single(rsc, list, selection, key, item);
|
||||||
|
(key, widget, Some(TailRow::Blocks(blocks)))
|
||||||
}
|
}
|
||||||
@@ -33,20 +33,26 @@
|
|||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{collections::BTreeMap, time::Instant};
|
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 (`client_core::markdown_blocks`, and
|
||||||
|
/// docs/DECISIONS.md for why), so the block, not the row, is the unit --
|
||||||
|
/// `(row, block)` compares lexicographically, which is reading order for
|
||||||
|
/// both levels, so every range query below is unchanged.
|
||||||
|
pub type SelKey = (RowKey, u32);
|
||||||
|
|
||||||
pub struct Selection {
|
pub struct Selection {
|
||||||
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
|
rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
|
||||||
anchor: Option<(RowKey, Vec2)>,
|
anchor: Option<(SelKey, Vec2)>,
|
||||||
/// One arbiter shared by every row's drag handler -- RUST.md's I5
|
/// 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
|
/// gesture conflict (a row's own `click_or_drag()` and a list-level
|
||||||
/// pan wanting the same touch gesture). See `drag` below, and
|
/// pan wanting the same touch gesture). See `drag` below, and
|
||||||
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
/// `iris::sense::DragGesture`'s own doc for the arbitration, velocity
|
||||||
arbiter: DragArbiter,
|
/// tracking and pointer-capture mechanics this no longer owns itself
|
||||||
/// Tracks the last ~100ms of this gesture's pan deltas (in the same
|
/// -- Iris's 2026-09-06 ask (`IRIS.md`) that a drag's *mechanics* live
|
||||||
/// signed units `list.scroll` takes), so a release that turns out to
|
/// in iris's default input layer, with only the pan-vs-select
|
||||||
/// have been panning can hand `List::fling` a realistic initial
|
/// *decision* staying here.
|
||||||
/// velocity instead of one frame's noisy last delta --
|
gesture: DragGesture,
|
||||||
/// IRIS_TODO.md's "swiping has no momentum."
|
|
||||||
velocity: VelocityTracker,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Selection {
|
impl Default for Selection {
|
||||||
@@ -60,22 +66,43 @@ impl Selection {
|
|||||||
Self {
|
Self {
|
||||||
rows: BTreeMap::new(),
|
rows: BTreeMap::new(),
|
||||||
anchor: None,
|
anchor: None,
|
||||||
arbiter: DragArbiter::new(),
|
gesture: DragGesture::new(),
|
||||||
velocity: VelocityTracker::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A row's selectable text became visible/known. Every addition here
|
/// A row's selectable text became visible/known. Every addition here
|
||||||
/// needs its removal (`unregister`) -- called when `List` evicts the
|
/// needs its removal (`unregister`, or `clear` for all of them at
|
||||||
/// row (`pop_front`/`pop_back`), so this map never outgrows however
|
/// once) -- called when `List` evicts the row (`pop_front`/
|
||||||
/// many rows are actually loaded.
|
/// `pop_back`/`clear`), so this map never outgrows however many rows
|
||||||
pub fn register(&mut self, key: RowKey, text: WeakWidget<TextEdit>) {
|
/// are actually loaded. `List::place` guards the twin of this same
|
||||||
|
/// class of bug on the list's own side (`list.rs`'s `slot_exists`
|
||||||
|
/// assertion) -- a derived handle that silently outlives what it
|
||||||
|
/// points to; the next caller adding a third row-keyed side table
|
||||||
|
/// should read both.
|
||||||
|
pub fn register(&mut self, key: SelKey, text: WeakWidget<TextEdit>) {
|
||||||
self.rows.insert(key, text);
|
self.rows.insert(key, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unregister(&mut self, key: RowKey) {
|
/// Drops every registration at once -- the same shape `List::clear()`
|
||||||
self.rows.remove(&key);
|
/// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm
|
||||||
if self.anchor.map(|(k, _)| k) == Some(key) {
|
/// calls right before it, since a full rebuild drops every row's old
|
||||||
|
/// widget and `push_row` re-`register`s each surviving key's new one
|
||||||
|
/// as it goes (review docs/REVIEW-2026-09-06.md finding 1: the
|
||||||
|
/// `Rebuild` arm used to call only `List::clear()`, leaving any key
|
||||||
|
/// dropped by the regroup -- present in the old rows, absent from the
|
||||||
|
/// new ones -- pointing at a widget the list had just freed, so the
|
||||||
|
/// next long-press anywhere panicked in `begin`'s deselect loop).
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.rows.clear();
|
||||||
|
self.anchor = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets every block of one row -- a row is registered block by
|
||||||
|
/// block, so its removal has to take all of them, and taking only the
|
||||||
|
/// first is how a freed widget would be left behind in this map.
|
||||||
|
pub fn unregister(&mut self, row: RowKey) {
|
||||||
|
self.rows.retain(|&(k, _), _| k != row);
|
||||||
|
if self.anchor.map(|((k, _), _)| k) == Some(row) {
|
||||||
self.anchor = None;
|
self.anchor = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,8 +112,8 @@ impl Selection {
|
|||||||
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
|
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that
|
||||||
/// never turns into a drag leaves exactly this and nothing else
|
/// never turns into a drag leaves exactly this and nothing else
|
||||||
/// selected.
|
/// selected.
|
||||||
pub fn begin(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) {
|
pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
|
||||||
let rows: Vec<RowKey> = self.rows.keys().copied().collect();
|
let rows: Vec<SelKey> = self.rows.keys().copied().collect();
|
||||||
for k in rows {
|
for k in rows {
|
||||||
if k != key
|
if k != key
|
||||||
&& let Some(w) = self.rows.get(&k)
|
&& let Some(w) = self.rows.get(&k)
|
||||||
@@ -102,7 +129,7 @@ impl Selection {
|
|||||||
|
|
||||||
/// The drag continues, now over `key`'s row at `pos`. See the module
|
/// The drag continues, now over `key`'s row at `pos`. See the module
|
||||||
/// doc for the anchor-row shortcut.
|
/// doc for the anchor-row shortcut.
|
||||||
pub fn extend(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) {
|
pub fn extend(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
|
||||||
let Some((anchor_key, _anchor_pos)) = self.anchor else {
|
let Some((anchor_key, _anchor_pos)) = self.anchor else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -117,7 +144,7 @@ impl Selection {
|
|||||||
} else {
|
} else {
|
||||||
(key, anchor_key)
|
(key, anchor_key)
|
||||||
};
|
};
|
||||||
let in_range: Vec<RowKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
|
let in_range: Vec<SelKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
|
||||||
for k in &in_range {
|
for k in &in_range {
|
||||||
let Some(w) = self.rows.get(k).copied() else {
|
let Some(w) = self.rows.get(k).copied() else {
|
||||||
continue;
|
continue;
|
||||||
@@ -133,7 +160,7 @@ impl Selection {
|
|||||||
w.edit(ui).select_all();
|
w.edit(ui).select_all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outside: Vec<RowKey> = self
|
let outside: Vec<SelKey> = self
|
||||||
.rows
|
.rows
|
||||||
.keys()
|
.keys()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -146,6 +173,47 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The block indices currently registered for `row`, in order. For a
|
||||||
|
/// test asserting that a row's removal or rebuild took every one of
|
||||||
|
/// its blocks with it -- the contract `unregister` states and the one
|
||||||
|
/// a caller can get wrong silently, since a stale handle only shows
|
||||||
|
/// up as a panic on some later, unrelated press.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn registered_blocks(&self, row: RowKey) -> impl Iterator<Item = u32> + '_ {
|
||||||
|
self.rows
|
||||||
|
.keys()
|
||||||
|
.filter(move |(k, _)| *k == row)
|
||||||
|
.map(|&(_, b)| b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which registered block is under `pos_window`, with the position
|
||||||
|
/// and size that block's own `TextEdit` wants (block-local, the way
|
||||||
|
/// `begin`/`extend` are given them by a block's own pointer handler).
|
||||||
|
///
|
||||||
|
/// For the pointer-captured half of a drag, where the event no longer
|
||||||
|
/// reaches the widget under the finger and the list-level handler has
|
||||||
|
/// to say where the finger is. It asks the render state for each
|
||||||
|
/// block's drawn box rather than doing the arithmetic from the row's
|
||||||
|
/// extent -- the box is what a hit test resolves against anyway, and
|
||||||
|
/// it means this and a block's own handler cannot disagree about
|
||||||
|
/// where a block is. O(blocks loaded), on one frame of a drag.
|
||||||
|
pub fn locate(
|
||||||
|
&self,
|
||||||
|
ui: &impl UiRsc,
|
||||||
|
render: &UiRenderState,
|
||||||
|
pos_window: Vec2,
|
||||||
|
) -> Option<(SelKey, Vec2, Vec2)> {
|
||||||
|
for (&key, w) in &self.rows {
|
||||||
|
let Some(px) = render.window_region(w, ui) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if px.contains(pos_window) {
|
||||||
|
return Some((key, pos_window - px.top_left, px.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether any row currently has a non-empty selection -- what a fresh
|
/// Whether any row currently has a non-empty selection -- what a fresh
|
||||||
/// press consults so `drag` knows whether an early horizontal move is
|
/// press consults so `drag` knows whether an early horizontal move is
|
||||||
/// "start dragging the selection handle" rather than an ordinary tap.
|
/// "start dragging the selection handle" rather than an ordinary tap.
|
||||||
@@ -165,85 +233,81 @@ impl Selection {
|
|||||||
/// row, is what makes that consistent as a drag crosses row
|
/// row, is what makes that consistent as a drag crosses row
|
||||||
/// boundaries).
|
/// boundaries).
|
||||||
///
|
///
|
||||||
/// `pos_row`/`size` are row-local, as `begin`/`extend` want;
|
/// `row`, if given, is `(key, pos_row, size)` for whichever row the
|
||||||
|
/// pointer is currently over -- row-local, as `begin`/`extend` want.
|
||||||
|
/// `None` once the gesture is pointer-captured (`iris::sense`'s
|
||||||
|
/// pointer-capture doc) and the current position falls outside every
|
||||||
|
/// row `List` has loaded (a gap, or off the end of the content); a
|
||||||
|
/// `Pan` outcome never needs it, so this only actually matters mid-
|
||||||
|
/// selection, where it is rare and the frame is simply dropped.
|
||||||
/// `pos_window` is in window space, since a pan's delta has to stay
|
/// `pos_window` is in window space, since a pan's delta has to stay
|
||||||
/// meaningful even when this frame's event landed on a different row
|
/// meaningful even when this frame's event landed on a different row
|
||||||
/// than the last one.
|
/// than the last one. `render` is `CursorData`'s own field -- what
|
||||||
|
/// `DragGesture` needs to take pointer capture.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
/// Returns what the gesture decided this frame, so a caller with its
|
||||||
|
/// own meaning for a *tap* -- a row's link handler -- reads it from
|
||||||
|
/// the one arbiter that already knows, rather than timing a second
|
||||||
|
/// one beside it (which would disagree the moment either changed).
|
||||||
pub fn drag(
|
pub fn drag(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut impl UiRsc,
|
ui: &mut impl UiRsc,
|
||||||
list: WeakWidget<List>,
|
list: WeakWidget<List>,
|
||||||
key: RowKey,
|
row: Option<(SelKey, Vec2, Vec2)>,
|
||||||
pos_row: Vec2,
|
|
||||||
size: Vec2,
|
|
||||||
pos_window: Vec2,
|
pos_window: Vec2,
|
||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
) {
|
render: &UiRenderState,
|
||||||
let outcome = match sense {
|
) -> GestureOutcome {
|
||||||
CursorSense::PressStart(_) => {
|
if matches!(sense, CursorSense::PressStart(_)) {
|
||||||
let already_selected = self.has_selection(ui);
|
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
|
||||||
self.velocity.reset();
|
|
||||||
// A fresh touch-down cancels any fling still coasting from
|
// A fresh touch-down cancels any fling still coasting from
|
||||||
// the previous gesture -- `List::fling`'s own doc, and
|
// the previous gesture -- `List::fling`'s own doc, and
|
||||||
// Android's `Scroller::abortAnimation` for the same reason.
|
// Android's `Scroller::abortAnimation` for the same reason.
|
||||||
list(ui).cancel_fling();
|
list(ui).cancel_fling();
|
||||||
self.arbiter.update(pos_window, now)
|
|
||||||
}
|
}
|
||||||
CursorSense::PressEnd(_) => {
|
|
||||||
// A fling only ever follows a pan -- never a selection
|
|
||||||
// that happened to end with the finger still moving, and
|
|
||||||
// never a tap/long-press that never left `Undecided`.
|
|
||||||
if self.arbiter.is_panning() {
|
|
||||||
let v = self.velocity.velocity();
|
|
||||||
list(ui).fling(v);
|
|
||||||
}
|
|
||||||
self.arbiter.release();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A `Pressing` frame with the arbiter still `Idle` means this
|
|
||||||
// gesture's `ACTION_DOWN` landed somewhere no row's sensor
|
|
||||||
// covers (a row's own padding/gap, or a header with no
|
|
||||||
// selection handler) and this row is only now getting the
|
|
||||||
// touch as it moves across it -- the touch is definitely still
|
|
||||||
// down (that's what `Pressing` means), so without this the
|
|
||||||
// arbiter would sit in `Idle` answering `Undecided` for the
|
|
||||||
// rest of the gesture (`DragArbiter::update`'s own doc).
|
|
||||||
// Recovered by starting the press here instead of where it
|
|
||||||
// was missed -- RUST.md's I5 intermittent-touch-scroll-dropout
|
|
||||||
// finding, 2026-09-05.
|
|
||||||
_ if self.arbiter.is_idle() => {
|
|
||||||
let already_selected = self.has_selection(ui);
|
let already_selected = self.has_selection(ui);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
let outcome =
|
||||||
self.velocity.reset();
|
self.gesture
|
||||||
list(ui).cancel_fling();
|
.handle(render, list.id(), sense, pos_window, now, already_selected);
|
||||||
self.arbiter.update(pos_window, now)
|
|
||||||
}
|
|
||||||
_ => self.arbiter.update(pos_window, now),
|
|
||||||
};
|
|
||||||
match outcome {
|
match outcome {
|
||||||
DragOutcome::Undecided => {}
|
GestureOutcome::Undecided => {}
|
||||||
DragOutcome::Pan(dy) => {
|
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||||
let amt = -dy;
|
GestureOutcome::SelectStart => {
|
||||||
self.velocity.add_sample(amt, now);
|
if let Some((key, pos_row, size)) = row {
|
||||||
list(ui).scroll(amt);
|
// Grep-able on "iris selection" the way the frame
|
||||||
}
|
// report is on "iris frame report" -- selection has no
|
||||||
DragOutcome::SelectStart => {
|
// accessibility label of its own yet, so this is the
|
||||||
// Grep-able on "iris selection" the way the frame report is
|
// smallest way to confirm a real on-device long-
|
||||||
// on "iris frame report" -- selection has no accessibility
|
// press-then-drag actually reached here (RUST.md's I5
|
||||||
// label of its own yet, so this is the smallest way to
|
// box, "Measurements taken" (c)).
|
||||||
// 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:?}");
|
log::info!("iris selection: begin at row {key:?}");
|
||||||
self.begin(ui, key, pos_row, size);
|
self.begin(ui, key, pos_row, size);
|
||||||
}
|
}
|
||||||
DragOutcome::SelectExtend => {
|
}
|
||||||
|
GestureOutcome::SelectExtend => {
|
||||||
|
if let Some((key, pos_row, size)) = row {
|
||||||
log::info!("iris selection: extend to row {key:?}");
|
log::info!("iris selection: extend to row {key:?}");
|
||||||
self.extend(ui, key, pos_row, size);
|
self.extend(ui, key, pos_row, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A fling only ever follows a pan -- never a selection that
|
||||||
|
// happened to end with the finger still moving, and never a
|
||||||
|
// tap/long-press that never left `Undecided` -- exactly what
|
||||||
|
// `DragGesture`'s `Some(v)` already encodes.
|
||||||
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
list(ui).fling(-v);
|
||||||
|
// The half that actually makes it move -- see
|
||||||
|
// `List::fling`'s doc. Without it the velocity is
|
||||||
|
// computed, stored, and never advanced by anything.
|
||||||
|
let id = list.id();
|
||||||
|
ui.ui_mut().animate(id);
|
||||||
|
}
|
||||||
|
// A tap is nobody's business here -- `row.rs` reads it from
|
||||||
|
// the returned outcome and follows a link if one was under
|
||||||
|
// the finger.
|
||||||
|
GestureOutcome::Released(None) | GestureOutcome::Tapped => {}
|
||||||
|
}
|
||||||
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The concatenated selected text, in row order, `None` if nothing is
|
/// The concatenated selected text, in row order, `None` if nothing is
|
||||||
@@ -343,9 +407,10 @@ mod tests {
|
|||||||
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak();
|
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak();
|
||||||
|
|
||||||
let mut sel = Selection::new();
|
let mut sel = Selection::new();
|
||||||
sel.register(1, field);
|
sel.register((1, 0), field);
|
||||||
assert!(sel.arbiter.is_idle());
|
assert!(sel.gesture.is_idle());
|
||||||
|
|
||||||
|
let render = UiRenderState::new();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let size = Vec2::new(100.0, 20.0);
|
let size = Vec2::new(100.0, 20.0);
|
||||||
// No `PressStart` is ever sent -- only the `Pressing` frames a
|
// No `PressStart` is ever sent -- only the `Pressing` frames a
|
||||||
@@ -353,22 +418,21 @@ mod tests {
|
|||||||
sel.drag(
|
sel.drag(
|
||||||
&mut rsc,
|
&mut rsc,
|
||||||
list,
|
list,
|
||||||
1,
|
Some(((1, 0), Vec2::ZERO, size)),
|
||||||
Vec2::ZERO,
|
|
||||||
size,
|
|
||||||
Vec2::new(540.0, 700.0),
|
Vec2::new(540.0, 700.0),
|
||||||
CursorSense::Pressing(CursorButton::Left),
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
now,
|
now,
|
||||||
|
&render,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!sel.arbiter.is_idle(),
|
!sel.gesture.is_idle(),
|
||||||
"a Pressing frame with the arbiter still Idle must recover \
|
"a Pressing frame with the arbiter still Idle must recover \
|
||||||
the press rather than leaving it stuck"
|
the press rather than leaving it stuck"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unregister_forgets_the_row_and_clears_a_matching_anchor() {
|
fn unregister_forgets_every_block_of_the_row_and_clears_a_matching_anchor() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
@@ -382,9 +446,13 @@ mod tests {
|
|||||||
.weak();
|
.weak();
|
||||||
|
|
||||||
let mut sel = Selection::new();
|
let mut sel = Selection::new();
|
||||||
sel.register(5, field);
|
// Two blocks of the same row, which is what `unregister` has to
|
||||||
sel.anchor = Some((5, Vec2::ZERO));
|
// take together -- removing only the first is how a freed widget
|
||||||
assert_eq!(sel.rows.len(), 1);
|
// gets left in this map.
|
||||||
|
sel.register((5, 0), field);
|
||||||
|
sel.register((5, 1), field);
|
||||||
|
sel.anchor = Some(((5, 1), Vec2::ZERO));
|
||||||
|
assert_eq!(sel.rows.len(), 2);
|
||||||
|
|
||||||
sel.unregister(5);
|
sel.unregister(5);
|
||||||
assert!(sel.rows.is_empty());
|
assert!(sel.rows.is_empty());
|
||||||
|
|||||||
@@ -0,0 +1,897 @@
|
|||||||
|
//! 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 ([`client_core::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
|
||||||
|
//! `client_core::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::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into};
|
||||||
|
use crate::selection::Selection;
|
||||||
|
use client_core::tool_summary::{ToolInput, parse_tool_input};
|
||||||
|
use client_core::transcript_fold::{ToolState, TranscriptItem};
|
||||||
|
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 much of a tool's output an open card draws before it offers the
|
||||||
|
/// rest behind a tap.
|
||||||
|
///
|
||||||
|
/// **A divergence from Compose, on purpose.** `ToolCard` draws the whole
|
||||||
|
/// output however long, and gets away with it because a Compose `Text`
|
||||||
|
/// inside a `LazyColumn` is laid out lazily; here the output is one text
|
||||||
|
/// widget, and shaping a hundred kilobytes of it through parley is the
|
||||||
|
/// cost `docs/EXPLORER.md`'s `EDIT_LIMIT` was measured against. Lines
|
||||||
|
/// *and* bytes because the two run out at different times -- a diff is
|
||||||
|
/// many short lines, a minified file is one enormous one.
|
||||||
|
const OUTPUT_LINES: usize = 80;
|
||||||
|
const OUTPUT_BYTES: usize = 4096;
|
||||||
|
/// A cap of nothing would draw an empty panel and a "Show all" for every
|
||||||
|
/// output there is, which reads as a rendering fault rather than as a cap.
|
||||||
|
/// Checked at compile time, since both are constants.
|
||||||
|
const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0);
|
||||||
|
|
||||||
|
/// The mark that says a card opens, always drawn from the **monospace**
|
||||||
|
/// face.
|
||||||
|
///
|
||||||
|
/// Not a style choice: `NotoSans-Regular.ttf`, which every other string
|
||||||
|
/// here is set in, has no glyph at U+25B8/U+25BE/U+25B4 at all, while
|
||||||
|
/// `NotoSansMono-Regular.ttf` does -- read out of both bundled `cmap`s on
|
||||||
|
/// 2026-09-06. A missing glyph is the failure nobody who wrote the code
|
||||||
|
/// ever sees, so the face that has the glyph is named at the one place the
|
||||||
|
/// character is written. IRIS_TODO's "a drawn chevron" has the real fix,
|
||||||
|
/// which needs a line primitive iris does not have.
|
||||||
|
const CLOSED_MARK: &str = "\u{25b8}";
|
||||||
|
const OPEN_MARK: &str = "\u{25be}";
|
||||||
|
const UP_MARK: &str = "\u{25b4}";
|
||||||
|
|
||||||
|
/// Which cards the reader has opened, and which have had their whole
|
||||||
|
/// output asked for.
|
||||||
|
///
|
||||||
|
/// Outside the widget tree on purpose: a card is rebuilt when its result
|
||||||
|
/// arrives, and being open is the reader's state rather than the event's
|
||||||
|
/// -- held in the widget, it would silently close the moment the tool
|
||||||
|
/// answered. Keyed by the call's own id, which survives a regroup. Its
|
||||||
|
/// path out is [`ToolRow::apply_calls`], which drops the entry for any
|
||||||
|
/// call no longer in the row.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ToolRowState {
|
||||||
|
group_expanded: bool,
|
||||||
|
open: HashMap<String, bool>,
|
||||||
|
whole_output: HashMap<String, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a handler needs to redraw part of this row, in one `Rc` so
|
||||||
|
/// that a handler registered once keeps working against calls that arrive
|
||||||
|
/// later. The rebuild functions read `calls` fresh rather than capturing a
|
||||||
|
/// call, which is what lets [`ToolRow::apply_calls`] replace a card's
|
||||||
|
/// content without re-registering its gesture.
|
||||||
|
struct Shared {
|
||||||
|
calls: RefCell<Vec<TranscriptItem>>,
|
||||||
|
state: RefCell<ToolRowState>,
|
||||||
|
/// One `WidgetPtr` per call, in order -- what makes a result cost one
|
||||||
|
/// card. Empty while the group is collapsed, because a collapsed group
|
||||||
|
/// draws no cards at all. Its path out is [`build_content`], which
|
||||||
|
/// clears it before building whatever replaces them.
|
||||||
|
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
|
||||||
|
/// The whole row's content, swapped when the group opens or closes.
|
||||||
|
/// Filled in immediately after construction -- the `WidgetPtr` cannot
|
||||||
|
/// exist before the `Rc` every handler inside it captures.
|
||||||
|
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
selection: Rc<RefCell<Selection>>,
|
||||||
|
key: RowKey,
|
||||||
|
/// Whether a call in this row could still be running -- the caller's
|
||||||
|
/// `session_working`, and `false` for every row behind the newest,
|
||||||
|
/// whose turn has already ended. The one input to [`ToolState`] that
|
||||||
|
/// is not a property of the call itself, and what separates "still
|
||||||
|
/// going" from "nobody found out".
|
||||||
|
working: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One transcript row's worth of tool calls, kept by the caller for the
|
||||||
|
/// row a result can still land in -- the tool-call counterpart of
|
||||||
|
/// [`crate::row::RowBlocks`], and the reason a `ToolEnd` costs one card
|
||||||
|
/// rather than a row.
|
||||||
|
pub struct ToolRow {
|
||||||
|
shared: Rc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `f` as this widget's **tap**, panning the list instead when
|
||||||
|
/// the finger moves.
|
||||||
|
///
|
||||||
|
/// The one gesture entry point in this file. `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()` here 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.
|
||||||
|
fn on_tap<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
f: impl Fn(&mut Rsc) + 'static,
|
||||||
|
) where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (list, selection) = (shared.list, shared.selection.clone());
|
||||||
|
ptr.on(
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||||
|
move |ctx, rsc| {
|
||||||
|
let outcome = selection.borrow_mut().drag(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
None,
|
||||||
|
ctx.data.cursor.pos,
|
||||||
|
ctx.data.sense,
|
||||||
|
ctx.data.cursor.time,
|
||||||
|
ctx.data.render,
|
||||||
|
);
|
||||||
|
if outcome == GestureOutcome::Tapped {
|
||||||
|
f(rsc);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.add(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the edge the reader is looking at while this row changes height.
|
||||||
|
///
|
||||||
|
/// `List::note_tap` wants a viewport-relative position and this row only
|
||||||
|
/// knows its own box, so `List::extent` (last frame's on-screen box for
|
||||||
|
/// this key) turns the two into the position `list.rs`'s hold-the-edge
|
||||||
|
/// pass resolves against -- the two-step contract that module's doc
|
||||||
|
/// describes for `AGENTS.md`'s `holdTopEdge`.
|
||||||
|
fn note_tap(rsc: &mut impl UiRsc, shared: &Shared) {
|
||||||
|
// 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)) = (shared.list)(rsc).extent(shared.key) {
|
||||||
|
(shared.list)(rsc).note_tap(top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
|
||||||
|
wtext(content)
|
||||||
|
.size(size)
|
||||||
|
.color(color)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_on(Axis::X)` to this non-editable `Text` made it draw
|
||||||
|
/// nothing at all -- an empty panel where the command should be, seen on
|
||||||
|
/// 2026-09-06 in `docs/bench/p1b-2026-09-06/` and bisected to that one
|
||||||
|
/// call (the fence, which does the same thing to a `TextEdit`, is fine).
|
||||||
|
/// Recorded in docs/IRIS_TODO.md; when it is fixed, the pan belongs here
|
||||||
|
/// too, because the long command is the one being read closely.
|
||||||
|
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
let field = body
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.size(BODY_SIZE)
|
||||||
|
.wrap(false)
|
||||||
|
.add(rsc);
|
||||||
|
field
|
||||||
|
.masked()
|
||||||
|
.pad(dp(RAW_PAD_DP))
|
||||||
|
.background(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The word a card shows for what became of the call, and the colour it is
|
||||||
|
/// in. `None` for a call that simply worked -- the ordinary outcome says
|
||||||
|
/// nothing, the way it says nothing in Compose.
|
||||||
|
///
|
||||||
|
/// Colour by consequence: the same red wherever something failed, the same
|
||||||
|
/// peach wherever the turn is stopped on a person, yellow where the answer
|
||||||
|
/// is that nobody knows.
|
||||||
|
fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> {
|
||||||
|
match state {
|
||||||
|
// A spinner would say the machine is working; while this call
|
||||||
|
// waits on an answer the machine is doing nothing at all, so the
|
||||||
|
// card says whose move it is instead (`ToolRows.kt`).
|
||||||
|
ToolState::Deciding => Some(("your turn", AWAITING_COLOR)),
|
||||||
|
ToolState::Running => Some(("running", MUTED_COLOR)),
|
||||||
|
ToolState::Failed => Some(("failed", FAILED_COLOR)),
|
||||||
|
ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)),
|
||||||
|
ToolState::Succeeded => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a screen reader is given for one card, and what a `ui-trace`
|
||||||
|
/// script taps by: the tool, what the call is for, and how it went when
|
||||||
|
/// that is anything but "fine" -- the same three things the Compose card's
|
||||||
|
/// own text says, in the order it says them.
|
||||||
|
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
|
||||||
|
let mut name = tool.to_string();
|
||||||
|
if let Some(title) = parsed.title() {
|
||||||
|
name.push_str(": ");
|
||||||
|
name.push_str(title);
|
||||||
|
}
|
||||||
|
if let Some((word, _)) = state_mark(state) {
|
||||||
|
name.push_str(" (");
|
||||||
|
name.push_str(word);
|
||||||
|
name.push(')');
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading a group carries, closed or open. Compose's exact wording,
|
||||||
|
/// because it is also the name every `ui-trace` script taps it by.
|
||||||
|
fn group_label(count: usize) -> String {
|
||||||
|
format!("Called {count} tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `output` cut to what an open card draws, with the line count it was cut
|
||||||
|
/// from; `None` when the whole of it fits.
|
||||||
|
///
|
||||||
|
/// 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 path is identified by its other end; this is not a path.)
|
||||||
|
fn capped(output: &str) -> Option<(&str, usize)> {
|
||||||
|
let by_lines = output
|
||||||
|
.char_indices()
|
||||||
|
.filter(|(_, c)| *c == '\n')
|
||||||
|
.nth(OUTPUT_LINES - 1)
|
||||||
|
.map(|(i, _)| i);
|
||||||
|
let by_bytes = (output.len() > OUTPUT_BYTES).then(|| {
|
||||||
|
let mut end = OUTPUT_BYTES;
|
||||||
|
while !output.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
end
|
||||||
|
});
|
||||||
|
let cut = match (by_lines, by_bytes) {
|
||||||
|
(Some(a), Some(b)) => a.min(b),
|
||||||
|
(a, b) => a.or(b)?,
|
||||||
|
};
|
||||||
|
Some((&output[..cut], output.lines().count()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool's output, or the reason there is none to show.
|
||||||
|
///
|
||||||
|
/// The empty cases are drawn rather than left blank: "it printed nothing"
|
||||||
|
/// and "nothing ever came back" are the pair [`ToolState`] exists to keep
|
||||||
|
/// apart, and a card that drew neither would show the same thing for both.
|
||||||
|
fn output_block<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
id: &str,
|
||||||
|
output: &str,
|
||||||
|
call_state: ToolState,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if output.is_empty() {
|
||||||
|
let (words, colour) = match call_state {
|
||||||
|
ToolState::Succeeded => ("No output", MUTED_COLOR),
|
||||||
|
ToolState::Failed => ("Failed, with no output", FAILED_COLOR),
|
||||||
|
ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR),
|
||||||
|
ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR),
|
||||||
|
};
|
||||||
|
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let whole = shared
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.whole_output
|
||||||
|
.get(id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
let shown = if whole { None } else { capped(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.map_or(output, |(head, _)| head).to_string(),
|
||||||
|
BODY_SIZE,
|
||||||
|
NAME_COLOR,
|
||||||
|
);
|
||||||
|
column.push(raw_block(rsc, body));
|
||||||
|
if let Some((_, lines)) = shown {
|
||||||
|
let label = format!("Show all {lines} lines");
|
||||||
|
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let more = more_strong.weak();
|
||||||
|
let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR)
|
||||||
|
.label(label)
|
||||||
|
.add_strong(rsc);
|
||||||
|
more(rsc).set(words);
|
||||||
|
let shared_for_tap = shared.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
on_tap(rsc, more, shared, move |rsc| {
|
||||||
|
note_tap(rsc, &shared_for_tap);
|
||||||
|
shared_for_tap
|
||||||
|
.state
|
||||||
|
.borrow_mut()
|
||||||
|
.whole_output
|
||||||
|
.insert(id.clone(), true);
|
||||||
|
redraw_card(rsc, &shared_for_tap, index);
|
||||||
|
});
|
||||||
|
column.push(more_strong.any());
|
||||||
|
}
|
||||||
|
column.width(rest(1)).add_strong(rsc).any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tool call's card content.
|
||||||
|
///
|
||||||
|
/// Collapsed, this is one `Span` of at most four short strings -- no
|
||||||
|
/// input, no output, nothing whose size is the call's size.
|
||||||
|
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let call = shared.calls.borrow()[index].clone();
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} = &call
|
||||||
|
else {
|
||||||
|
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
|
||||||
|
return Span::empty(Dir::DOWN).add_strong(rsc).any();
|
||||||
|
};
|
||||||
|
let parsed = parse_tool_input(tool, input);
|
||||||
|
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
|
||||||
|
// A call waiting on permission is shown open whatever the reader last
|
||||||
|
// chose: the command is the thing being decided, and a row saying only
|
||||||
|
// "Bash" cannot be decided on (`ToolRows.kt`).
|
||||||
|
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|
||||||
|
|| call_state == ToolState::Deciding;
|
||||||
|
|
||||||
|
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
|
||||||
|
header.push(
|
||||||
|
text(
|
||||||
|
if open { OPEN_MARK } else { CLOSED_MARK },
|
||||||
|
BODY_SIZE,
|
||||||
|
MUTED_COLOR,
|
||||||
|
)
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
header.push(
|
||||||
|
text(tool.clone(), NAME_SIZE, NAME_COLOR)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
match (open, parsed.title()) {
|
||||||
|
// Open, the summary is redundant -- the input below is the same
|
||||||
|
// thing in full -- and the space goes to the timeout instead, at
|
||||||
|
// the far end, since it is a limit on the call rather than part of
|
||||||
|
// what the call does.
|
||||||
|
(true, _) | (false, None) => {
|
||||||
|
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
|
||||||
|
}
|
||||||
|
// One line, clipped rather than shrunk or wrapped: a wrapped
|
||||||
|
// command turns one row into four and a run of them into a wall.
|
||||||
|
(false, Some(title)) => header.push(
|
||||||
|
text(title.to_string(), BODY_SIZE, MUTED_COLOR)
|
||||||
|
.wrap(false)
|
||||||
|
.masked()
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if open && let Some(timeout) = &parsed.timeout {
|
||||||
|
header.push(
|
||||||
|
text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((word, colour)) = state_mark(call_state) {
|
||||||
|
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
|
||||||
|
column.push(header.width(rest(1)).add_strong(rsc).any());
|
||||||
|
if open {
|
||||||
|
if let Some(description) = &parsed.description {
|
||||||
|
// The tool's own prose about what it is doing, so it belongs
|
||||||
|
// with the reader's text rather than inside the machine's --
|
||||||
|
// above the input block rather than in it (`ToolInput.kt`).
|
||||||
|
column.push(
|
||||||
|
text(description.clone(), BODY_SIZE, MUTED_COLOR)
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(subject) = &parsed.subject {
|
||||||
|
let spans = match parsed.language {
|
||||||
|
Some(language) => {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
highlight_into(&mut spans, subject, 0..subject.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(subject.clone(), BODY_SIZE, NAME_COLOR).spans(spans);
|
||||||
|
column.push(raw_block(rsc, body));
|
||||||
|
}
|
||||||
|
if !parsed.rest.is_empty() {
|
||||||
|
// Never dropped: a field left out would be claiming the tool
|
||||||
|
// had no other input when it might (`ToolInput.kt`).
|
||||||
|
let body = text(parsed.rest.join("\n"), BODY_SIZE, MUTED_COLOR);
|
||||||
|
column.push(raw_block(rsc, body));
|
||||||
|
}
|
||||||
|
column.push(output_block(rsc, shared, index, id, output, call_state));
|
||||||
|
}
|
||||||
|
|
||||||
|
column
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(card_label(tool, &parsed, call_state))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild card `index` in place. The removal half is the returned
|
||||||
|
/// `StrongWidget` being dropped, which frees the content this replaced.
|
||||||
|
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let Some(ptr) = shared.card_ptr(index) else {
|
||||||
|
// Reached only if a handler outlives the card it was registered
|
||||||
|
// on, which `apply_calls` is written to prevent.
|
||||||
|
debug_assert!(false, "card {index} has no widget to redraw");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let content = build_card(rsc, shared, index);
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card and the tap that opens it. The gesture is registered **once**,
|
||||||
|
/// on a `WidgetPtr` whose content is replaced as often as needed -- which
|
||||||
|
/// is why every rebuild reads the call out of [`Shared`] rather than
|
||||||
|
/// capturing one.
|
||||||
|
fn build_card_ptr<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
) -> (StrongWidget, WeakWidget<WidgetPtr>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
// The strong handle is the card's one real registration and goes to
|
||||||
|
// whatever container holds it; the weak one is what the gesture and
|
||||||
|
// every later redraw address it by.
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
shared.cards.borrow_mut().push(ptr);
|
||||||
|
debug_assert_eq!(
|
||||||
|
shared.cards.borrow().len(),
|
||||||
|
index + 1,
|
||||||
|
"a card's index is its position, and both are the call's"
|
||||||
|
);
|
||||||
|
let content = build_card(rsc, shared, index);
|
||||||
|
ptr(rsc).set(content);
|
||||||
|
let for_tap = shared.clone();
|
||||||
|
on_tap(rsc, ptr, shared, move |rsc| {
|
||||||
|
note_tap(rsc, &for_tap);
|
||||||
|
let Some(id) = for_tap.call_id(index) else {
|
||||||
|
debug_assert!(false, "tapped card {index} is no longer in the row");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let was = for_tap
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.open
|
||||||
|
.get(&id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
for_tap.state.borrow_mut().open.insert(id, !was);
|
||||||
|
redraw_card(rsc, &for_tap, index);
|
||||||
|
});
|
||||||
|
(strong.any(), ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bar the height of one line of `LABEL_SIZE` text, carrying `mark`
|
||||||
|
/// centred -- the group's collapse control at its foot.
|
||||||
|
///
|
||||||
|
/// Given the same content as the heading above rather than a height that
|
||||||
|
/// looks close, so the surface the calls sit on is the same thickness at
|
||||||
|
/// both ends (`ToolRows.kt`'s `groupBarHeight`, which derives the number
|
||||||
|
/// from the type for the same reason).
|
||||||
|
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
let mark = text(UP_MARK, BODY_SIZE, MUTED_COLOR)
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.center()
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
// Anything shown only as a mark still needs a name: this is what
|
||||||
|
// a screen reader reads and what a `ui-trace` script taps.
|
||||||
|
.label("Collapse these tool calls")
|
||||||
|
.add_strong(rsc);
|
||||||
|
ptr(rsc).set(mark);
|
||||||
|
let for_tap = shared.clone();
|
||||||
|
on_tap(rsc, ptr, shared, move |rsc| toggle_group(rsc, &for_tap));
|
||||||
|
strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row's whole content: a lone card, a closed group's one line, or an
|
||||||
|
/// open group's header, cards and foot.
|
||||||
|
///
|
||||||
|
/// Rebuilt whole when the group opens or closes, because that is a change
|
||||||
|
/// of what the row *is* rather than of one card in it. Everything a single
|
||||||
|
/// card's tap does goes through [`redraw_card`] instead.
|
||||||
|
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
shared.cards.borrow_mut().clear();
|
||||||
|
let count = shared.calls.borrow().len();
|
||||||
|
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
|
||||||
|
|
||||||
|
// One call is left alone: "Called 1 tool" hides a card to say the same
|
||||||
|
// thing in more words, and the run this grouping exists for is the
|
||||||
|
// burst of five greps nobody wants to scroll past (`ToolRows.kt`).
|
||||||
|
if count == 1 {
|
||||||
|
return build_card_ptr(rsc, shared, 0).0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shared.state.borrow().group_expanded {
|
||||||
|
let heading = group_label(count);
|
||||||
|
return text(heading.clone(), NAME_SIZE, NAME_COLOR)
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(heading)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading = group_label(count);
|
||||||
|
let mut group = Span::empty(Dir::DOWN);
|
||||||
|
group.push(
|
||||||
|
text(heading.clone(), NAME_SIZE, NAME_COLOR)
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(heading)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
// The cards go straight into the group's own `Span`, not into a
|
||||||
|
// second one inside it. **A `Span` of `Pad`ded children inside another
|
||||||
|
// `Span` places those children a slot out of step** -- each card's
|
||||||
|
// content drew one card's height below its own box, so the cards read
|
||||||
|
// as empty bars with somebody else's summary in them. Bisected on
|
||||||
|
// 2026-09-06 against `iris/run-headless.sh transcript` with
|
||||||
|
// `IRIS_TOOLS_EXPANDED=1`: removing the inner `Span` fixes it and
|
||||||
|
// removing the cards' own `Pad` fixes it, while the card background,
|
||||||
|
// the `Sized` wrappers and the per-card `WidgetPtr` all make no
|
||||||
|
// difference. It is a framework defect rather than this file's --
|
||||||
|
// docs/RUST.md's P1b box and docs/IRIS_TODO.md carry the repro -- and
|
||||||
|
// one `Span` is the shape that works today. What it costs is the 4dp
|
||||||
|
// inset the Compose group holds its cards off its edge by; the cards'
|
||||||
|
// own padding stands in for it.
|
||||||
|
for index in 0..count {
|
||||||
|
group.push(build_card_ptr(rsc, shared, index).0);
|
||||||
|
}
|
||||||
|
// Shutting it from here anchors the other end: the reader is at the
|
||||||
|
// bottom of a long group, and what they are looking at is what follows
|
||||||
|
// it (`ToolRows.kt`'s `CollapseBar`).
|
||||||
|
group.push(collapse_bar(rsc, shared));
|
||||||
|
group
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(GROUP_FILL).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
note_tap(rsc, shared);
|
||||||
|
let was = shared.state.borrow().group_expanded;
|
||||||
|
shared.state.borrow_mut().group_expanded = !was;
|
||||||
|
let content = build_content(rsc, shared);
|
||||||
|
shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shared {
|
||||||
|
/// Swap the row's whole content. The old `StrongWidget` is freed as it
|
||||||
|
/// drops here, which is the removal half of what replaced it.
|
||||||
|
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
|
||||||
|
let Some(ptr) = *self.content.borrow() else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"the row's content pointer is set before anything can tap it"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_id(&self, index: usize) -> Option<String> {
|
||||||
|
match self.calls.borrow().get(index) {
|
||||||
|
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
|
||||||
|
self.cards.borrow().get(index).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a tool row: one card, or a run of them under one heading.
|
||||||
|
///
|
||||||
|
/// `working` is the caller's `session_working` **for this row** -- true
|
||||||
|
/// only for the newest row of a session that is still doing something.
|
||||||
|
/// Every row behind it belongs to a turn that has ended, so a call in one
|
||||||
|
/// with no result never came back rather than still running.
|
||||||
|
pub fn build_tool_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<List>,
|
||||||
|
selection: Rc<RefCell<Selection>>,
|
||||||
|
key: RowKey,
|
||||||
|
calls: Vec<TranscriptItem>,
|
||||||
|
working: bool,
|
||||||
|
) -> (StrongWidget, ToolRow)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let shared = Rc::new(Shared {
|
||||||
|
calls: RefCell::new(calls),
|
||||||
|
state: RefCell::new(ToolRowState::default()),
|
||||||
|
cards: RefCell::new(Vec::new()),
|
||||||
|
content: RefCell::new(None),
|
||||||
|
list,
|
||||||
|
selection,
|
||||||
|
key,
|
||||||
|
working: Cell::new(working),
|
||||||
|
});
|
||||||
|
// `.add_strong`, not `.add`: this row *is* the top of its own subtree,
|
||||||
|
// so nothing else holds it and it has to own itself (`row.rs`).
|
||||||
|
let content_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let content = content_strong.weak();
|
||||||
|
*shared.content.borrow_mut() = Some(content);
|
||||||
|
let inner = build_content(rsc, &shared);
|
||||||
|
content(rsc).set(inner);
|
||||||
|
(content_strong.any(), ToolRow { shared })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolRow {
|
||||||
|
/// The calls this row is currently drawing -- what a caller passes
|
||||||
|
/// back to [`Self::apply_calls`] when something other than the calls
|
||||||
|
/// themselves changed (the session's status).
|
||||||
|
pub fn calls(&self) -> Vec<TranscriptItem> {
|
||||||
|
self.shared.calls.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many cards this row currently draws -- zero for a closed
|
||||||
|
/// group, which is the whole reason its calls' outputs cost nothing.
|
||||||
|
/// Only the tests ask; nothing on screen is decided by it.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn card_count(&self) -> usize {
|
||||||
|
self.shared.cards.borrow().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open or close this row's group without a tap.
|
||||||
|
///
|
||||||
|
/// Exists because the expanded appearance is otherwise unreachable
|
||||||
|
/// from anything that cannot press the screen -- a headless
|
||||||
|
/// screenshot on this displayless machine, and a test. Same path a tap
|
||||||
|
/// takes, including `List::note_tap`, so what it produces is what a
|
||||||
|
/// reader would have got.
|
||||||
|
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.shared.state.borrow().group_expanded != expanded {
|
||||||
|
toggle_group(rsc, &self.shared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring this row up to date with `calls` **without** rebuilding the
|
||||||
|
/// cards that did not change, and say whether that was possible.
|
||||||
|
/// `false` means the caller must rebuild the row the ordinary way.
|
||||||
|
///
|
||||||
|
/// This is what the per-card `WidgetPtr` exists for: a `ToolEnd`
|
||||||
|
/// changes one call, so it costs one card, whatever else is in the
|
||||||
|
/// run. The same rule `RowBlocks::apply_delta` follows for the blocks
|
||||||
|
/// of a message.
|
||||||
|
///
|
||||||
|
/// Refused when a call *left* the row or the calls were reordered: a
|
||||||
|
/// card's index is its call's position, and every registered handler
|
||||||
|
/// closed over that index. A run only ever grows at its end while it
|
||||||
|
/// is the live row, so the refused cases are the ones a page join
|
||||||
|
/// produces -- and those go through `Rebuild` already.
|
||||||
|
pub fn apply_calls<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
calls: &[TranscriptItem],
|
||||||
|
working: bool,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
// A row that was tool calls and now holds something else is a
|
||||||
|
// different row, not a changed one -- and nothing here could draw
|
||||||
|
// a message anyway.
|
||||||
|
if calls.is_empty()
|
||||||
|
|| !calls
|
||||||
|
.iter()
|
||||||
|
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let old = self.shared.calls.borrow().clone();
|
||||||
|
if calls.len() < old.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Whether the group is drawn as one card or as a stack changes at
|
||||||
|
// exactly one call, and that is a different row, not a changed
|
||||||
|
// one.
|
||||||
|
if (old.len() == 1) != (calls.len() == 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
|
||||||
|
self.shared.working.set(working);
|
||||||
|
*self.shared.calls.borrow_mut() = calls.to_vec();
|
||||||
|
// The path out for the reader's own state: a call that is no
|
||||||
|
// longer in this row keeps no entry in `open`/`whole_output`.
|
||||||
|
let ids: std::collections::HashSet<String> = calls
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut state = self.shared.state.borrow_mut();
|
||||||
|
state.open.retain(|id, _| ids.contains(id));
|
||||||
|
state.whole_output.retain(|id, _| ids.contains(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A collapsed group draws no cards, so a changed call is worth
|
||||||
|
// nothing on screen -- unless the *count* changed, which is the
|
||||||
|
// whole of what its one line says.
|
||||||
|
if self.shared.cards.borrow().is_empty() {
|
||||||
|
if calls.len() != old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.shared.cards.borrow().len(),
|
||||||
|
old.len(),
|
||||||
|
"an open row draws exactly one card per call"
|
||||||
|
);
|
||||||
|
|
||||||
|
for index in changed {
|
||||||
|
redraw_card(rsc, &self.shared, index);
|
||||||
|
}
|
||||||
|
// A call *joining* the run rebuilds the row's content rather than
|
||||||
|
// appending one card: the group's `Span` holds its collapse bar
|
||||||
|
// after the cards, and `Span::push` would put the new card behind
|
||||||
|
// it. That is still O(this row) -- every other row is untouched --
|
||||||
|
// and it is much rarer than a result arriving, which is the case
|
||||||
|
// the per-card `WidgetPtr` above exists for.
|
||||||
|
if calls.len() > old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -700,6 +700,7 @@ impl Translator {
|
|||||||
events.push(Event::ToolEnd {
|
events.push(Event::ToolEnd {
|
||||||
id: about.clone(),
|
id: about.clone(),
|
||||||
output: texts.join("\n"),
|
output: texts.join("\n"),
|
||||||
|
is_error: crate::session::import::tool_result_is_error(block),
|
||||||
});
|
});
|
||||||
// Deliberately does *not* finish a subagent `about` might name:
|
// Deliberately does *not* finish a subagent `about` might name:
|
||||||
// the Task tool runs in the background by default, so this
|
// the Task tool runs in the background by default, so this
|
||||||
@@ -1015,7 +1016,44 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "toolu_01".to_string(),
|
id: "toolu_01".to_string(),
|
||||||
output: "probe-ok".to_string()
|
output: "probe-ok".to_string(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of the test above, and the one it cannot stand in
|
||||||
|
/// for: a call the tool itself reported as failed. Both lines are
|
||||||
|
/// `tool_result`s and both carry output, so nothing but `is_error`
|
||||||
|
/// tells them apart -- which is why dropping the field made a broken
|
||||||
|
/// call draw exactly like one that worked.
|
||||||
|
#[test]
|
||||||
|
fn a_failed_tool_result_says_so() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
|
||||||
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
|
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_02","type":"tool_result","content":"No such file or directory","is_error":true}]},"parent_tool_use_id":null}"#,
|
||||||
|
// No `is_error` at all: every transcript written before
|
||||||
|
// the field was read looks like this, and it means the
|
||||||
|
// call was not reported to have failed.
|
||||||
|
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_03","type":"tool_result","content":"fine"}]},"parent_tool_use_id":null}"#,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "toolu_02".to_string(),
|
||||||
|
output: "No such file or directory".to_string(),
|
||||||
|
is_error: true,
|
||||||
|
},
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "toolu_03".to_string(),
|
||||||
|
output: "fine".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1459,6 +1497,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "toolu_05".to_string(),
|
id: "toolu_05".to_string(),
|
||||||
output: "took a screenshot".to_string(),
|
output: "took a screenshot".to_string(),
|
||||||
|
is_error: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ impl EchoDriver {
|
|||||||
self.emit(Event::ToolEnd {
|
self.emit(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("{label} step {index} finished"),
|
output: format!("{label} step {index} finished"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +646,7 @@ impl EchoDriver {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("call {i} finished"),
|
output: format!("call {i} finished"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
finish();
|
finish();
|
||||||
@@ -698,6 +700,7 @@ impl EchoDriver {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("ran: {command}"),
|
output: format!("ran: {command}"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +720,7 @@ impl EchoDriver {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("echoed: {input}"),
|
output: format!("echoed: {input}"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,6 +821,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A run of three, which the app folds into one collapsed group -- the
|
// A run of three, which the app folds into one collapsed group -- the
|
||||||
@@ -829,9 +834,20 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
||||||
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
||||||
});
|
});
|
||||||
|
// The middle one fails, so this fixture carries a run in
|
||||||
|
// which the three calls are not all in the same state --
|
||||||
|
// the case a card drawing every finished call the same way
|
||||||
|
// looks correct on. `is_error` is the CLI's own field
|
||||||
|
// (`import::tool_result_is_error`), and a driver that
|
||||||
|
// never sets it makes the failed appearance unreachable
|
||||||
|
// from the sandbox.
|
||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("beat {beat}, call {i} of 3"),
|
output: match i == 2 {
|
||||||
|
true => format!("beat {beat}, call {i} of 3: No such file or directory"),
|
||||||
|
false => format!("beat {beat}, call {i} of 3"),
|
||||||
|
},
|
||||||
|
is_error: i == 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,6 +872,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("beat {beat}: captured"),
|
output: format!("beat {beat}: captured"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Somebody else's voice, which is its own row shape.
|
// Somebody else's voice, which is its own row shape.
|
||||||
@@ -904,6 +921,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: tool_id,
|
id: tool_id,
|
||||||
output: "helper done".to_string(),
|
output: "helper done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let target = Duration::from_secs(3);
|
let target = Duration::from_secs(3);
|
||||||
@@ -915,6 +933,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
|||||||
let _ = sink.send(Event::ToolEnd {
|
let _ = sink.send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: "subagent finished".to_string(),
|
output: "subagent finished".to_string(),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,6 +1088,7 @@ impl Driver for EchoDriver {
|
|||||||
self.emit(Event::ToolEnd {
|
self.emit(Event::ToolEnd {
|
||||||
id: call,
|
id: call,
|
||||||
output: format!("answered: {answer}"),
|
output: format!("answered: {answer}"),
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
// The work carries on where it left off, which is what makes the
|
// The work carries on where it left off, which is what makes the
|
||||||
// asked-here row a boundary with a group on each side rather than
|
// asked-here row a boundary with a group on each side rather than
|
||||||
|
|||||||
@@ -363,6 +363,22 @@ fn is_hidden(record: &Value) -> bool {
|
|||||||
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a `tool_result` block says the call itself failed.
|
||||||
|
///
|
||||||
|
/// One reader for the field rather than one per caller: the live
|
||||||
|
/// translator (`translate.rs`) and this replay of the CLI's own file look
|
||||||
|
/// at the same block shape, and a call drawn as failed in one and as
|
||||||
|
/// succeeded in the other would be the same conversation disagreeing with
|
||||||
|
/// itself. Absent means "not reported to have failed" -- which is what the
|
||||||
|
/// CLI writes for a call that went fine, and also what every transcript
|
||||||
|
/// written before this field was read says.
|
||||||
|
pub(crate) fn tool_result_is_error(block: &Value) -> bool {
|
||||||
|
block
|
||||||
|
.get("is_error")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn text_of(content: &Value) -> String {
|
fn text_of(content: &Value) -> String {
|
||||||
match content {
|
match content {
|
||||||
Value::String(text) => text.clone(),
|
Value::String(text) => text.clone(),
|
||||||
@@ -549,6 +565,7 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
|||||||
events.push(Event::ToolEnd {
|
events.push(Event::ToolEnd {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
output: text_of(block.get("content").unwrap_or(&Value::Null)),
|
output: text_of(block.get("content").unwrap_or(&Value::Null)),
|
||||||
|
is_error: tool_result_is_error(block),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "t1".into(),
|
id: "t1".into(),
|
||||||
output: "done".into(),
|
output: "done".into(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
Event::Image {
|
Event::Image {
|
||||||
image: "img1".into(),
|
image: "img1".into(),
|
||||||
|
|||||||