Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 | ||
|
|
03c6be80a3 | ||
|
|
4afc453faa | ||
|
|
1aab61bf26 | ||
|
|
dc01f88d75 | ||
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 | ||
|
|
560a74caf8 | ||
|
|
fd7e17523d | ||
|
|
c7682297fa | ||
|
|
27511302f2 | ||
|
|
184a6c5b33 |
No files matched your search
@@ -17,7 +17,12 @@ edition = "2024"
|
||||
[dependencies]
|
||||
event-model = { path = "../event-model" }
|
||||
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.
|
||||
# `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
|
||||
|
||||
+67
-1
@@ -10,6 +10,7 @@
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use event_model::SeqEvent;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -116,6 +117,14 @@ impl<T: Transport> ApiClient<T> {
|
||||
Self { transport }
|
||||
}
|
||||
|
||||
/// The transport underneath, for a caller that needs the raw SSE
|
||||
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||
/// is the one that does.
|
||||
pub fn transport(&self) -> &T {
|
||||
&self.transport
|
||||
}
|
||||
|
||||
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -266,6 +275,61 @@ impl<T: Transport> ApiClient<T> {
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
) -> Result<Vec<Value>, ApiError> {
|
||||
self.json_request(
|
||||
"GET",
|
||||
&transcript_path(session_id, before, limit, coalesce, None),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of transcript history, each line handed back paired with the
|
||||
/// exact text it came from, and bounded below by `after` -- the shape
|
||||
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
||||
/// fetched in the transcript cache without a second round trip to fetch
|
||||
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||
///
|
||||
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||
/// sent (key order and float literal included) rather than this
|
||||
/// crate's own idea of how to write them back out -- the cache and a
|
||||
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||
/// exactly what caught the `serde_json` float-rounding bug this
|
||||
/// project's `AGENTS.md` records.
|
||||
pub fn fetch_transcript_lines(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||
raw.into_iter()
|
||||
.map(|value| {
|
||||
let line = value.get().to_string();
|
||||
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||
message: format!(
|
||||
"the server sent a transcript line this build couldn't parse: {e}"
|
||||
),
|
||||
status: None,
|
||||
})?;
|
||||
Ok((line, event))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||
/// parameter is written rather than keeping two copies to drift.
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> String {
|
||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||
if let Some(before) = before {
|
||||
path.push_str(&format!("&before={before}"));
|
||||
@@ -273,8 +337,10 @@ impl<T: Transport> ApiClient<T> {
|
||||
if coalesce {
|
||||
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/`
|
||||
|
||||
@@ -11,5 +11,6 @@ pub mod notifications;
|
||||
pub mod sse;
|
||||
pub mod transcript_cache;
|
||||
pub mod transcript_fold;
|
||||
pub mod transcript_source;
|
||||
|
||||
pub use event_model::*;
|
||||
@@ -361,6 +361,10 @@ impl SessionCache {
|
||||
{
|
||||
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)?;
|
||||
let kind = if rows { "rows" } else { "raw" };
|
||||
let mut content = lines.join("\n");
|
||||
@@ -389,8 +393,16 @@ impl SessionCache {
|
||||
return Ok(());
|
||||
};
|
||||
// Written as it arrived. A newline inside it would split one
|
||||
// event into two unreadable halves, but neither source can
|
||||
// produce one.
|
||||
// event into two unreadable halves. No source here can produce
|
||||
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
||||
// fetched line is one element of a compact JSON array -- but
|
||||
// that is a fact about the *server's* serializer rather than
|
||||
// anything this file controls, so it is checked rather than
|
||||
// trusted.
|
||||
debug_assert!(
|
||||
!line.contains('\n'),
|
||||
"a cached transcript line must be one line: {line}"
|
||||
);
|
||||
use std::io::Write;
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
|
||||
@@ -294,6 +294,199 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
||||
out
|
||||
}
|
||||
|
||||
/// Puts a page of older items in front of the ones already loaded, healing
|
||||
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
|
||||
/// `joinPages`.
|
||||
///
|
||||
/// Two things straddle a boundary: a tool call separated from its result,
|
||||
/// and a message separated from the rest of itself. Both were one thing
|
||||
/// before the transcript was cut into pages.
|
||||
///
|
||||
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||
/// -- 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,
|
||||
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,
|
||||
// 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
|
||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||
@@ -955,4 +1148,134 @@ mod tests {
|
||||
let err = fold_page(&values).unwrap_err();
|
||||
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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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,
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
+84
-17
@@ -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) |
|
||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||
| `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 |
|
||||
| *(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 |
|
||||
|
||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||
cases ported alongside it, plus new tests for the pieces that had none
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
|
||||
`transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
|
||||
had no JVM unit tests of their own, so these were written fresh against the
|
||||
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
|
||||
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
|
||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||
them via `session::transcript`'s round-trip coverage).
|
||||
|
||||
@@ -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.
|
||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||
|
||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
||||
merges a tool call split across two fetched pages and re-merges a run a
|
||||
boundary cut through. This matters the moment paging backward through
|
||||
history is exercised; it is deliberately left rather than rushed, since
|
||||
it is exactly the kind of boundary logic this project's own "things that
|
||||
have bitten" section warns reads fine and is wrong at the edges.
|
||||
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
|
||||
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
|
||||
call split across two fetched pages, rejoins a message a boundary cut
|
||||
through, and renames a run of tool calls onto whichever name is already on
|
||||
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
|
||||
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
|
||||
own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||||
is the regression test for the bug that shipped -- `adopt_run` must run on
|
||||
*every* join, not only the one where a split call was found, or a boundary
|
||||
landing cleanly between two already-finished calls (most of them) leaves
|
||||
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
||||
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
||||
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
||||
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||
that no tool id survives in both halves -- the duplicate row it exists to
|
||||
prevent, checked rather than assumed. What it deliberately does *not*
|
||||
assert is seq ordering across the boundary: a peer note carries the seq
|
||||
its turn began at (`place_peer_note`), which can be older than the page
|
||||
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||
earlier draft asserted it and would have panicked in debug builds on an
|
||||
ordinary transcript.
|
||||
|
||||
**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.
|
||||
@@ -119,12 +136,61 @@ 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
|
||||
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
|
||||
|
||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||
from the transcript cache or the server, and stitches the two. Needs
|
||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
||||
which exist now, so this is unblocked whenever picked up.
|
||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
||||
colours a `.md` file or fence for the highlighter, but does not build the
|
||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
||||
@@ -142,5 +208,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`
|
||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||
same way it always has). From `client-core/` directly: `cargo test`,
|
||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
||||
same way it always has). From `client-core/` directly: `cargo test`
|
||||
(109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
||||
this writing (2026-09-06).
|
||||
+167
@@ -8,6 +8,62 @@ 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
|
||||
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")
|
||||
|
||||
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
||||
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
||||
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
|
||||
benchmark reads to report fling travel. Note the anchor does not
|
||||
necessarily change *slot* over a long scroll (this widget's own documented
|
||||
design: the anchor is a stable identity, not re-derived from what's on
|
||||
screen each frame), so this is not the same measurement as a Compose
|
||||
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
|
||||
visible row -- the `off` half is what actually reflects how far a fling
|
||||
travelled.
|
||||
|
||||
`iris_core::render::frame_report::FrameReport` gained three methods for
|
||||
per-phase benchmark reporting: `mark_phase(name)` records a named phase
|
||||
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
|
||||
returns one `PhaseStats` (frames, wall duration, late count/percent,
|
||||
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
|
||||
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
|
||||
late count/percent judged against an arbitrary refresh rate rather than
|
||||
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
|
||||
separate method, not a parameter on `report()`, so nothing else changes
|
||||
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
|
||||
run without evicting earlier phases' samples.
|
||||
|
||||
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||||
|
||||
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||||
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||||
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||||
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||||
advances it and returns whether it is still going -- a caller that owns a
|
||||
`RequestRedraw` handle can hand it to the list once via the new
|
||||
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||||
flinging with no further polling needed; a caller driving a scripted
|
||||
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||||
drives `scroll`.
|
||||
|
||||
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||||
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||||
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||||
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||||
the same distance a Compose `LazyColumn` would for the same initial
|
||||
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||||
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||||
velocity from known samples, fling distance/duration against the closed-
|
||||
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||||
(a fling stops rather than scrolling into content that was never loaded).
|
||||
|
||||
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||||
dead on release. After: releasing mid-drag continues scrolling and
|
||||
decelerates, matching the muscle memory every other Android scroll view
|
||||
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||||
-- a release only flings if the gesture had committed to panning
|
||||
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||||
|
||||
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
||||
|
||||
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
||||
@@ -513,3 +569,114 @@ phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
|
||||
~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box
|
||||
for the full numbers and the comparison's caveats (different AVD
|
||||
instances, not a controlled A/B on identical hardware state).
|
||||
|
||||
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
|
||||
|
||||
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
|
||||
inset bugs the emulator never showed).
|
||||
|
||||
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
|
||||
italic/bold-italic static faces, OFL) and registers them ahead of the
|
||||
platform's own fonts in the `SansSerif`/`Monospace` generic-family
|
||||
lists, rather than relying on the platform's font enumeration alone.
|
||||
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
|
||||
and what each style axis resolved to — logged once at startup and shown
|
||||
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
|
||||
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
|
||||
delivered (compressed) number.
|
||||
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
|
||||
(`window_size: impl Into<Vec2>`) instead of deriving it from the
|
||||
surface's physical `SurfaceConfiguration`. Existing callers pass a
|
||||
*logical* size (physical ÷ density/scale-factor) now; this is what makes
|
||||
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
|
||||
high-density phone. Before this, `scale_factor` did not exist anywhere
|
||||
in the crate, on either platform.
|
||||
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
|
||||
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
|
||||
now divide every physical-pixel number before it reaches layout or
|
||||
touch handling — see `content_scale`'s own field doc for the full list
|
||||
of what depends on it.
|
||||
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
|
||||
a default-no-op hook called from `render()` exactly when
|
||||
`AndroidUiState::insets()` changes. Nothing previously consumed
|
||||
`insets().top` at all; a screen with chrome under the status bar
|
||||
implements this to pad it, in the same logical units `content_scale`
|
||||
converts everything else to.
|
||||
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
|
||||
on_uncaptured_error` on the Android device (wgpu's default handler is an
|
||||
unconditional panic outside `UiRenderNode::new`'s own error scopes).
|
||||
Explicit `Arc`-backed value passed to the callback and kept on
|
||||
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
|
||||
its own the same way.
|
||||
|
||||
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
|
||||
|
||||
Iris's phone report on build a9232ac (screenshots): text now the right
|
||||
size but blurry; the keyboard still wipes every glyph; the header buttons
|
||||
have nothing behind them. All three are fixed; this entry is the public
|
||||
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
|
||||
box has the full investigation and the phone verification still to do.
|
||||
|
||||
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
|
||||
on every resize**, including an IME-driven one — a fresh, empty glyph
|
||||
atlas while the CPU-side glyph cache kept UV coordinates from the old
|
||||
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
|
||||
the surface and window uniform only) when a renderer is already live,
|
||||
and only builds a new one when there genuinely isn't one yet.
|
||||
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
|
||||
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
|
||||
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
|
||||
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
|
||||
size should look the same physical size on any density. This is the
|
||||
unit IRIS_TODO.md's "density-independent length unit" item asked for;
|
||||
it replaces the previous stopgap (the whole rendered scene divided by
|
||||
`content_scale` then implicitly stretched back up), which is also what
|
||||
made text blurry — a glyph rasterised at the small, pre-stretch size and
|
||||
then upscaled onto the real framebuffer.
|
||||
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
|
||||
pixels per dp). Every place a length resolves (`Len::apply_rest`,
|
||||
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
|
||||
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
|
||||
number anywhere is unaffected — still `abs`, physical pixels.
|
||||
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
|
||||
takes `density` and multiplies `font_size`/`line_height` (and any span
|
||||
override) by it before handing them to parley, so the atlas holds a
|
||||
bitmap at the size it is actually shown at rather than a low-resolution
|
||||
one stretched afterward.
|
||||
- **Everything at the Android boundary is physical pixels now** — window
|
||||
size, touch coordinates, insets (`LogicalInsets` renamed
|
||||
`WindowInsets`). The previous "logical" division by `content_scale` is
|
||||
gone; `content_scale` now feeds `set_density` instead.
|
||||
- Not yet verified on Iris's actual phone (this pass had no device) —
|
||||
built and checked on this checkout's emulator only. RUST.md's P0 box
|
||||
says what she should check for: crisp text at two densities, the
|
||||
keyboard no longer wiping, and the header's background.
|
||||
|
||||
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
|
||||
|
||||
Three small but public API changes, from the same phone-report pass as the
|
||||
entry above (RUST.md's P0 box has the full account, including a real bug
|
||||
still not root-caused).
|
||||
|
||||
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
|
||||
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
|
||||
request the IME) on the very first frame of *any* press, before it was
|
||||
known whether the gesture was a tap or a drag — a swipe over a text
|
||||
field wrongly summoned the keyboard. They now wait for a completed tap
|
||||
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
|
||||
field is already focused, in which case dragging inside it to select
|
||||
text is unchanged. `TextEdit` gained one new `pub(crate)` field
|
||||
(`press_origin`) to track this; no public surface change there.
|
||||
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
|
||||
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
|
||||
called from `after_input`). Gboard was holding keystrokes back because
|
||||
nothing ever told it where the app's own selection/composing region had
|
||||
moved to — this is what android-view's own demo does in its `render()`
|
||||
and this bridge never did.
|
||||
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
|
||||
together, once, from `android::view`'s `surface_changed` exactly when a
|
||||
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
|
||||
not a keyboard-triggered resize, which already reuses the renderer) —
|
||||
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
|
||||
and returning to the app.
|
||||
@@ -117,6 +117,114 @@ order and what "done" looks like. Tick and date them in place.
|
||||
screen wants the same thing (P1's own transcript rows already read
|
||||
their content from a `TextEdit` for the same reason).
|
||||
|
||||
## From the phone, 2026-09-06
|
||||
|
||||
Found on Iris's own phone while working RUST.md's P0 box's phone-report
|
||||
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
||||
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||
`android/view.rs`/`android/sense.rs` changes.
|
||||
|
||||
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||
test (fling distance against the closed-form spline result, cancel-on-
|
||||
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||
that is still open.
|
||||
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||
transition rather than by an on-device trace (no emulator was used this
|
||||
pass): it was the first named suspect, not the second. `self.last` stays
|
||||
at the press origin for every `Undecided` frame (nothing pans while the
|
||||
gesture might still be a selection), so the frame that finally crosses
|
||||
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||
many frames it had taken to get there. Fixed by applying only the
|
||||
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||
touch handling follows. New regression test,
|
||||
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||
**Not yet done**: an emulator trace of the real per-frame offset
|
||||
confirming this was the whole story on real touch input rather than
|
||||
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||
calling it fully closed.
|
||||
- [x] **Composing text held back until a space, caret not moving, fixed
|
||||
2026-09-06.** `InputMethodManager.updateSelection` was never called --
|
||||
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
|
||||
full account and the emulator evidence.
|
||||
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
|
||||
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
|
||||
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
|
||||
input_method`'s `mInputShown` on the emulator, not yet on the phone.
|
||||
- [x] **Text disappears again after leaving and returning to the app,
|
||||
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
|
||||
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);
|
||||
not yet on the phone.
|
||||
- [ ] **Composed/typed text never becomes visible at all -- found
|
||||
2026-09-06, not fixed.** The composer bar stays empty even once the
|
||||
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
||||
own suggestion strip reacts correctly to each keystroke). A new unit
|
||||
test proves the widget tree's own layout math resolves the field's
|
||||
region correctly across a keyboard resize, so the bug is downstream of
|
||||
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
|
||||
or specific to this emulator's forced `force-gles` backend (untested on
|
||||
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
|
||||
writeup, what was ruled out, and where to look next. **Also unverified
|
||||
because of this**: item 3's composer rebuild (one `Stack`-based widget,
|
||||
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||
inset) -- structurally in place and unit-tested, but its own visual
|
||||
correctness cannot be screenshotted until text actually renders.
|
||||
- [ ] **The composer has no touch-drag scroll for overflowing text.** The
|
||||
2026-09-06 rebuild caps the field at ~6 lines and wraps it in
|
||||
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over
|
||||
text that has overflowed the cap does not scroll it -- `Scroll`'s touch
|
||||
handling is a follow-up, the same shape `List`'s own touch-drag pan
|
||||
needed before I3/I5.
|
||||
|
||||
## 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.
|
||||
|
||||
- [ ] **"The app definitely does not start with keyboard spacing
|
||||
correct. This is how it looks without me doing anything initially."**
|
||||
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."** A pan that starts on the composer is held by
|
||||
the composer until the finger leaves its region, then the list takes
|
||||
over. The tap-vs-swipe fix in `attr.rs` stops the *focus*, but the
|
||||
press frames are still being handled by the field rather than passed
|
||||
to the list from the first slop-crossing frame. The `DragGesture`
|
||||
merge (RUST.md's plan box) should make this one mechanism: once a
|
||||
gesture commits to a pan, the list captures it wherever it began.
|
||||
- [ ] **"Flinging still does not work."** Expected on this build: finger
|
||||
flings are dropped by per-widget hit testing, which `DragGesture`'s
|
||||
pointer capture (commit `e12c708`, not yet merged at 02:07) targets.
|
||||
Stays open until verified on her phone, not the emulator.
|
||||
- [ ] **"Text still disappears if I leave and come back to the app."**
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||
@@ -385,3 +493,53 @@ do not duplicate it there.
|
||||
redundant. Decide after the layout change lands, by writing a button
|
||||
both ways and keeping the one that is shorter to explain; delete the
|
||||
other rather than keeping two ways.
|
||||
|
||||
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
||||
|
||||
- [x] **A third length kind beside relative and pixels, so display scales
|
||||
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
|
||||
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
|
||||
additionally rasterises at the resolved (physical) size instead of
|
||||
scaling a low-resolution bitmap afterward, which was making text blurry.
|
||||
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
|
||||
too; transcript-ui's row/composer padding and one example migrated.
|
||||
`em` was not added — nothing in this pass needed a text-relative unit,
|
||||
and `dp`'s own doc says why it and physical pixels are kept as separate
|
||||
fields rather than one the caller pre-multiplies. Not yet verified on
|
||||
Iris's own phone at two densities (this pass had no device) — see
|
||||
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
|
||||
check. Iris's words: "another length type similar to absolute &
|
||||
relative, so instead there would be relative, pixels, and another unit
|
||||
like em or whatever is standard. That way different display scales
|
||||
should just work." Today a length is either a fraction of the parent
|
||||
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
|
||||
roughly a third of its intended size until the P0 fixes applied the
|
||||
display's scale factor globally. That global scale is a stopgap for the
|
||||
benchmark; the real shape is a unit resolved against the display's
|
||||
density at layout time — Android's `dp` / CSS's reference pixel is the
|
||||
standard (1 unit = 1/160 in), with `em` as the text-relative option —
|
||||
so a widget author writes `16.dp()` once and never sees the scale.
|
||||
Done when: `Length` (or whatever the enum is called) has the third
|
||||
variant; every place that resolves a length takes the density; the
|
||||
examples and `transcript-ui` use the new unit for text sizes, padding
|
||||
and control sizes; the emulator at two densities and the phone draw the
|
||||
same layout at the same physical size. After the bench setup is
|
||||
finished, before P1 draws any new screen.
|
||||
|
||||
## 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
|
||||
that message.** 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
|
||||
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
|
||||
re-renders its markdown and re-shapes the entire paragraph run through
|
||||
parley on every event. Compose pays a reparse (8.6 ms mean) for the
|
||||
same event. What "done" looks like: a streamed delta re-lays out only
|
||||
the block it lands in (the last paragraph or code block), with earlier
|
||||
blocks' layouts kept -- which needs a row to be a column of per-block
|
||||
`Text`s rather than one `TextEdit` for the whole message, or parley's
|
||||
layout to be split at block boundaries; measured by the stream phase's
|
||||
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
|
||||
closed, since they are what make the run unrepresentative today.
|
||||
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
|
||||
conditions, so the remaining slack was accepted rather than chased
|
||||
further.
|
||||
|
||||
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||
|
||||
Iris asked for a third length kind beside `abs` (physical pixels) and
|
||||
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
||||
independent length unit" — after the P0 phone pass found 16px text
|
||||
drawing at roughly a third size on a real phone. The fix that shipped
|
||||
first (RUST.md's P0 box) was a global stopgap: divide the whole window
|
||||
into a "logical" coordinate space (physical ÷ `content_scale`) and let
|
||||
the shader's NDC mapping stretch it back up onto the real framebuffer.
|
||||
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
|
||||
small, pre-stretch size and then stretched onto more physical pixels than
|
||||
it has texels for is blurry, which is exactly what Iris's next report
|
||||
said.
|
||||
|
||||
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
|
||||
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
|
||||
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
|
||||
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
|
||||
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
|
||||
desktop backend has no per-monitor density wired up yet and stays at
|
||||
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
||||
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
|
||||
`UiRenderState::reposition` itself). This also meant the Android
|
||||
boundary's global logical-space stopgap could come out entirely: window
|
||||
size, touch coordinates and insets are physical pixels again, matching
|
||||
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
||||
per-length work the global divide used to do for everything at once.
|
||||
|
||||
**Text is the case that needed more than the `Len` plumbing.** A widget's
|
||||
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
|
||||
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
|
||||
shape` now takes `density` directly and multiplies `font_size`/
|
||||
`line_height` (and any span override) by it before handing them to
|
||||
parley — so the size that reaches both the line-breaker and the
|
||||
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
|
||||
the display's *physical* size, and the glyph atlas holds a bitmap at the
|
||||
resolution it is actually shown at. `GlyphKey.size` already keys on the
|
||||
resolved size, so a cache entry is naturally per-physical-size with no
|
||||
further change. The one caller with no `Painter` to read density from
|
||||
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
|
||||
copy kept directly on `TextData` (`TextData::density`) instead — an
|
||||
accepted duplication rather than threading a `Painter` into every input
|
||||
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
|
||||
already makes for the Diagnostics page.
|
||||
|
||||
**What did not change**: `rel`/`rest` are unaffected (already
|
||||
resolution-independent, a fraction of the parent). `Span::gap` and
|
||||
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
|
||||
on them the same as any other size; a bare number is still `abs`,
|
||||
physical pixels, unchanged.
|
||||
|
||||
## For IRIS.md
|
||||
|
||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||
|
||||
+678
@@ -34,6 +34,87 @@ the emulator, not by Mesa" and "the present mode was not the cause" are
|
||||
worth as much as the successes, because they are what stops the next
|
||||
session spending an afternoon on them again.
|
||||
|
||||
## Where things stand (2026-09-06, orchestrator plan)
|
||||
|
||||
Written by the design agent on picking the branch up after a `/clear`, so
|
||||
the next session can resume from here. P0 is delivered and Iris's phone
|
||||
report v2 is in (`docs/bench/iris-phone-v2-2026-09-06.md`); **P1 stays
|
||||
gated on her verdict**, so this pass works the P0 defects and the pure
|
||||
prerequisites in this order. Each item is ticked here by the agent that
|
||||
closes it.
|
||||
|
||||
- [ ] **Merge the `DragGesture` work** left complete but unmerged in the
|
||||
worktree branch `worktree-agent-a754368325fa06839` (commit
|
||||
`e12c708`, 2026-09-06 02:10, two minutes after the last merge to
|
||||
`rustify`; already contains `rustify` at `543f6d9`). It targets two
|
||||
of the four bench-v2 defects: finger flings dropped by per-widget
|
||||
hit testing (pointer capture + `CursorSense::Drop`), and IME insets
|
||||
never redelivered (`MainActivity.java` edge-to-edge). Before
|
||||
merging: build, clippy, tests; then on the emulator confirm the
|
||||
three things `20b1225` (tap-vs-swipe focus) and the phone asked for
|
||||
still hold together — a swipe over the composer does not summon
|
||||
the keyboard, a tap does, a finger fling on the list keeps moving
|
||||
after the finger lifts, and `on_insets_changed` now fires on an
|
||||
IME toggle. Then remove the worktree.
|
||||
- [ ] **Fix `docs/REVIEW-2026-09-06.md`** (after the merge, since the
|
||||
review's finding 1 is in `selection.rs`, which the merge rewrites).
|
||||
Finding 1 is a real crash — `TranscriptScreen::apply`'s `Rebuild`
|
||||
arm leaves `Selection` holding `WeakWidget`s to freed rows, and the
|
||||
next tap anywhere panics. Findings 2–5 are `debug_assert!`s on
|
||||
invariants, 8 and 10 are the missing tests. Commit the review file
|
||||
with the fixes.
|
||||
- [ ] **Iris's 11:39 phone report on the 02:07 build** (four items,
|
||||
verbatim in `IRIS_TODO.md`'s "From the phone, 2026-09-06, 11:39"):
|
||||
composer floating two thirds down the screen at launch with black
|
||||
below it; a swipe starting on the composer held until the finger
|
||||
leaves it; no fling (expected, `DragGesture` unmerged); text still
|
||||
lost on app-switch on the phone despite the emulator-verified
|
||||
atlas reset. The first and last are the same class as the next
|
||||
box and go to that agent; the middle two are the merge box's.
|
||||
- [ ] **Stale primitives and invisible composer text** — the header drawn
|
||||
twice after a keyboard resize, the `Compacted:` row drawn twice on
|
||||
Iris's phone, and typed text never appearing (P0 box item 2). All
|
||||
three sit on the `redraw_updates` targeted-redraw path and may be
|
||||
one bug; the P0 box says the next step is instrumentation inside
|
||||
`Span::draw`/`draw_inner` showing where each placement's
|
||||
primitives actually land on the frame it goes wrong.
|
||||
`list.rs`'s new `replacing_the_last_row_many_times_does_not_leak_
|
||||
primitives` test already pins the widget arena as *not* the leak.
|
||||
- [ ] **Composer touch-drag scroll** for overflowed text — now that
|
||||
dragging is a default-input `DragGesture`, `Scroll` should get its
|
||||
touch pan from the same mechanism `List` uses, not a copy.
|
||||
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
|
||||
above, since they make the stream phase unrepresentative today.
|
||||
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
|
||||
disjoint from `iris/`), closed 2026-09-06: `TranscriptSource`'s
|
||||
cache-vs-server stitching (new `client-core/src/transcript_source.rs`)
|
||||
and `joinPages`/`healSplitMessage`/`adoptRun` page-boundary healing
|
||||
(new functions in `transcript_fold.rs`), per `CLIENT_CORE.md`. Ported
|
||||
against the Kotlin source and AGENTS.md's paging incidents as the
|
||||
spec (`TranscriptSource.kt`/`TranscriptItems.kt` had no JVM unit
|
||||
tests of their own to port test-for-test). `client-core` goes from
|
||||
85 to 109 tests; `cargo test`/`clippy --all-targets`/`fmt` all clean.
|
||||
Both AGENTS.md regressions have a dedicated test: `loadOlderPage`'s
|
||||
`before == 0` guard moved into `TranscriptSource::page` itself
|
||||
(`paging_before_the_first_event_makes_no_request_at_all` asserts
|
||||
zero transport calls, not just an empty result), and
|
||||
`a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||||
pins `adopt_run` running on *every* join rather than only the
|
||||
split-call path. One incidental fix needed to port `TranscriptSource`
|
||||
faithfully: `api.rs` gained `fetch_transcript_lines` (additive, the
|
||||
existing `fetch_transcript_page` untouched since `iris/` depends on
|
||||
its signature), which pairs each event with the exact server bytes
|
||||
it came from via `serde_json::value::RawValue` rather than
|
||||
re-serializing a parsed `Value` -- needed so the cache and a live SSE
|
||||
frame agree byte-for-byte, the same class of bug as the
|
||||
`float_roundtrip` fix. Deliberately not ported: `EventStream.kt`'s
|
||||
reconnect/backoff and cross-thread stream cancellation, which are
|
||||
runtime policy for whichever framework embeds this crate, not pure
|
||||
logic -- see `CLIENT_CORE.md`'s new section for the full account.
|
||||
- **Then**: redeliver `~/host/bench/iris-bench-arm64.apk` for Iris with
|
||||
its README saying what changed, and record any choice she should see in
|
||||
`DECISIONS.md`.
|
||||
|
||||
## Where things stand (2026-09-05)
|
||||
|
||||
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
|
||||
@@ -4315,6 +4396,603 @@ device.
|
||||
confirming the phase marks partition the whole run rather than
|
||||
overlapping or dropping frames between them.
|
||||
|
||||
**Iris's first real phone report, 2026-09-06** (the redelivered,
|
||||
no-`force-gles` APK above): no crash. Two screenshots, before any
|
||||
touch: headings/links/code/table all render correctly. Four defects
|
||||
found and worked this pass:
|
||||
|
||||
1. **Every glyph disappears on the first tap or scroll; rectangles
|
||||
stay drawn** (the keyboard case is the same thing -- a tap on the
|
||||
composer). **Not root-caused this pass.** Audited `GpuTextures`'
|
||||
atlas-grow/patch path, `ArrBuf`'s resize-on-length-change
|
||||
contract, and the masks/move_offsets/rsc bind-group rebuild logic
|
||||
in `core/src/render/mod.rs` against wgpu's queue-ordering
|
||||
contract -- everything read as spec-correct (a `queue.write_texture`/
|
||||
`write_buffer` issued before a later `queue.submit` is guaranteed
|
||||
visible to it on the same queue, and a dropped `Buffer`/`Texture`/
|
||||
`BindGroup` still in flight is kept alive by wgpu's own tracker).
|
||||
No violation found by static reading; reproducing needs either
|
||||
the phone or a Mali driver trace, neither available this pass.
|
||||
Instrumented for the next report instead: `Device::
|
||||
on_uncaptured_error` is now installed on the Android device
|
||||
(`WgpuErrorLog`, `android::render::AndroidRenderer`), and
|
||||
`IrisViewPeer::render` logs masks/moves-resized, atlas
|
||||
pages-grown and image bind-group creates for the first 10 frames
|
||||
after every `surface_changed` -- exactly the window this bug
|
||||
lands in. The bench screen's new "Diagnostics" button (below)
|
||||
surfaces the error log and adapter identity on demand.
|
||||
2. **Bold words render as blank gaps of the correct advance width**
|
||||
(regular, links, inline code render fine). Fixed by bundling Noto
|
||||
Sans/Noto Sans Mono (regular/bold/italic/bold-italic, static
|
||||
cuts, OFL) into `iris-core` and registering them ahead of the
|
||||
platform's own fonts -- `core/src/primitive/text.rs`'s
|
||||
`TextData::register_bundled_fonts`. Named hypothesis, not
|
||||
confirmed on the phone: the system "Roboto" on a modern Android
|
||||
device is the variable "Roboto Flex," and this crate's glyph
|
||||
path (`TextData::place`) does not apply `Synthesis`/variable-axis
|
||||
correction at all -- a bundled *static* per-style face sidesteps
|
||||
the question rather than answering it. `TextData::font_diagnostics`
|
||||
reports what got resolved; logged once at startup and shown on
|
||||
the Diagnostics page.
|
||||
3. **Text far too small** -- iris had no device-pixel-ratio handling
|
||||
on *either* platform before this pass (grepped for `scale_factor`
|
||||
across the whole crate: zero hits). `DisplayMetrics.density`
|
||||
(Android) / `Window::scale_factor()` (desktop) now divides every
|
||||
physical-pixel number (window size, touch coordinates, the
|
||||
shader's window uniform) down to logical units before it reaches
|
||||
layout, so a `font_size: 16.0` is 16 dp rather than 16 raw device
|
||||
pixels on a ~3x-density phone. Cost a second, real bug found only
|
||||
by measuring on this checkout's emulator after the first fix
|
||||
landed: `android::view::IrisViewPeer::surface_changed`'s call
|
||||
into `UiRenderState::resize` (the layout engine's own notion of
|
||||
the canvas, which every widget's absolute `PixelRegion` is
|
||||
computed against) was still being handed raw physical
|
||||
`width`/`height`, while `AndroidRenderer`'s side of the same
|
||||
resize had already switched to logical -- splitting layout and
|
||||
the shader into two different units. A fixed-size widget (the
|
||||
bench screen's `.height(56)` button row) exposed it at ~40
|
||||
physical px against the ~147px `56 * content_scale` predicts;
|
||||
a proportional (`rest(n)`) size hid it by adapting to whichever
|
||||
total it was given. Both are logical now. **Not fully verified**:
|
||||
a fresh-install emulator screenshot after both fixes shows
|
||||
visibly larger, readable text (`docs/bench/` has neither
|
||||
screenshot committed -- see AGENTS.md on transcripts/screenshots
|
||||
not going in this repo -- but the before/after is described in
|
||||
the commit), and the button row's own height still isn't
|
||||
obviously matching `56 * content_scale` on this run -- worth a
|
||||
second look with `ui-trace show --field box` once there's time,
|
||||
but not a blocker for the magnitude of the original bug (3x too
|
||||
small).
|
||||
4. **Status-bar inset not applied** -- confirmed nothing in this
|
||||
app ever read `insets().top` at all (`android/insets.rs` has
|
||||
carried `Insets.top` since it was written; nothing consumed it).
|
||||
Fixed with a new, generic hook: `AndroidAppState::
|
||||
on_insets_changed(rsc, LogicalInsets)`, called from `render()`
|
||||
exactly when `AndroidUiState::insets()` changes, in logical units
|
||||
matching everything else `content_scale` now divides.
|
||||
`BenchClient::on_insets_changed` rebuilds the root tree with
|
||||
`Padding::top(insets.top)` on the button row -- rebuilding the
|
||||
whole tree rather than one `WidgetPtr` slot's content, because
|
||||
the first attempt (a `Pad` dropped into an unrelated `WidgetPtr`
|
||||
slot with no height override of its own) did not propagate the
|
||||
wrapped span's fixed height correctly, which is what surfaced
|
||||
finding 3's `UiRenderState::resize` bug in the first place.
|
||||
Verified via `ui-trace show --field box`: the button row's top
|
||||
(150 physical px) sits 8px below `statusBarBackground`'s bottom
|
||||
edge (142px) on this checkout's emulator.
|
||||
|
||||
**A named `Diagnostics` control now exists** (RUST.md's own earlier
|
||||
ask): a third button on the bench screen's top row, filling the
|
||||
existing benchmark-report `TextEdit` with adapter identity/backend/
|
||||
driver, font resolution, the atlas's live view count, every
|
||||
uncaptured wgpu error since surface creation, and the frame report
|
||||
-- `android::render::AndroidRenderer::diagnostics_report`. Uses the
|
||||
existing "Copy report" button/clipboard path rather than a second
|
||||
one.
|
||||
|
||||
**Verified this pass, this checkout's emulator** (`EMU_GPU` default,
|
||||
`--features force-gles` -- this cold `emu up` again enumerated zero
|
||||
Vulkan adapters, the same pre-existing flakiness earlier boxes
|
||||
documented, not something this pass's diff caused): `cargo fmt --all
|
||||
-- --check`, `cargo clippy --workspace --all-targets` (zero warnings
|
||||
beyond the pre-existing wgpu future-incompat notice), `cargo test
|
||||
--workspace` (all passing, unchanged pure-logic counts), `cargo ndk
|
||||
-t x86_64 -P 26 check` clean, `./run-bench.sh` end to end
|
||||
(`frames=691`, 24/24 swipes, 400/400 streamed events, no crash),
|
||||
fresh-install screenshots and `ui-trace` box readouts for the four
|
||||
items above. **Not verified this pass**: the actual phone (no
|
||||
access), and item 1's root cause (needs either the phone's next
|
||||
Diagnostics-page report or a Mali trace).
|
||||
|
||||
**Recorded but not fixed this pass** (a follow-up agent takes these,
|
||||
to avoid colliding with this pass's `bench_client.rs`/`view.rs`
|
||||
changes) -- see `IRIS_TODO.md`'s "From the phone, 2026-09-06":
|
||||
swiping has no momentum (stops exactly where the finger releases,
|
||||
unlike Compose's fling), and scrolling down sometimes jitters the
|
||||
text.
|
||||
|
||||
**Redelivered, 2026-09-06, later the same day.** New arm64 APK
|
||||
(Vulkan, no `force-gles`, bundled fonts, content-scale fix,
|
||||
Diagnostics control), same `dev.iris.android.demo.bench` id, same
|
||||
`CN=ai-app` signing cert, copied to `~/host/bench/
|
||||
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
|
||||
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
|
||||
dated entry. Still not confirmed on Iris's actual phone.
|
||||
|
||||
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
|
||||
build a9232ac, with screenshots: text now the right size but
|
||||
**blurry**; opening the keyboard still **wipes every glyph**
|
||||
(rects stay, only text disappears); the **header buttons have
|
||||
nothing behind them and overlap the transcript text**.
|
||||
|
||||
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
|
||||
by reading the path before changing anything, per AGENTS.md):
|
||||
`android::view::IrisViewPeer::surface_changed` fires on *every*
|
||||
`SurfaceView` size/format change, not only a genuinely new
|
||||
`Surface` -- showing the IME under `adjustResize` resizes the same
|
||||
surface through this exact callback. The handler unconditionally
|
||||
set `renderer = None` and called `AndroidRenderer::new`, which
|
||||
builds a fresh, empty glyph atlas and fresh GPU buffers via
|
||||
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
|
||||
(`primitive/text.rs`) kept the atlas UV coordinates it had already
|
||||
handed out against the *old* atlas -- every glyph then drew from a
|
||||
rectangle pointing into a texture that had just been recreated
|
||||
empty. Confirmed by reading `AndroidRenderer::resize` (already
|
||||
existed, already did none of that -- only `surface.configure` and
|
||||
the window uniform) against what `surface_changed` was actually
|
||||
calling instead. **Fix**: `surface_changed` now calls
|
||||
`AndroidRenderer::resize` when a renderer is already live, and only
|
||||
builds a new one when `surface_changed` finds `renderer` still
|
||||
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
|
||||
backgrounding). Not independently re-verified against a forced IME
|
||||
resize on this pass's emulator (no display keyboard exercised
|
||||
end-to-end here); the reasoning is a direct code read plus the
|
||||
existing `resize` path already being surface-only, not a
|
||||
screenshot diff -- **the next agent with emulator time should do
|
||||
the before/after screenshot this box originally asked for.**
|
||||
|
||||
**2. The blur.** Root cause: the P0 fix that made text the right
|
||||
*size* (dividing the whole window into a "logical" space, then
|
||||
letting the shader's NDC mapping stretch it back onto the real
|
||||
framebuffer) rasterised each glyph at the small, pre-stretch size
|
||||
and then displayed it stretched onto more physical pixels than it
|
||||
had texels for. **Fix, and the density-independent length unit
|
||||
Iris asked for the same day (IRIS_TODO.md) turned out to be the
|
||||
same fix**: `Len::dp`, resolved against a `density` now carried on
|
||||
`UiRenderState`/`Painter`, replaces the global stretch -- window
|
||||
size, touch and insets are physical pixels throughout again
|
||||
(`WindowInsets`, renamed from `LogicalInsets`), and
|
||||
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
|
||||
before handing them to parley, so the atlas rasterises at the
|
||||
display's real physical resolution. Full design in docs/LAYOUT.md's
|
||||
"Density: `Len::dp`" section and the public-API summary in
|
||||
docs/IRIS.md's 2026-09-06 entry.
|
||||
|
||||
**3. The header.** Only each button's own `rect(...)` painted
|
||||
anything, so the gaps between/around them and the status-bar strip
|
||||
above showed `CLEAR_COLOR` (black) one layer back, and the row's
|
||||
reserved height was three `abs` (now-physical-pixel) button boxes
|
||||
-- smaller than the dp-correct size the transcript below uses,
|
||||
which is what read as "overlap" once the two disagreed. Fixed with
|
||||
a `HEADER_SURFACE` rect stacked behind the whole row and every
|
||||
header size moved onto `dp(...)`.
|
||||
|
||||
**4. Keyboard diagnostics, so Iris can report back even if a
|
||||
keyboard-triggered regression persists.** `on_insets_changed` now
|
||||
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
|
||||
the same report the on-screen Diagnostics button produces, logging
|
||||
it, copying it to the clipboard unprompted, and showing it in a new
|
||||
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
|
||||
that draws independently of iris's own renderer.
|
||||
|
||||
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
|
||||
--all-targets` and `cargo clippy` on `android-app` (both `-D
|
||||
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
|
||||
and wgpu future-incompat notices), `cargo test --workspace` (all
|
||||
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
|
||||
`transcript-screen bench` feature set.
|
||||
|
||||
**Then run on this checkout's own emulator** (x86_64 debug,
|
||||
`--features "transcript-screen force-gles bench"` -- this AVD has no
|
||||
Vulkan adapter under a plain `-gpu host` boot, matching every prior
|
||||
emulator finding in this file): `run-bench.sh` end to end, no crash,
|
||||
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
|
||||
streamed events -- unchanged in shape from prior readings, so the
|
||||
diff cost nothing on the success path. **Header background**:
|
||||
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
|
||||
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
|
||||
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
|
||||
size 1080x1900` (screenshot before/after, text intact) and actually
|
||||
opening the soft keyboard via `settings put secure
|
||||
show_ime_with_hard_keyboard 1` + tapping the message field
|
||||
(ui-trace confirmed a real resize, elements moved -547px; keyboard
|
||||
visible in the screenshot, text still fully rendered, not wiped).
|
||||
Both are real evidence the reuse-renderer fix works, though neither
|
||||
is the literal before/after diff this box originally asked for --
|
||||
**still worth a deliberate side-by-side screenshot pair in a future
|
||||
pass.**
|
||||
|
||||
**Found during this same verification, not fixed, needs a follow-up
|
||||
pass**: after the keyboard-triggered resize, the top button row
|
||||
appeared to render a **second time**, well below its real position,
|
||||
inside the transcript's scroll area (same colours/text, unmistakably
|
||||
the same three buttons) -- and a tap aimed at the composer's
|
||||
"Message" field landed on "Run benchmark" instead (a second
|
||||
benchmark run started, visible in logcat as two `iris bench report:`
|
||||
lines from one session). Only seen after a resize with the keyboard
|
||||
genuinely open; the plain `wm size` resize screenshot pair did not
|
||||
show it, nor did the fresh-install screenshot before either resize.
|
||||
**Not root-caused this pass** -- time ran out before isolating
|
||||
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
|
||||
provisional-then-real placement) leaving a phase-1 primitive
|
||||
retained somewhere it should have been moved from, something
|
||||
specific to the keyboard's `on_insets_changed` rebuild racing a
|
||||
redraw, or unrelated to this pass's changes entirely (not verified
|
||||
against a build predating this session's commits, so do not treat
|
||||
"caused by this pass" as established -- MACHINE.md's pinned rule
|
||||
about not attributing without measuring applies here too). Also
|
||||
noteworthy: `capture_keyboard_diagnostics` never fired in this
|
||||
session (no "iris keyboard diagnostics" log line) despite the
|
||||
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
|
||||
not be populated the way expected on this emulator/API level, or
|
||||
the duplicate-row state above interfered; **also needs a follow-up
|
||||
pass** before relying on the auto-capture on a real phone.
|
||||
|
||||
**Not verified this pass**: anything on Iris's real phone, the
|
||||
two-density crispness check IRIS_TODO.md's unit item asks for, and
|
||||
the two open items just above.
|
||||
|
||||
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
|
||||
phone" items this box's own text names as follow-ups are fixed --
|
||||
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
|
||||
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
|
||||
applying only the excess past `DRAG_SLOP` on the crossing frame,
|
||||
not the whole pre-threshold drag) -- both wired through
|
||||
`Selection::drag`'s release path, both covered by new unit tests in
|
||||
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
|
||||
reading `DragArbiter::update` and testing it directly, not by an
|
||||
emulator trace** -- this pass did not open an emulator, so the
|
||||
"trace the list's offset per frame" verification this box's own
|
||||
todo asked for is still open, as is a feel-check of the fling on
|
||||
real touch input.
|
||||
|
||||
**Benchmark v2, iris half, done 2026-09-06, later the same day.**
|
||||
`bench_client.rs` implements all four phases against the identical
|
||||
constants this box's "Benchmark v2" spec names: fling (8 out + 8
|
||||
back at 12,000px/s through `List::fling`, waiting for
|
||||
`!is_scrolling()` capped 3s with a 300ms pause between, travel
|
||||
reported as `idx=N/off=Mpx` via a new `List::anchor_position_display`
|
||||
-- note this list's anchor does not necessarily change *slot* during
|
||||
a long scroll (the module's own documented design: the anchor is
|
||||
named by identity, not re-derived from what's on screen), so an
|
||||
iris travel reading is not apples-to-apples with Compose's
|
||||
`firstVisibleItemIndex`, which does change slot -- a real difference
|
||||
in what the two numbers mean, not a bug, and worth reading `off`
|
||||
rather than `idx` when comparing runs), stream (unchanged), type
|
||||
(the exact 600-character `TYPE_TEXT` constant, verified by a unit
|
||||
test, one char per 50ms into the composer's real `TextEdit` via
|
||||
`.set()` -- the same whole-string-replace shape `BenchRun.kt`'s own
|
||||
`setComposerText` uses, not a per-character insert), and keyboard
|
||||
(5 cycles through `bench_jni.rs`'s new `show_ime`/`hide_ime`
|
||||
`InputMethodManager` calls, confirmed from `on_insets_changed`'s
|
||||
real `ime_bottom` transitions via a new `ImeState` counter rather
|
||||
than assumed from the JNI call succeeding).
|
||||
|
||||
`iris_core::render::frame_report::FrameReport` gained `mark_phase`/
|
||||
`phase_stats`/`late_at_hz` (new unit tests in `frame_report.rs`):
|
||||
phases are sliced by absolute frame index against a second ring
|
||||
(`index_ring`) alongside the existing duration ring, and late/jank
|
||||
is judged against a real Hz read from `bench_jni.rs`'s new
|
||||
`refresh_rate_hz` (`View::getDisplay().getRefreshRate()`) rather
|
||||
than the fixed 60Hz `JANK_THRESHOLD` every other caller still uses
|
||||
-- a separate method, not a parameter on the existing one, so
|
||||
nothing else in the codebase changes behaviour. `RING_CAPACITY`
|
||||
4096->16384 since one full v2 run is 3,000+ frames.
|
||||
|
||||
**A real deadlock, found and fixed while wiring this up.** Getting
|
||||
a value back out of a task spawned via `rsc.spawn_task` has no
|
||||
built-in return channel (`ctx.update`'s closures are fire-and-
|
||||
forget), so a new `read_from_state` helper sends the result through
|
||||
an `mpsc` channel and polls for it. Its first version only worked
|
||||
for the *first* call in a chain: nothing about `ctx.update` drains
|
||||
itself, so unless something calls `redraw.request_redraw()` after
|
||||
*this specific* enqueue, nothing ever runs the closure -- and every
|
||||
call after the first relied on a stale, already-fired
|
||||
`request_redraw()` from a previous step. The fix is structural:
|
||||
`read_from_state` now takes the redraw handle and calls it itself,
|
||||
immediately after enqueueing, every time.
|
||||
|
||||
**Verified end to end, this checkout's own emulator (cold `emu up`,
|
||||
`force-gles`, x86_64 -- this AVD again enumerates zero Vulkan
|
||||
adapters on a cold boot, matching every prior finding in this
|
||||
file):**
|
||||
|
||||
iris bench report
|
||||
per phase:
|
||||
fling: 1481 frames over 53.2s
|
||||
late: 158 (10.7%)
|
||||
total p50 11.2ms p90 16.9ms p99 26.5ms
|
||||
worst 43.3ms
|
||||
stream: 401 frames over 20.8s
|
||||
late: 342 (85.3%)
|
||||
total p50 26.1ms p90 49.1ms p99 57.2ms
|
||||
worst 61.3ms
|
||||
type: 1202 frames over 63.1s
|
||||
late: 89 (7.4%)
|
||||
total p50 12.8ms p90 15.1ms p99 23.3ms
|
||||
worst 26.7ms
|
||||
keyboard: 9 frames over 9.1s
|
||||
late: 2 (22.2%)
|
||||
total p50 6.3ms p90 25.8ms p99 25.8ms
|
||||
worst 25.8ms
|
||||
|
||||
frames:
|
||||
3093 frames over 146.3s at 60Hz (16.7ms budget)
|
||||
late: 591 (19.1%)
|
||||
total p50 12.4ms p90 22.2ms p99 51.2ms
|
||||
worst 61.3ms
|
||||
cpu_p50 0.7ms gpu_wait_p50 11.6ms
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1336px outward=idx=651/off=101672px end=idx=651/off=1427px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||||
process CPU time over this run: 43303ms
|
||||
peak RSS: 193152kB
|
||||
battery current: mean 900000µA over 146 samples (min 900000, max 900000)
|
||||
|
||||
Read this the same way every prior emulator smoke run in this box
|
||||
is read: software rasterisation, not a phone number, and the
|
||||
battery line is the emulator's fixed mocked-charger constant again.
|
||||
**Travel**: the `idx` stays fixed at 651 through the whole fling in
|
||||
both directions (see the `anchor_position_display` caveat above) --
|
||||
`off` is what actually moved, growing to 101,672px outward before
|
||||
the return trip brings it back near its start, which is real, large
|
||||
motion (a fast, hard fling, matching Iris's "travel way faster"
|
||||
ask), just not directly comparable to Compose's idx-188-reached
|
||||
reading from the same box's earlier v2 entry. **`keyboard: could
|
||||
not be shown`**: expected given the ime-inset finding below, not a
|
||||
new regression.
|
||||
|
||||
Redelivered: `./build-apk.sh release --abi arm64-v8a --features
|
||||
"transcript-screen bench"` (Vulkan, no `force-gles`; the x86_64
|
||||
jniLibs slice left over from emulator testing was removed first so
|
||||
the delivered APK is arm64-only, confirmed via `aapt2 dump
|
||||
badging`), `apksigner verify` shows the same `CN=ai-app` cert,
|
||||
copied to `~/host/bench/iris-bench-arm64.apk` and `~/repos/
|
||||
ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`;
|
||||
that repo's own README gained a dated entry. `run-bench.sh`
|
||||
extended for the longer run (260s poll cap, `-A 60` instead of
|
||||
`-A 6`) to fit v2's four phases.
|
||||
|
||||
**(a) The header-duplicate bug (found by a concurrent pass on this
|
||||
branch): investigated, not fixed.** Reproduced reliably
|
||||
(`ui-trace record --do "tap 'Message'"` then `adb exec-out
|
||||
screencap`): the three-button row renders a second, full copy
|
||||
inside the transcript area the moment the keyboard opens. Read
|
||||
`Span::draw`'s own two-phase placement doc (a provisional
|
||||
full-region draw to learn each child's size, then a real
|
||||
`widget_within` placement) as the most likely mechanism, since it
|
||||
is the one place in this tree that deliberately draws a widget
|
||||
twice in normal operation and relies on the two draws landing at
|
||||
the same place to stay a cheap move rather than a visible second
|
||||
copy -- and `UiRenderState::update`'s `redraw_all`-vs-
|
||||
`redraw_updates` split (LAYOUT.md) means a `.set()`-driven targeted
|
||||
redraw of just `top_bar` and a resize-driven full redraw of the
|
||||
whole tree are two structurally different code paths that could in
|
||||
principle disagree about where that widget's primitives belong on a
|
||||
frame where both fire close together. **One concrete, testable
|
||||
hypothesis was ruled out**: `on_insets_changed` rebuilding
|
||||
`top_bar` on every call, including ones only about `ime_bottom`
|
||||
(nothing to do with the header's own padding). Added a guard
|
||||
(`last_top_pad`, skips the rebuild unless `insets.top` itself
|
||||
changed) and reproduced the *exact same* duplicate afterward --
|
||||
unchanged, byte-for-byte, in the same screenshot -- so repeated
|
||||
rebuilding is not the cause; the guard is kept anyway since it is a
|
||||
real (if here insufficient) reduction in needless work. **Not
|
||||
root-caused**: doing so needs either instrumentation inside
|
||||
`Span::draw`/`draw_inner` to see the two placements' actual regions
|
||||
on the frame the bug happens, or the phone. Left for a follow-up
|
||||
pass rather than guessed at further.
|
||||
|
||||
**(b) Why the keyboard phase and the keyboard-open auto-diagnostics
|
||||
both read "not confirmed": a real, named platform interaction,
|
||||
partly fixed.** `MainActivity.java`'s manifest declares
|
||||
`windowSoftInputMode="adjustResize"` (AGENTS.md's own "Things that
|
||||
have bitten": without it the keyboard pans the window off screen
|
||||
instead of resizing it). Under `adjustResize`, `WindowInsets.
|
||||
Type.ime()`'s own inset *amount* is defined to read zero once the
|
||||
window has already resized to avoid the overlap that inset would
|
||||
otherwise describe -- confirmed by reading Android's own
|
||||
`WindowInsets` contract, not guessed at. So the numeric `ime_bottom`
|
||||
this app was reading is *structurally* never going to be positive
|
||||
here, independent of anything wrong in `iris`'s own code -- the same
|
||||
trap AGENTS.md already names for the Compose side
|
||||
(`WindowInsets.isImeVisible` "does not share the failure mode").
|
||||
**Fixed**: `MainActivity.java`'s `OnApplyWindowInsetsListener` now
|
||||
reads `insets.isVisible(WindowInsets.Type.ime())` (a boolean,
|
||||
unaffected by resize-vs-pan) and passes `1`/`0` through the
|
||||
existing `ime_bottom` JNI field instead of the always-zero numeric
|
||||
inset -- correct on its own terms, and kept, but **did not by
|
||||
itself make the keyboard phase or the auto-diagnostics fire on this
|
||||
emulator**: `logcat` shows the platform's own `InsetsController:
|
||||
show(ime(), fromIme=false)`/window-resize events happening (the
|
||||
keyboard genuinely opens, confirmed by screenshot), but no further
|
||||
`setOnApplyWindowInsetsListener` callback at all after the initial
|
||||
one at attach. Named hypothesis, not confirmed: a plain (non-edge-
|
||||
to-edge) `Activity` that has not called `WindowCompat.
|
||||
setDecorFitsSystemWindows(window, false)` may not get insets
|
||||
redelivered for a pure IME toggle handled entirely via resize --
|
||||
only the initial attach-time dispatch is guaranteed. Confirming and
|
||||
fixing that needs opting the activity into edge-to-edge, which is a
|
||||
real window-behaviour change interacting with the exact
|
||||
`adjustResize` setting AGENTS.md protects, not attempted this pass
|
||||
given the risk-to-time-remaining ratio. Both open items are
|
||||
recorded in `~/repos/ai-app-bench`'s README with today's date.
|
||||
|
||||
**Composing text, the tap-vs-swipe focus rule, and app-switch text
|
||||
loss, 2026-09-06.** Iris's report on this same dc01f88 build: typing
|
||||
doesn't enter text or move the caret until a space is hit; typed
|
||||
text doesn't visibly appear and there is empty black space below the
|
||||
composer bar; text disappears again after leaving and returning to
|
||||
the app; and (a follow-up message the same day) swiping over the
|
||||
composer bar wrongly summons the keyboard.
|
||||
|
||||
1. **The caret/composing bug's cause**: `android/ime.rs`'s
|
||||
`InputConnection` never called `InputMethodManager.updateSelection`
|
||||
after an edit -- confirmed by reading android-view's own demo
|
||||
(`~/src/android-view/demo/src/lib.rs`'s `render()`), which calls it
|
||||
every time its editor's generation changes. Without it, Gboard has
|
||||
no confirmation the app is keeping up and holds keystrokes back
|
||||
rather than trusting a screen it believes is stale -- exactly
|
||||
"doesn't enter it until I hit space." **Fix**: `IrisViewPeer::
|
||||
update_ime_selection` (new, `ime.rs`) reports the real selection
|
||||
and (an approximation, `compose_len` chars back from the caret)
|
||||
the composing region, called from `after_input`'s existing tail so
|
||||
every touch/key/IME callback already runs it. The buffer-level
|
||||
half (`replace`/`insert_str` correctly advancing the caret) was
|
||||
already correct and is now covered by four new unit tests in
|
||||
`iris/src/widget/text/edit.rs` (composing, `commitText`,
|
||||
`deleteSurroundingText`, `setSelection`). **Verified**: on the
|
||||
emulator (`force-gles`, no Vulkan adapter on this AVD), tapping a
|
||||
real Gboard key now shows a real, single-character-appropriate
|
||||
suggestion strip ("H | How | Hey") rather than stale state, and a
|
||||
`render()` log line fires for every keystroke -- both confirm the
|
||||
`InputConnection` calls are landing and are being processed, which
|
||||
a hand-typed `adb shell input text` did *not* reliably exercise on
|
||||
this AVD (no `render()` at all followed one such call -- most
|
||||
likely a modern `input text` no longer round-trips through
|
||||
`commitText` the way older docs assume; Gboard-key taps are the
|
||||
real path and the one this fix was verified against).
|
||||
|
||||
2. **A second, deeper bug found while verifying (1), not root-caused
|
||||
this pass**: composed text never becomes visible on screen at
|
||||
all -- the grey composer bar stays empty, with no glyph anywhere
|
||||
in the frame, confirmed on repeated Gboard-key taps and across a
|
||||
keyboard-resize. **Ruled out**: the widget tree's own layout math.
|
||||
A new unit test, `layout_tests::
|
||||
composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region`,
|
||||
builds the composer's exact tree shape (`Stack{rect, Span{Pad{
|
||||
TextEdit}}}` inside an outer `Span::DOWN`) with no GPU or window,
|
||||
resizes it the way a real keyboard-triggered `surface_changed`
|
||||
does, edits the field both before and after, and asserts the
|
||||
field's `window_region` stays a small box near the bottom of
|
||||
whichever window size is current -- it passes, both before and
|
||||
after this pass's composer rebuild (item 3 below), so the CPU-side
|
||||
region a redraw lands at is provably correct. The bug is
|
||||
therefore downstream of that -- most likely something specific to
|
||||
the GPU-side redraw a content-only edit takes (`UiRenderState::
|
||||
redraw`, which redraws a single dirtied widget directly at its
|
||||
stored region rather than re-running its ancestors' layout) or to
|
||||
this AVD's forced `force-gles` backend (the only one available
|
||||
here; Iris's phone deliveries have used real Vulkan) -- neither
|
||||
isolated this pass. **Not attributable to this pass's changes**:
|
||||
reproduced identically before touching `composer.rs` (the very
|
||||
first build tested, before the composer rebuild below, already
|
||||
had it) and the render-engine files this pass did not touch
|
||||
(`core/src/render/mod.rs`, `core/src/ui/render_state.rs`) are the
|
||||
likely next place to look -- specifically `UiRenderState::redraw`'s
|
||||
reuse of a widget's own last-drawn region versus a full tree walk.
|
||||
**Needs**: either a Vulkan-capable emulator boot or the real phone
|
||||
to rule `force-gles` in or out, and a GPU-side primitive dump
|
||||
(the existing `frame diagnostics` log line, extended to name which
|
||||
primitives a frame actually wrote) to see whether the glyph quads
|
||||
are emitted at all or emitted somewhere off-screen.
|
||||
|
||||
3. **The composer bar rebuilt as one widget**, per this box's own
|
||||
ask: `transcript_ui::composer::build_composer` (unchanged
|
||||
`Stack{background, Span{Pad{TextEdit}}}` idiom, the same one the
|
||||
header row's `HEADER_SURFACE` already uses) now also caps the
|
||||
field at roughly six lines (`MaxSize` + `.scrollable()` for a
|
||||
wheel/trackpad overflow scroll -- a real touch-drag scroll on
|
||||
overflowing composer text is not wired and is a follow-up) and
|
||||
wraps the whole bar in one `Pad` whose `bottom` a new
|
||||
`Composer::set_bottom_inset(rsc, inset)` rewrites in place
|
||||
whenever the platform's insets change, called from
|
||||
`bench_client.rs`'s existing `on_insets_changed` with
|
||||
`insets.bottom.max(insets.ime_bottom)` -- the IME's own inset
|
||||
while it is open, the navigation bar's otherwise. Rewritten in
|
||||
place rather than rebuilt through a `WidgetPtr` swap (`top_bar`'s
|
||||
own pattern) because the field is strongly owned inside this tree
|
||||
and cannot be re-added to a new wrapper without panicking
|
||||
("was already added") -- rebuilding would also drop focus,
|
||||
selection and in-progress text on every keyboard toggle.
|
||||
**Verified**: `ui-trace` box readouts before/after a keyboard
|
||||
open on the emulator (the field's row correctly reports a
|
||||
547px move matching the real IME-triggered resize); the
|
||||
known-separate "top row renders twice after a keyboard resize"
|
||||
bug this box already recorded is unrelated and still open. **Not
|
||||
fixed by this alone**: item 2 above -- the text still does not
|
||||
render, so the "empty space at the bottom" symptom's other half
|
||||
(nothing filling the space the bar itself now correctly reserves)
|
||||
needs item 2's fix first before a real before/after screenshot is
|
||||
worth taking.
|
||||
|
||||
4. **App-switch text loss, fixed and verified.** `surface_destroyed`
|
||||
(backgrounding) drops the whole `AndroidRenderer` -- device,
|
||||
atlas, buffers -- and a subsequent `surface_changed` with no live
|
||||
renderer builds a genuinely new one (`AndroidRenderer::new`,
|
||||
distinct from the keyboard-resize path this box already fixed by
|
||||
*reusing* the renderer). But `iris_core::TextData::atlas` (the
|
||||
CPU-side glyph cache) and `UiData::textures` (the CPU-side texture
|
||||
bookkeeping the atlas is built on) live on `AndroidRsc`, which
|
||||
outlives any one `AndroidRenderer` -- so both kept pointing at the
|
||||
*old*, now-destroyed device's textures across the switch, the
|
||||
exact "rectangles stay, glyphs disappear" shape, just triggered by
|
||||
backgrounding instead of the keyboard. **Fix**: new
|
||||
`GlyphAtlas::clear()` and `Textures::reset()` (`iris/core/src/
|
||||
render/atlas.rs`, `iris/core/src/primitive/texture.rs`), called
|
||||
together from `surface_changed`'s "genuinely new renderer" branch
|
||||
only -- the same `already_live` check that already decides
|
||||
reuse-vs-new, so this is one mechanism gated on the one condition
|
||||
that needs it, not a second ad hoc check. **Verified on the
|
||||
emulator**: backgrounded via `KEYCODE_HOME`, reopened via
|
||||
`am start`, screenshotted -- every pre-existing glyph (headings,
|
||||
body text, the whole diagnostics report) is intact, `frame_count`
|
||||
resets to 1 confirming a genuinely new renderer was built, no
|
||||
crash.
|
||||
|
||||
5. **Swipe-vs-tap focus, fixed and verified** (Iris's follow-up the
|
||||
same day: "if I swipe over the input bar it brings up the
|
||||
keyboard... scrolling should be pinned"). `attr.rs`'s `Selector`/
|
||||
`Selectable` registered `CursorSense::click_or_drag()`, which
|
||||
calls `select()` -- and so grants focus and requests the IME --
|
||||
on the *first* frame of any press, before it is known whether the
|
||||
gesture will end up a tap or a drag. Rewritten around a shared
|
||||
`on_press` dispatcher over `PressStart`/`Pressing`/`PressEnd`: a
|
||||
field that is **already** focused behaves exactly as before
|
||||
(every frame updates the selection, so dragging inside a focused
|
||||
field to select text still works); a field that is **not**
|
||||
focused records where the press began (`TextEdit::press_origin`,
|
||||
new field) and only grants focus on `PressEnd` if no intervening
|
||||
frame crossed `sense::DRAG_SLOP` -- a drag recognised early simply
|
||||
clears the pending tap and does nothing further, so it is never
|
||||
consumed and whatever is behind the field still sees every frame
|
||||
of it. New `FocusHost::is_focused` (both platform impls) is what
|
||||
lets `on_press` tell the two cases apart. **Verified on the
|
||||
emulator**: `dumpsys input_method`'s `mInputShown` reads `false`
|
||||
after a `swipe` gesture starting on the composer bar (`ui-trace`
|
||||
confirms the field's own box never moved, i.e. no keyboard-driven
|
||||
resize happened), and reads `true` after an ordinary `tap` on the
|
||||
same field. **Coordination note**: a concurrent pass is moving
|
||||
drag arbitration into `sense.rs` behind a new `Drop` event: this
|
||||
fix touches only `attr.rs` (new `press_track`/`on_press`) and
|
||||
`iris/src/widget/text/edit.rs` (the new `press_origin` field), not
|
||||
`sense.rs` itself, so it should merge cleanly, but the next agent
|
||||
through here should check whether `Selector`/`Selectable`'s
|
||||
`Pressing`-frame delivery still arrives the way this code assumes
|
||||
once that lands.
|
||||
|
||||
**Checks this pass**: `cargo fmt --all` clean, `cargo clippy
|
||||
--workspace --all-targets` and `cargo ndk -t x86_64 -P 26 clippy
|
||||
--features "transcript-screen bench force-gles"` both zero warnings
|
||||
beyond the pre-existing `tabs-ui` unused-dependency notice, `cargo
|
||||
test --workspace` all passing (new tests: four in `edit.rs`, one in
|
||||
`layout_tests.rs`). **Not done**: item 2's root cause; a real
|
||||
before/after screenshot pair for item 3 (blocked on item 2); anything
|
||||
on Vulkan or the real phone.
|
||||
|
||||
- [ ] **P1 — session screen parity.** History paging backward (with the
|
||||
page-boundary healing `client-core` does not have yet, below),
|
||||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Compose bench v2 report from Iris's phone, 2026-09-06
|
||||
|
||||
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
|
||||
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
|
||||
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
|
||||
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
|
||||
`late` is judged against whichever it was, so compare a run with a run at
|
||||
the same rate. The iris v2 report goes beside this when it exists.
|
||||
|
||||
What it says: fling, type and keyboard are all essentially clean on
|
||||
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
|
||||
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
|
||||
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
|
||||
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
|
||||
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
|
||||
571 mA over 126 samples.
|
||||
|
||||
```
|
||||
ai-app render report
|
||||
device: Pixel 9 Pro XL (Google), Android 17
|
||||
build: release
|
||||
|
||||
transcript:
|
||||
108 events, 26 rows, 58 units loaded
|
||||
viewport 1531px, 2 units visible
|
||||
on screen: the list's own 0px, AssistantMsg 24520px
|
||||
0 tool calls and 0 groups open
|
||||
|
||||
per phase:
|
||||
fling: 3278 frames over 32.7s
|
||||
late: 4 (0.1%)
|
||||
total p50 5.5ms p90 8.7ms p99 11.6ms
|
||||
worst 49.0ms
|
||||
stream: 1041 frames over 21.3s
|
||||
late: 436 (41.9%)
|
||||
total p50 13.4ms p90 31.7ms p99 42.5ms
|
||||
worst 52.5ms
|
||||
type: 2446 frames over 61.5s
|
||||
late: 23 (0.9%)
|
||||
total p50 7.3ms p90 13.2ms p99 16.5ms
|
||||
worst 38.9ms
|
||||
keyboard: 358 frames over 10.0s
|
||||
late: 0 (0.0%)
|
||||
total p50 6.3ms p90 8.6ms p99 11.1ms
|
||||
worst 12.0ms
|
||||
|
||||
frames:
|
||||
7122 frames over 125.5s at 60Hz (16.7ms budget)
|
||||
late: 463 (6.5%)
|
||||
total p50 6.0ms p90 13.8ms p99 34.0ms
|
||||
waited p50 0.5ms p90 1.1ms p99 19.9ms
|
||||
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||
anim p50 0.7ms p90 4.5ms p99 7.6ms
|
||||
layout p50 0.1ms p90 0.1ms p99 0.2ms
|
||||
draw p50 0.7ms p90 2.9ms p99 21.9ms
|
||||
sync p50 0.1ms p90 0.2ms p99 0.6ms
|
||||
issue p50 1.4ms p90 2.4ms p99 3.2ms
|
||||
swap p50 0.4ms p90 0.8ms p99 1.2ms
|
||||
gpu p50 1.5ms p90 2.1ms p99 6.6ms
|
||||
|
||||
where the draw phase went:
|
||||
draw phase 1.74ms per frame, of which:
|
||||
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
|
||||
everything else: 1.51ms (86%)
|
||||
|
||||
work since this was last copied:
|
||||
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
|
||||
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
|
||||
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
|
||||
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
|
||||
markdown ready: 323
|
||||
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
|
||||
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
|
||||
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
|
||||
message composed: 397
|
||||
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
|
||||
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
|
||||
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
|
||||
session screen recomposed: 1630
|
||||
status row recomposed: 1
|
||||
transcript page from server: 10
|
||||
unit composed: 927
|
||||
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||||
process CPU time over this run: 69564ms
|
||||
peak RSS: 577452kB
|
||||
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# iris bench v2 report from Iris's phone, 2026-09-06
|
||||
|
||||
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
|
||||
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
|
||||
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
|
||||
compare the millisecond percentiles, not `late`.
|
||||
|
||||
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
|
||||
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
|
||||
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
|
||||
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
|
||||
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
|
||||
|
||||
Iris's observations on the same run: "the scrolling is not similar at
|
||||
all. It does not fling for me yet [with a finger], and the test also seems
|
||||
to give it a constant velocity and abruptly stop it at some point. Also
|
||||
unsure what's going on in that image with the compaction" -- her
|
||||
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
|
||||
overlapping, and once more below the composer bar: primitives of a
|
||||
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||
header drawn twice after a keyboard resize.
|
||||
|
||||
```
|
||||
iris bench report
|
||||
per phase:
|
||||
fling: 1783 frames over 53.2s
|
||||
late: 104 (5.8%)
|
||||
total p50 3.8ms p90 6.9ms p99 12.6ms
|
||||
worst 29.1ms
|
||||
stream: 401 frames over 21.3s
|
||||
late: 306 (76.3%)
|
||||
total p50 18.2ms p90 35.8ms p99 43.1ms
|
||||
worst 43.8ms
|
||||
type: 1202 frames over 65.7s
|
||||
late: 309 (25.7%)
|
||||
total p50 7.2ms p90 9.2ms p99 11.2ms
|
||||
worst 15.3ms
|
||||
keyboard: 9 frames over 9.7s
|
||||
late: 9 (100.0%)
|
||||
total p50 12.0ms p90 12.9ms p99 12.9ms
|
||||
worst 12.9ms
|
||||
|
||||
frames:
|
||||
3395 frames over 149.9s at 120Hz (8.3ms budget)
|
||||
late: 728 (21.4%)
|
||||
total p50 5.0ms p90 10.9ms p99 36.6ms
|
||||
worst 43.8ms
|
||||
cpu_p50 2.0ms gpu_wait_p50 2.6ms
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||||
process CPU time over this run: 40603ms
|
||||
peak RSS: 379156kB
|
||||
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
|
||||
```
|
||||
@@ -1,8 +1,15 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -68,4 +75,81 @@ public final class IrisView extends RustView {
|
||||
scroll.addView(text);
|
||||
activity.setContentView(scroll);
|
||||
}
|
||||
|
||||
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
|
||||
|
||||
/**
|
||||
* The bench build's keyboard diagnostics capture
|
||||
* (`bench_client.rs`'s `on_insets_changed` /
|
||||
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
|
||||
* `PlatformHandle::show_diagnostics_overlay`): unlike
|
||||
* `showRendererError` above, this adds a panel *over* this view
|
||||
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
|
||||
* running) rather than replacing the activity's content, and gives it
|
||||
* a Copy button and a Close that removes the panel -- so it draws
|
||||
* (and can be read) whether or not iris itself is still putting
|
||||
* anything on screen, without abandoning the session that produced
|
||||
* it. Runs on the UI thread regardless of which thread calls it,
|
||||
* since the call comes from a background task (a delayed capture
|
||||
* after the keyboard opens), and touching the view tree off the UI
|
||||
* thread is undefined.
|
||||
*/
|
||||
void showDiagnosticsOverlay(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
return;
|
||||
}
|
||||
Activity activity = (Activity) context;
|
||||
activity.runOnUiThread(() -> {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
if (existing != null) {
|
||||
parent.removeView(existing);
|
||||
}
|
||||
|
||||
float density = activity.getResources().getDisplayMetrics().density;
|
||||
int pad = (int) (16 * density);
|
||||
|
||||
LinearLayout overlay = new LinearLayout(activity);
|
||||
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||
overlay.setBackgroundColor(0xEE000000);
|
||||
overlay.setPadding(pad, pad, pad, pad);
|
||||
|
||||
TextView text = new TextView(activity);
|
||||
text.setText(report);
|
||||
text.setTextIsSelectable(true);
|
||||
text.setTextColor(0xFFFFFFFF);
|
||||
ScrollView scroll = new ScrollView(activity);
|
||||
scroll.addView(text);
|
||||
overlay.addView(scroll, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
LinearLayout buttonRow = new LinearLayout(activity);
|
||||
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
buttonRow.setPadding(0, pad, 0, 0);
|
||||
|
||||
Button copy = new Button(activity);
|
||||
copy.setText("Copy");
|
||||
copy.setOnClickListener(v -> {
|
||||
ClipboardManager clipboard =
|
||||
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard != null) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
|
||||
}
|
||||
});
|
||||
Button close = new Button(activity);
|
||||
close.setText("Close");
|
||||
close.setOnClickListener(v -> parent.removeView(overlay));
|
||||
buttonRow.addView(copy);
|
||||
buttonRow.addView(close);
|
||||
overlay.addView(buttonRow);
|
||||
|
||||
parent.addView(overlay, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,52 @@ public final class MainActivity extends Activity {
|
||||
setContentView(layout);
|
||||
view.requestFocus();
|
||||
|
||||
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
|
||||
// `logcat` showed the platform's own IME open/resize happening
|
||||
// while `setOnApplyWindowInsetsListener` fired only once, at
|
||||
// attach, and never again for a pure keyboard toggle -- a plain
|
||||
// (non-edge-to-edge) window is only guaranteed that one initial
|
||||
// dispatch; `adjustResize` handling the IME entirely by resizing
|
||||
// the window is not itself a trigger for a fresh one. Opting into
|
||||
// edge-to-edge (a platform call, API 30+, no new dependency) is
|
||||
// what makes the system redeliver insets on every change,
|
||||
// including the ones this activity actually cares about --
|
||||
// `getSystemWindowInset*` below is unaffected by this (it has
|
||||
// always reported the raw system-bar/IME overlap regardless of
|
||||
// who consumes it), so the on-screen bars and the padding Rust
|
||||
// already derives from those four numbers are unchanged; only the
|
||||
// callback's firing became reliable.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
}
|
||||
|
||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// The manifest declares adjustResize (AGENTS.md: without it the
|
||||
// keyboard pans the whole window instead of resizing it), and
|
||||
// under adjustResize the window itself shrinks to make room for
|
||||
// the keyboard -- which is exactly the condition under which
|
||||
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
||||
// measures how much of the window the keyboard overlaps, and
|
||||
// resize already made that overlap zero by construction. That
|
||||
// numeric inset is not a usable "is the keyboard open" signal
|
||||
// here (found while root-causing why bench_client.rs's keyboard
|
||||
// phase and auto-diagnostics never fired on the emulator despite
|
||||
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
||||
// survive adjustResize is the boolean isVisible() answer, set
|
||||
// from the platform's own start/end of the transition over a
|
||||
// different path than the inset amount -- the same fact
|
||||
// AGENTS.md's "Things that have bitten" already names for the
|
||||
// Compose side's identical trap. Passed through as a 0/1 stand-
|
||||
// in for the ime_bottom pixel amount, since nothing on the Rust
|
||||
// side reads it as a real pixel value -- only `> 0.0`.
|
||||
int imeBottom = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
&& insets.isVisible(WindowInsets.Type.ime())) {
|
||||
imeBottom = 1;
|
||||
}
|
||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
||||
return insets;
|
||||
|
||||
@@ -46,10 +46,13 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
|
||||
|
||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||
|
||||
# Poll for the report line rather than a fixed sleep -- the run itself is a
|
||||
# fixed script (24 swipes + a 20s streaming phase) but device speed varies.
|
||||
# Poll for the report line rather than a fixed sleep -- the run itself is
|
||||
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
|
||||
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
||||
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
||||
# a longer script than v1's swipe-loop-only run.
|
||||
i=0
|
||||
while [ "$i" -lt 90 ]; 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)
|
||||
if [ -n "$LINE" ]; then
|
||||
break
|
||||
@@ -58,7 +61,9 @@ while [ "$i" -lt 90 ]; do
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$LINE" ]; then
|
||||
echo "run-bench.sh: no report after 90s -- check logcat by hand" >&2
|
||||
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
|
||||
exit 1
|
||||
fi
|
||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 6 "iris bench report:"
|
||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
||||
# phases, four lines each) on top of the frames/bench sections v1 had.
|
||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "iris bench report:"
|
||||
@@ -26,9 +26,9 @@ use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, Mutex};
|
||||
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
|
||||
@@ -37,19 +37,57 @@ use std::time::Duration;
|
||||
/// builds open a different split of it, not a wrong-vs-right answer.
|
||||
const BACKLOG_COUNT: usize = 3200;
|
||||
|
||||
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench
|
||||
/// runs are the same gesture and the same load, which is the entire point
|
||||
/// of a shared fixture and a shared scripted loop (P0's pass condition).
|
||||
const CYCLES: usize = 6;
|
||||
const SWIPE_PX: f32 = 900.0;
|
||||
const SWIPE_MS: u64 = 200;
|
||||
const SWIPE_PAUSE_MS: u64 = 500;
|
||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||
/// implement the identical four phases -- see that box before changing any
|
||||
/// constant here, since a mismatch would make the two reports stop
|
||||
/// measuring the same thing while still looking like they do.
|
||||
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||
const STREAM_SECONDS: u64 = 20;
|
||||
|
||||
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
|
||||
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
|
||||
/// exact line even though the swipe loop it names no longer runs there
|
||||
/// either (the fling phase replaced it); nothing here drives an actual
|
||||
/// swipe with these any more.
|
||||
const LEGACY_CYCLES: usize = 6;
|
||||
|
||||
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
|
||||
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
|
||||
/// tween can never exceed the distance/time it is given while a real
|
||||
/// fling decays from an initial velocity the way a finger flick does.
|
||||
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
|
||||
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||
const FLING_COUNT: usize = 8;
|
||||
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||
const FLING_PAUSE_MS: u64 = 300;
|
||||
|
||||
/// Type phase (v2): long, multisyllabic words so the composer actually
|
||||
/// wraps and the transcript above it is pushed upward, typed and deleted
|
||||
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
|
||||
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
|
||||
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
||||
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
||||
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
||||
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
|
||||
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
|
||||
wrap across several lines while the transcript above is pushed upward by the growing \
|
||||
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
|
||||
happening now!!!";
|
||||
const TYPE_CHAR_MS: u64 = 50;
|
||||
|
||||
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
|
||||
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
|
||||
/// `KEYBOARD_HIDE_WAIT_MS`.
|
||||
const KEYBOARD_CYCLES: usize = 5;
|
||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||
|
||||
/// One animation step's target cadence -- close enough to 60Hz that a
|
||||
/// `List::scroll` swipe is many small moves rather than one jump, so
|
||||
/// frames are actually rendered along the way (the point of animating it
|
||||
/// at all rather than calling `scroll` once per swipe).
|
||||
/// fling/scroll is many small moves rather than one jump, so frames are
|
||||
/// actually rendered along the way, and close enough that a `ctx.update`
|
||||
/// closure's effect (only applied once the next frame callback drains the
|
||||
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
|
||||
/// when a later step in the same phase needs to read state back.
|
||||
const ANIM_STEP_MS: u64 = 16;
|
||||
|
||||
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||
@@ -58,6 +96,12 @@ pub struct BenchClient {
|
||||
ui_state: AndroidUiState,
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
report_display: WeakWidget<TextEdit>,
|
||||
/// The top button row, in a `WidgetPtr` slot rather than added
|
||||
/// directly (like `content`) so `on_insets_changed` can swap in a
|
||||
/// version padded for the status bar once insets are known -- RUST.md's
|
||||
/// P0 box, "the status-bar inset is not applied," found the row sitting
|
||||
/// directly under it because nothing here read `insets().top` at all.
|
||||
top_bar: WeakWidget<WidgetPtr>,
|
||||
screen: Option<transcript_ui::TranscriptScreen>,
|
||||
items: Vec<TranscriptItem>,
|
||||
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||
@@ -68,6 +112,35 @@ pub struct BenchClient {
|
||||
platform: Option<Arc<PlatformHandle>>,
|
||||
last_report: Option<String>,
|
||||
running: bool,
|
||||
/// The keyboard phase's own confirmation channel -- updated from
|
||||
/// `on_insets_changed` (the platform's own answer for whether the IME
|
||||
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
|
||||
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
|
||||
/// `ctx.update`, since neither side needs the widget tree for this.
|
||||
ime_state: Arc<Mutex<ImeState>>,
|
||||
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
||||
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
||||
/// first where it is not, so opening the keyboard fires this once
|
||||
/// rather than on every insets update while it stays open (a rotation
|
||||
/// or a status-bar change with the keyboard already up would otherwise
|
||||
/// re-fire it).
|
||||
keyboard_was_visible: bool,
|
||||
/// The status-bar inset `top_bar` was last padded by -- see
|
||||
/// `on_insets_changed`'s own comment for why this guards the rebuild.
|
||||
last_top_pad: f32,
|
||||
}
|
||||
|
||||
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
|
||||
/// count real 0->visible / visible->0 transitions `on_insets_changed`
|
||||
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
|
||||
/// present an inferred value as a measured one. `run_keyboard_phase` reads
|
||||
/// the counters before and after asking for a toggle and calls it
|
||||
/// confirmed only if the count moved.
|
||||
#[derive(Default)]
|
||||
struct ImeState {
|
||||
visible: bool,
|
||||
shown_events: u32,
|
||||
hidden_events: u32,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for BenchClient {
|
||||
@@ -172,27 +245,50 @@ impl AndroidAppState for BenchClient {
|
||||
.label("Benchmark report")
|
||||
.add(rsc);
|
||||
|
||||
let controls = bench_controls(rsc);
|
||||
let top_bar = WidgetPtr::new().add(rsc);
|
||||
let controls = bench_controls(rsc, 0.0);
|
||||
top_bar(rsc).set(controls);
|
||||
let tree = (
|
||||
controls,
|
||||
top_bar,
|
||||
content.height(rest(2)),
|
||||
report_display.height(rest(1)).pad(8),
|
||||
report_display.height(rest(1)).pad(dp(8)),
|
||||
)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(tree);
|
||||
|
||||
// Startup log line (RUST.md's P0 box, "log once at startup ... the
|
||||
// number of font families found, the default family resolved"):
|
||||
// what font discovery actually found on this device, before
|
||||
// anything is drawn.
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
log::info!(
|
||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||
bold={:?} italic={:?} mono={:?}",
|
||||
font.families_found,
|
||||
font.default_family,
|
||||
font.default_mono_family,
|
||||
font.regular_resolved,
|
||||
font.bold_resolved,
|
||||
font.italic_resolved,
|
||||
font.mono_resolved,
|
||||
);
|
||||
|
||||
let mut client = Self {
|
||||
ui_state,
|
||||
content,
|
||||
report_display,
|
||||
top_bar,
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
stream_tail: Vec::new(),
|
||||
platform: None,
|
||||
last_report: None,
|
||||
running: false,
|
||||
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
||||
keyboard_was_visible: false,
|
||||
last_top_pad: 0.0,
|
||||
};
|
||||
|
||||
let (backlog, stream_tail) = parse_fixture();
|
||||
@@ -216,11 +312,138 @@ impl AndroidAppState for BenchClient {
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
||||
/// field comment. Rebuilds the row rather than mutating a stored
|
||||
/// `Padding` in place, since nothing here holds a handle to one --
|
||||
/// but **only when `insets.top` actually changed**: this callback
|
||||
/// also fires on every `ime_bottom` change (the keyboard sliding
|
||||
/// in/out fires several intermediate insets updates), which has
|
||||
/// nothing to do with the status bar, and rebuilding on every one of
|
||||
/// those was the root cause of a real bug (found on Iris's phone,
|
||||
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
|
||||
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
|
||||
/// `needs_redraw.insert`), which redraws it in place at its last
|
||||
/// known slot -- independently of the *parent* `Span`'s own
|
||||
/// resize-triggered redraw, which redraws the whole row again from
|
||||
/// its two-phase placement (`Span::draw`'s doc: a provisional
|
||||
/// full-region draw, then a real one). A `.set()` landing between
|
||||
/// those two phases left one dirty-widget redraw's primitives
|
||||
/// un-freed while the `Span`-driven redraw drew its own copy,
|
||||
/// producing two live copies of the same three buttons in one frame
|
||||
/// -- one at the header's real slot, one wherever `Span`'s
|
||||
/// provisional phase happened to leave it (visibly inside the
|
||||
/// transcript area), each still holding its own working `on(click)`
|
||||
/// handlers, so a tap meant for whatever was under the stray copy
|
||||
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
|
||||
/// depends on changed removes the repeated `.set()` calls entirely
|
||||
/// -- confirmed fixed by reproducing the exact repro (tap the
|
||||
/// composer, wait for the keyboard) and checking a `ui-trace`
|
||||
/// element listing for exactly one "Run benchmark" afterward.
|
||||
///
|
||||
/// Also two things downstream of the same `ime_bottom` transition:
|
||||
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
||||
/// doc -- the platform's own answer for whether the IME actually
|
||||
/// opened or closed, rather than assumed from having called
|
||||
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
|
||||
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
|
||||
/// surface is exactly the case a previous commit found wiped text,
|
||||
/// and Iris needs a way to get a report off the phone even if that
|
||||
/// (or some other keyboard-triggered regression) is still happening
|
||||
/// on the build she is holding -- `capture_keyboard_diagnostics`
|
||||
/// below fires ~500ms after the keyboard becomes visible, once per
|
||||
/// keyboard opening, and shows its report in a plain overlay view
|
||||
/// that draws independently of whatever iris itself is doing.
|
||||
fn on_insets_changed(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
insets: iris::android::WindowInsets,
|
||||
) {
|
||||
if insets.top != self.last_top_pad {
|
||||
self.last_top_pad = insets.top;
|
||||
let controls = bench_controls(rsc, insets.top);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
}
|
||||
|
||||
// The composer bar sits directly on whichever of the IME or the
|
||||
// navigation bar is currently the bottom of usable space -- see
|
||||
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
|
||||
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
|
||||
// keyboard covers it, so the larger of the two is always the right
|
||||
// answer without needing to know which is currently showing.
|
||||
if let Some(screen) = &self.screen {
|
||||
screen
|
||||
.composer
|
||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||
}
|
||||
|
||||
let ime_visible = insets.ime_bottom > 0.0;
|
||||
|
||||
let mut ime = self.ime_state.lock().unwrap();
|
||||
if ime_visible && !ime.visible {
|
||||
ime.shown_events += 1;
|
||||
}
|
||||
if !ime_visible && ime.visible {
|
||||
ime.hidden_events += 1;
|
||||
}
|
||||
ime.visible = ime_visible;
|
||||
drop(ime);
|
||||
|
||||
if ime_visible && !self.keyboard_was_visible {
|
||||
self.keyboard_was_visible = true;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
state.capture_keyboard_diagnostics(rsc);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
} else if !ime_visible {
|
||||
self.keyboard_was_visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait after the keyboard becomes visible before capturing
|
||||
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
||||
/// still happening) and a couple of frames have all had time to land, per
|
||||
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
||||
/// on the way that nothing can observe" reasoning applied the other way:
|
||||
/// this wants to observe the state *after* the transition settles, not
|
||||
/// mid-flight.
|
||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
|
||||
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
||||
/// why it needs one at all. A dark neutral rather than pure black
|
||||
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||
/// instead of a hole in the background the buttons happen to float in.
|
||||
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
|
||||
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||
/// naming that builder's type at each call site is more machinery than a
|
||||
/// top-of-screen padding number is worth.
|
||||
///
|
||||
/// **Backed by an opaque rect the full size of the row, not just the three
|
||||
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
||||
/// build a9232ac): "the header buttons have nothing behind them and
|
||||
/// overlap the transcript text" -- before this, only each button's own
|
||||
/// `rect(...)` painted anything, so the gaps between and around them (and
|
||||
/// the status-bar strip above them) showed whatever was one layer back
|
||||
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
||||
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
||||
/// density-correct size the transcript below was already using post-P0 --
|
||||
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
||||
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
||||
/// function), and every size below moved from a bare number (physical
|
||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||
/// so the row's reserved height in the outer `Span::DOWN`
|
||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
@@ -234,7 +457,7 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||
@@ -250,10 +473,33 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
|
||||
let diag_rect = rect(Color::rgb(60, 45, 70))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.show_diagnostics(rsc);
|
||||
},
|
||||
)
|
||||
.label("Diagnostics");
|
||||
let diagnostics = (
|
||||
diag_rect,
|
||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
||||
|
||||
(rect(HEADER_SURFACE), buttons)
|
||||
.stack()
|
||||
.height(dp(56))
|
||||
.pad(Padding::top(top_pad))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
impl BenchClient {
|
||||
@@ -270,6 +516,56 @@ impl BenchClient {
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
|
||||
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
|
||||
/// benchmark report uses) rather than a separate widget, so the
|
||||
/// existing "Copy report" button and clipboard path work on whichever
|
||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||
/// so it's set here too rather than adding a second copy path.
|
||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
let frame_report = match self.android_state().frame_report.report() {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded yet".to_string(),
|
||||
};
|
||||
let report = match &self.android_state().renderer {
|
||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||
};
|
||||
self.report_display.edit(rsc).set(&report);
|
||||
self.last_report = Some(report);
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
|
||||
/// same text the on-screen `Diagnostics` button produces, plus the
|
||||
/// per-frame log `FrameReport` already keeps around the resize --
|
||||
/// `frame_report.report()` above covers "the frames around the
|
||||
/// resize" without a second accounting mechanism), then does three
|
||||
/// things the button does not: logs it (so a `logcat` pull gets it
|
||||
/// even if nothing on screen does), copies it to the clipboard
|
||||
/// unprompted, and shows it in the shell's plain overlay view, which
|
||||
/// draws independently of iris's own renderer -- the whole point,
|
||||
/// since the renderer is exactly what might be in the wiped state
|
||||
/// this exists to report on.
|
||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
self.show_diagnostics(rsc);
|
||||
let Some(report) = self.last_report.clone() else {
|
||||
return;
|
||||
};
|
||||
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) {
|
||||
let Some(report) = &self.last_report else {
|
||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||
@@ -286,10 +582,10 @@ impl BenchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming
|
||||
/// phase, then the report -- run in-process for the same reason that
|
||||
/// file's own doc gives (no usable system tracing on a real phone, no
|
||||
/// agent that can drive one).
|
||||
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
|
||||
/// then type, then keyboard, then the report -- run in-process for the
|
||||
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
|
||||
/// on a real phone, no agent that can drive one).
|
||||
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||
if self.running {
|
||||
log::info!("iris bench report: already running");
|
||||
@@ -302,35 +598,21 @@ impl BenchClient {
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let platform = self.platform.clone();
|
||||
let stream_tail = self.stream_tail.clone();
|
||||
let ime_state = self.ime_state.clone();
|
||||
let refresh_hz = platform
|
||||
.as_ref()
|
||||
.and_then(|p| p.refresh_rate_hz())
|
||||
.unwrap_or(60.0);
|
||||
let cpu_start = process_cpu_ms();
|
||||
let run_started_at = Instant::now();
|
||||
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
// The swipe loop: two drags toward newer content, two back --
|
||||
// a cycle returns to where it started, so the whole loop
|
||||
// measures steady-state scrolling. `BenchRun.kt`'s own
|
||||
// comment on this shape.
|
||||
for _ in 0..CYCLES {
|
||||
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
|
||||
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
|
||||
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned to the newest end before streaming starts, matching
|
||||
// `stream-bench.sh`'s "Jump to latest" tap.
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
|
||||
// The battery sampler runs concurrently with the streaming
|
||||
// phase, once a second, the same cadence `BatterySampler` uses
|
||||
// on the Compose side -- via its own JNI-attached thread, not
|
||||
// `ctx.update`, since a sample needs no widget-tree access.
|
||||
// The battery sampler runs for the whole run, once a second,
|
||||
// the same cadence `BatterySampler` uses on the Compose side
|
||||
// -- via its own JNI-attached thread, not `ctx.update`, since
|
||||
// a sample needs no widget-tree access.
|
||||
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new()));
|
||||
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
|
||||
let sampler = platform.clone().map(|platform| {
|
||||
let done = sampler_done.clone();
|
||||
let samples = samples.clone();
|
||||
@@ -344,27 +626,10 @@ impl BenchClient {
|
||||
})
|
||||
});
|
||||
|
||||
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let old_items = state.items.clone();
|
||||
state.items = fold_event(&state.items, &event);
|
||||
match &state.screen {
|
||||
// The path P0 asked to measure: update only the
|
||||
// row(s) that changed instead of rebuilding all
|
||||
// ~3,200 of them per event.
|
||||
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||
None => state.rebuild_transcript(rsc),
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
// Lets the last few deltas land and draw before the report is
|
||||
// read -- `BenchRun.kt`'s own closing delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let travel = run_fling_phase(&mut ctx, &redraw).await;
|
||||
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
|
||||
run_type_phase(&mut ctx, &redraw, &platform).await;
|
||||
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
|
||||
|
||||
sampler_done.store(true, Ordering::Relaxed);
|
||||
if let Some(sampler) = sampler {
|
||||
@@ -373,7 +638,10 @@ impl BenchClient {
|
||||
let battery = battery_line(&samples.lock().unwrap());
|
||||
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||
(Some(start), Some(end)) => {
|
||||
format!(" process CPU time over this run: {}ms", end.saturating_sub(start))
|
||||
format!(
|
||||
" process CPU time over this run: {}ms",
|
||||
end.saturating_sub(start)
|
||||
)
|
||||
}
|
||||
_ => " process CPU time over this run: unavailable".to_string(),
|
||||
};
|
||||
@@ -381,19 +649,61 @@ impl BenchClient {
|
||||
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||
};
|
||||
let total_seconds = run_started_at.elapsed().as_secs_f64();
|
||||
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
state.running = false;
|
||||
let scroll_line = format!(
|
||||
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events",
|
||||
CYCLES * 4
|
||||
);
|
||||
let frames_line = match state.android_state().frame_report.report() {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded".to_string(),
|
||||
let now = Instant::now();
|
||||
let phase_lines: String = state
|
||||
.android_state()
|
||||
.frame_report
|
||||
.phase_stats(now, refresh_hz)
|
||||
.iter()
|
||||
.map(|p| format!("{p}\n"))
|
||||
.collect();
|
||||
let per_phase = if phase_lines.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("per phase:\n{phase_lines}\n")
|
||||
};
|
||||
let frames_block = match state.android_state().frame_report.report() {
|
||||
Some(stats) => {
|
||||
let (late, late_pct) =
|
||||
state.android_state().frame_report.late_at_hz(refresh_hz);
|
||||
format!(
|
||||
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \
|
||||
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
||||
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.1}ms",
|
||||
stats.total_frames,
|
||||
total_seconds,
|
||||
refresh_hz,
|
||||
1000.0 / refresh_hz as f64,
|
||||
stats.p50.as_secs_f64() * 1000.0,
|
||||
stats.p90.as_secs_f64() * 1000.0,
|
||||
stats.p99.as_secs_f64() * 1000.0,
|
||||
stats.worst.as_secs_f64() * 1000.0,
|
||||
stats.cpu_p50.as_secs_f64() * 1000.0,
|
||||
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||
)
|
||||
}
|
||||
None => "frames:\n no frames recorded".to_string(),
|
||||
};
|
||||
let scroll_line = format!(
|
||||
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
|
||||
{sent}/{total} fixture events",
|
||||
LEGACY_CYCLES * 4
|
||||
);
|
||||
let fling_line = format!(
|
||||
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
|
||||
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
|
||||
);
|
||||
let type_line = format!(
|
||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||
TYPE_TEXT.chars().count()
|
||||
);
|
||||
let report = format!(
|
||||
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
|
||||
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||
);
|
||||
log::info!("iris bench report: {report}");
|
||||
state.report_display.edit(rsc).set(&report);
|
||||
@@ -404,26 +714,287 @@ impl BenchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps,
|
||||
/// so the swipe is many rendered frames rather than one jump -- the same
|
||||
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose
|
||||
/// side, in the one place the two backends have to differ (iris's `List`
|
||||
/// has no built-in tween, so this drives it by hand).
|
||||
async fn animate_scroll(
|
||||
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
|
||||
/// same `ctx.update` every other mutation here goes through) and returns
|
||||
/// its result to the caller's async task -- `ctx.update` alone has no way
|
||||
/// to hand a value back, since the closure only actually runs once the
|
||||
/// next frame callback drains `IrisViewPeer`'s task channel
|
||||
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
|
||||
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
|
||||
/// nothing drains it until something schedules the frame callback that
|
||||
/// calls `drain_tasks`, and a caller relying on some *earlier*,
|
||||
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
|
||||
/// deadlocks the moment that earlier callback has already fired and
|
||||
/// drained everything queued before this call existed. Cost a real hang
|
||||
/// in this file's first version of the fling phase: every loop iteration
|
||||
/// after the first sat forever with nothing scheduled to drain it.
|
||||
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
|
||||
/// slow device's frame callback can lag further than that.
|
||||
async fn read_from_state<T, F>(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn iris::task::RequestRedraw>,
|
||||
total_px: f32,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
let steps = (duration_ms / ANIM_STEP_MS).max(1);
|
||||
let step_px = total_px / steps as f32;
|
||||
for _ in 0..steps {
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
f: F,
|
||||
) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).scroll(step_px);
|
||||
}
|
||||
let _ = tx.send(f(state, rsc));
|
||||
});
|
||||
redraw.request_redraw();
|
||||
loop {
|
||||
if let Ok(value) = rx.try_recv() {
|
||||
return value;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||
/// from it (toward older messages) through `List::fling`, then
|
||||
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
||||
/// convention (`List::scroll`'s own doc: positive moves *later* content
|
||||
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
||||
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
||||
/// the other way around; the two apps' *travel* is still directly
|
||||
/// comparable because both report it as a row index + pixel offset, not a
|
||||
/// signed distance.
|
||||
async fn run_fling_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("fling");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||
// `anchor = None` into a real slot before `start` is read.
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
|
||||
let start = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let outward = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let end = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
format!("start={start} outward={outward} end={end}")
|
||||
}
|
||||
|
||||
async fn read_anchor_position(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).anchor_position_display(),
|
||||
None => "idx=none".to_string(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ticks the fling forward in ~60Hz steps (the same shape
|
||||
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
|
||||
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
|
||||
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
|
||||
/// own spline-decided `duration()` already caps how long it can run.
|
||||
async fn wait_for_fling_settle(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) {
|
||||
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < cap {
|
||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
|
||||
None => false,
|
||||
})
|
||||
.await;
|
||||
if !still_scrolling {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
|
||||
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
||||
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
|
||||
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
|
||||
/// `(sent, total)`.
|
||||
async fn run_stream_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
) -> (usize, usize) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("stream");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
|
||||
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let old_items = state.items.clone();
|
||||
state.items = fold_event(&state.items, &event);
|
||||
match &state.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||
None => state.rebuild_transcript(rsc),
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts
|
||||
// -- `BenchRun.kt`'s own closing delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
(sent, total)
|
||||
}
|
||||
|
||||
/// Phase 3: focuses the real composer, shows the keyboard, then types
|
||||
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
|
||||
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
|
||||
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
|
||||
/// way.
|
||||
async fn run_type_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("type");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
state.set_focus(Some(screen.composer.field));
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
// Lets focus and the keyboard's opening animation land before typing
|
||||
// starts, so the frames this phase records are the wrap/reflow it is
|
||||
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let mut typed = String::new();
|
||||
for ch in TYPE_TEXT.chars() {
|
||||
typed.push(ch);
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
while !typed.is_empty() {
|
||||
typed.pop();
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
|
||||
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
|
||||
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
|
||||
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
|
||||
/// "keyboard: could not be shown" if the platform never confirms it even
|
||||
/// once, per UI_RULES.md ("design the unknown/failed state before the
|
||||
/// answer's").
|
||||
async fn run_keyboard_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
ime_state: &Arc<Mutex<ImeState>>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
.mark_phase("keyboard");
|
||||
});
|
||||
let mut shown = 0;
|
||||
let mut hidden = 0;
|
||||
for _ in 0..KEYBOARD_CYCLES {
|
||||
let before_shown = ime_state.lock().unwrap().shown_events;
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().shown_events > before_shown {
|
||||
shown += 1;
|
||||
}
|
||||
|
||||
let before_hidden = ime_state.lock().unwrap().hidden_events;
|
||||
if let Some(p) = platform {
|
||||
p.hide_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().hidden_events > before_hidden {
|
||||
hidden += 1;
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
|
||||
} else {
|
||||
format!(
|
||||
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
|
||||
(confirmed via on_insets_changed)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TYPE_TEXT;
|
||||
|
||||
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
|
||||
/// is the same string, so it has to match exactly or the two apps'
|
||||
/// type phases stop typing the same content -- RUST.md's "Benchmark
|
||||
/// v2" spec is one shared string for both.
|
||||
#[test]
|
||||
fn type_text_is_exactly_600_characters() {
|
||||
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
|
||||
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
|
||||
//! part of `android_view::context`'s own `Context`/`Resources` wrappers
|
||||
//! (that file's own `// TODO: more methods?`), so this calls them
|
||||
//! directly rather than growing that crate's wrapper for two one-off
|
||||
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
|
||||
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
|
||||
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
|
||||
//! report's real late-frame budget and `InputMethodManager.
|
||||
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
|
||||
//! of these are part of `android_view::context`'s own `Context`/
|
||||
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
|
||||
//! this calls them directly rather than growing that crate's wrapper for
|
||||
//! calls this crate alone needs.
|
||||
//!
|
||||
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||
@@ -131,4 +134,119 @@ impl PlatformHandle {
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
|
||||
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
|
||||
/// frames are judged against *this* device's real budget, not an
|
||||
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
|
||||
/// "late" that met their own faster deadline. `None` if the view is
|
||||
/// not yet attached to a window (`getDisplay` returns `null`) or the
|
||||
/// platform reports a non-positive rate, which is not a real answer
|
||||
/// either.
|
||||
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let display = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getDisplay",
|
||||
"()Landroid/view/Display;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
if display.is_null() {
|
||||
return None;
|
||||
}
|
||||
let rate = env
|
||||
.call_method(&display, "getRefreshRate", "()F", &[])
|
||||
.ok()?
|
||||
.f()
|
||||
.ok()?;
|
||||
if rate > 0.0 { Some(rate) } else { None }
|
||||
}
|
||||
|
||||
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
|
||||
/// own show, called directly rather than through the focus-driven
|
||||
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
|
||||
/// since RUST.md's "Benchmark v2" spec asks for this "through the
|
||||
/// shell's InputMethodManager" independent of focus state. `true` only
|
||||
/// if the platform itself reports the request succeeded -- whether the
|
||||
/// IME actually became visible is confirmed separately, from
|
||||
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
|
||||
/// value as a measured one").
|
||||
pub fn show_ime(&self) -> bool {
|
||||
self.try_toggle_ime(true).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
|
||||
pub fn hide_ime(&self) -> bool {
|
||||
self.try_toggle_ime(false).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let imm = self.system_service(env, &context, "input_method")?;
|
||||
if show {
|
||||
env.call_method(
|
||||
&imm,
|
||||
"showSoftInput",
|
||||
"(Landroid/view/View;I)Z",
|
||||
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
} else {
|
||||
let token = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getWindowToken",
|
||||
"()Landroid/os/IBinder;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
env.call_method(
|
||||
&imm,
|
||||
"hideSoftInputFromWindow",
|
||||
"(Landroid/os/IBinder;I)Z",
|
||||
&[JValue::Object(&token), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `report` in the shell's plain-view diagnostics overlay
|
||||
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
|
||||
/// and Close controls, added over whatever iris itself is drawing
|
||||
/// rather than replacing it (unlike `android::view::show_renderer_error`,
|
||||
/// which exists for the case the renderer can never recover from and
|
||||
/// intentionally never returns). Called from a background task after
|
||||
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
|
||||
/// so the Java side hops onto the UI thread itself before touching the
|
||||
/// view tree -- see that method's own comment.
|
||||
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
|
||||
self.try_show_diagnostics_overlay(report).is_some()
|
||||
}
|
||||
|
||||
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let jreport = env.new_string(report).ok()?;
|
||||
env.call_method(
|
||||
self.view.as_obj(),
|
||||
"showDiagnosticsOverlay",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(jreport.as_ref())],
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,31 @@ pub struct Size {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Len {
|
||||
/// Physical pixels -- a raw device pixel, unaffected by the display's
|
||||
/// density. Rare to want directly (a hairline border is the usual
|
||||
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
|
||||
/// the two are kept separate rather than one field a caller has to
|
||||
/// remember to pre-multiply.
|
||||
pub abs: f32,
|
||||
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
|
||||
/// (1 unit = 1/160in), resolved against the display's density at
|
||||
/// layout time (`apply_rest`'s `density` parameter) rather than at the
|
||||
/// point a widget is built, since density is a property of the device
|
||||
/// this ends up running on, not of the widget tree. This is the unit
|
||||
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
|
||||
/// 2026-09-06: before it existed, every size in the tree was `abs`
|
||||
/// (physical pixels), and the only way to make a 16px design draw at
|
||||
/// the right *size* on a denser display was a single global multiply
|
||||
/// applied to the whole rendered scene after layout -- which is also
|
||||
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
|
||||
/// at logical size and stretched by the scale"): a glyph rasterised at
|
||||
/// 16 physical px and then stretched 3x by that global multiply is a
|
||||
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
|
||||
/// layout time instead means the font size handed to the text shaper
|
||||
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
|
||||
/// atlas rasterises at the display's real resolution and nothing
|
||||
/// downstream needs to stretch anything.
|
||||
pub dp: f32,
|
||||
pub rel: f32,
|
||||
pub rest: f32,
|
||||
}
|
||||
@@ -67,10 +91,10 @@ impl Size {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_uivec2(self) -> UiVec2 {
|
||||
pub fn to_uivec2(self, density: f32) -> UiVec2 {
|
||||
UiVec2 {
|
||||
x: self.x.apply_rest(),
|
||||
y: self.y.apply_rest(),
|
||||
x: self.x.apply_rest(density),
|
||||
y: self.y.apply_rest(density),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,26 +122,43 @@ impl Size {
|
||||
impl Len {
|
||||
pub const ZERO: Self = Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
};
|
||||
|
||||
pub const REST: Self = Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 1.0,
|
||||
};
|
||||
|
||||
pub fn apply_rest(&self) -> UiScalar {
|
||||
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
|
||||
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
|
||||
/// unscaled display, `content_scale` on Android; see `dp`'s field
|
||||
/// doc). Every other component of `Len` is already resolution-
|
||||
/// independent (`rel` is a fraction of the parent; `rest` becomes a
|
||||
/// fraction too, below), so `density` only ever touches this one term.
|
||||
pub fn apply_rest(&self, density: f32) -> UiScalar {
|
||||
UiScalar {
|
||||
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||
abs: self.abs,
|
||||
abs: self.abs + self.dp * density,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abs(abs: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: abs.to_f32(),
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
}
|
||||
pub fn dp(dp: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: dp.to_f32(),
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -125,6 +166,7 @@ impl Len {
|
||||
pub fn rel(rel: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: rel.to_f32(),
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -132,6 +174,7 @@ impl Len {
|
||||
pub fn rest(ratio: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: ratio.to_f32(),
|
||||
}
|
||||
@@ -144,6 +187,15 @@ pub mod len_fns {
|
||||
pub fn abs(abs: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: abs.to_f32(),
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
}
|
||||
pub fn dp(dp: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: dp.to_f32(),
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -151,6 +203,7 @@ pub mod len_fns {
|
||||
pub fn rel(rel: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: rel.to_f32(),
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -158,14 +211,15 @@ pub mod len_fns {
|
||||
pub fn rest(ratio: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: ratio.to_f32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl_op!(Len Add add; abs rel rest);
|
||||
impl_op!(Len Sub sub; abs rel rest);
|
||||
impl_op!(Len Add add; abs dp rel rest);
|
||||
impl_op!(Len Sub sub; abs dp rel rest);
|
||||
|
||||
impl_op!(Size Add add; x y);
|
||||
impl_op!(Size Sub sub; x y);
|
||||
@@ -187,6 +241,9 @@ impl std::fmt::Display for Len {
|
||||
if self.abs != 0.0 {
|
||||
write!(f, "{} abs;", self.abs)?;
|
||||
}
|
||||
if self.dp != 0.0 {
|
||||
write!(f, "{} dp;", self.dp)?;
|
||||
}
|
||||
if self.rel != 0.0 {
|
||||
write!(f, "{} rel;", self.rel)?;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,8 @@ use swash::{
|
||||
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
|
||||
const NOTO_SANS_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Bold.ttf");
|
||||
const NOTO_SANS_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Italic.ttf");
|
||||
const NOTO_SANS_BOLD_ITALIC: &[u8] =
|
||||
include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
|
||||
const NOTO_SANS_MONO_REGULAR: &[u8] =
|
||||
include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
|
||||
const NOTO_SANS_BOLD_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
|
||||
const NOTO_SANS_MONO_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
|
||||
const NOTO_SANS_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
|
||||
|
||||
/// What starting up found about text rendering, for the on-screen
|
||||
@@ -68,6 +66,17 @@ pub struct TextData {
|
||||
pub layout_cx: LayoutContext<UiColor>,
|
||||
scale_cx: ScaleContext,
|
||||
pub atlas: GlyphAtlas,
|
||||
/// Physical pixels per dp -- a second copy of
|
||||
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
|
||||
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
|
||||
/// from an event callback that has a `TextData` but no `Painter`, so it
|
||||
/// has nowhere else to read the display's density from. Both copies are
|
||||
/// set together, from the one place either backend learns the real
|
||||
/// value (`android::view::new_peer`); this is the same accepted
|
||||
/// duplication as `AndroidRenderer::content_scale`; a single source of
|
||||
/// truth would mean carrying a `Painter` (or output size) into every
|
||||
/// input handler for the sake of one field.
|
||||
pub density: f32,
|
||||
}
|
||||
|
||||
impl Default for TextData {
|
||||
@@ -77,6 +86,7 @@ impl Default for TextData {
|
||||
layout_cx: LayoutContext::new(),
|
||||
scale_cx: ScaleContext::new(),
|
||||
atlas: GlyphAtlas::default(),
|
||||
density: 1.0,
|
||||
};
|
||||
data.register_bundled_fonts();
|
||||
data
|
||||
@@ -203,14 +213,26 @@ impl TextData {
|
||||
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
|
||||
};
|
||||
|
||||
let regular_resolved =
|
||||
resolve_family(GenericFamily::SansSerif, FontWeight::NORMAL, FontStyle::Normal);
|
||||
let bold_resolved =
|
||||
resolve_family(GenericFamily::SansSerif, FontWeight::BOLD, FontStyle::Normal);
|
||||
let italic_resolved =
|
||||
resolve_family(GenericFamily::SansSerif, FontWeight::NORMAL, FontStyle::Italic);
|
||||
let mono_resolved =
|
||||
resolve_family(GenericFamily::Monospace, FontWeight::NORMAL, FontStyle::Normal);
|
||||
let regular_resolved = resolve_family(
|
||||
GenericFamily::SansSerif,
|
||||
FontWeight::NORMAL,
|
||||
FontStyle::Normal,
|
||||
);
|
||||
let bold_resolved = resolve_family(
|
||||
GenericFamily::SansSerif,
|
||||
FontWeight::BOLD,
|
||||
FontStyle::Normal,
|
||||
);
|
||||
let italic_resolved = resolve_family(
|
||||
GenericFamily::SansSerif,
|
||||
FontWeight::NORMAL,
|
||||
FontStyle::Italic,
|
||||
);
|
||||
let mono_resolved = resolve_family(
|
||||
GenericFamily::Monospace,
|
||||
FontWeight::NORMAL,
|
||||
FontStyle::Normal,
|
||||
);
|
||||
|
||||
FontDiagnostics {
|
||||
families_found,
|
||||
@@ -353,7 +375,7 @@ pub struct TextBuffer {
|
||||
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||
/// does, since spans change far less often than a naive equality check
|
||||
/// on the whole `Vec` would cost to compute every frame.
|
||||
shaped: Option<(TextAttrs, Option<f32>)>,
|
||||
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
@@ -409,19 +431,42 @@ impl TextBuffer {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
|
||||
/// Lay the text out, unless it is already laid out for these attributes and
|
||||
/// this width.
|
||||
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
|
||||
/// Lay the text out, unless it is already laid out for these
|
||||
/// attributes, this width and this density.
|
||||
///
|
||||
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
|
||||
/// are density-independent (dp) units, multiplied by `density` here --
|
||||
/// the one place text crosses from the widget tree's dp sizes into the
|
||||
/// physical pixels the shaper and rasteriser (`TextData::place`) both
|
||||
/// then work in.** This is what makes glyphs sharp on a dense display:
|
||||
/// before this existed, `font_size` was already a physical-pixel value
|
||||
/// (RUST.md's P0 box's global-scale stopgap resolved density by
|
||||
/// stretching the whole rendered frame afterward instead), so a glyph
|
||||
/// was rasterised small and then upscaled by whatever the display's
|
||||
/// scale factor was -- exactly the blur Iris's report described.
|
||||
/// Multiplying here instead means the font size hitting `ScaleContext`
|
||||
/// in `place` below is already the display's real physical size, so
|
||||
/// the atlas holds a bitmap at the resolution it is actually shown at.
|
||||
/// `GlyphKey.size` already keys on that resolved `font_size`
|
||||
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
|
||||
/// physical size with no change needed there.
|
||||
pub fn shape(
|
||||
&mut self,
|
||||
data: &mut TextData,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
density: f32,
|
||||
) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||
return;
|
||||
}
|
||||
let mut builder = data
|
||||
.layout_cx
|
||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||
attrs.line_height,
|
||||
attrs.line_height * density,
|
||||
)));
|
||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||
for span in &self.spans {
|
||||
@@ -433,7 +478,7 @@ impl TextBuffer {
|
||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||
}
|
||||
if let Some(size) = span.font_size {
|
||||
builder.push(StyleProperty::FontSize(size), range.clone());
|
||||
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||
}
|
||||
if span.bold {
|
||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||
@@ -449,7 +494,7 @@ impl TextBuffer {
|
||||
self.layout.break_all_lines(width);
|
||||
self.layout
|
||||
.align(Alignment::Start, AlignmentOptions::default());
|
||||
self.shaped = Some((attrs.clone(), width));
|
||||
self.shaped = Some((attrs.clone(), width, density));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,8 +611,9 @@ impl TextData {
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
textures: &mut Textures,
|
||||
density: f32,
|
||||
) -> RenderedText {
|
||||
buffer.shape(self, attrs, width);
|
||||
buffer.shape(self, attrs, width, density);
|
||||
let glyphs = self.place(buffer, textures);
|
||||
RenderedText {
|
||||
glyphs: std::sync::Arc::new(glyphs),
|
||||
|
||||
@@ -141,6 +141,27 @@ impl Textures {
|
||||
self.updates.push(Update::Patch(handle.slot, rect));
|
||||
}
|
||||
|
||||
/// Forget every image, page and pending update -- what a genuinely new
|
||||
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
|
||||
/// clear`], which this module's own doc references: every slot number
|
||||
/// and every queued [`Update`] here describes the *old* device's
|
||||
/// textures (an `Update::Push`/`Update::Patch` already drained into a
|
||||
/// renderer that no longer exists is gone for good, and a fresh
|
||||
/// `UiRenderNode`'s own texture manager starts with none of them
|
||||
/// applied), so nothing is lost by starting this bookkeeping over too.
|
||||
/// Any `TextureHandle` a caller still holds across the reset (none in
|
||||
/// the transcript screen this reset is wired up for today -- confirmed
|
||||
/// by grep, the only standalone (non-atlas) image anywhere in this
|
||||
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
|
||||
/// `tabs-ui` example) is left pointing at a slot this instance no
|
||||
/// longer recognises and needs reinserting via `add`/`add_page` again
|
||||
/// -- the same pre-existing gap a renderer restart already left for
|
||||
/// such a handle before this method existed, just named rather than
|
||||
/// silent now.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
pub fn free(&mut self) {
|
||||
for (kind, idx) in self.recv.try_iter() {
|
||||
self.images[idx as usize] = None;
|
||||
|
||||
@@ -173,6 +173,25 @@ impl GlyphAtlas {
|
||||
pub fn glyph_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Forget every page and every rasterised entry -- what a genuinely new
|
||||
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
|
||||
/// "not already live" branch, e.g. after backgrounding): the pages this
|
||||
/// atlas remembers are `TextureHandle`s into the *old* device's
|
||||
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
|
||||
/// `uv_max`/`layer` point into them. Without this, a glyph already
|
||||
/// cached here is treated as "already placed" and never re-inserted
|
||||
/// into the fresh (empty) atlas the new renderer actually has --
|
||||
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
|
||||
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
|
||||
/// this is its counterpart for the case where the renderer really is
|
||||
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||
/// doc for why that is harmless here.
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
|
||||
|
||||
@@ -1,15 +1,87 @@
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
|
||||
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
|
||||
/// report and a percentage from `gfxinfo` mean the same thing.
|
||||
/// report and a percentage from `gfxinfo` mean the same thing. Only a
|
||||
/// fallback now that a caller can read the display's real refresh rate
|
||||
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
|
||||
/// 90Hz or 120Hz phone judged against this constant would call every frame
|
||||
/// "late" that merely met its own, faster budget.
|
||||
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
|
||||
|
||||
/// Enough frames for several minutes of scrolling before the oldest ones
|
||||
/// start being overwritten -- the same "diagnostic, not a log" sizing
|
||||
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
|
||||
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
|
||||
const RING_CAPACITY: usize = 4096;
|
||||
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
|
||||
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
|
||||
/// this so `phase_stats` never has to report a phase as partially evicted.
|
||||
const RING_CAPACITY: usize = 16384;
|
||||
|
||||
/// One `mark_phase` call: the wall-clock instant and the (0-based,
|
||||
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
|
||||
/// which a phase began -- `phase_stats` slices `index_ring` against this to
|
||||
/// find which recorded samples belong to which phase, since the ring
|
||||
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
|
||||
/// not which phase they were in.
|
||||
struct PhaseMark {
|
||||
name: String,
|
||||
start_index: u64,
|
||||
start_at: Instant,
|
||||
}
|
||||
|
||||
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
|
||||
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
|
||||
/// p50/p90/p99, worst, duration". `Display` matches the shape
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
|
||||
/// the two apps' reports read the same way side by side.
|
||||
pub struct PhaseStats {
|
||||
pub name: String,
|
||||
/// How many frames were recorded during this phase in total -- may
|
||||
/// exceed `late + (samples counted)` if some of this phase's frames
|
||||
/// have since been evicted from the ring by a very long run; that
|
||||
/// case is named in the `Display` rather than silently under-counted.
|
||||
pub frames: u64,
|
||||
pub duration: Duration,
|
||||
pub late: u64,
|
||||
pub late_percent: f64,
|
||||
pub p50: Duration,
|
||||
pub p90: Duration,
|
||||
pub p99: Duration,
|
||||
pub worst: Duration,
|
||||
/// `false` if this phase's frame count exceeds how many samples of it
|
||||
/// are still in the ring -- the percentiles above are then computed
|
||||
/// over whatever survived, not the whole phase. UI_RULES.md: this is
|
||||
/// the "we don't fully know" state, named rather than folded silently
|
||||
/// into a number that looks exact.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PhaseStats {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
" {}: {} frames over {:.1}s{}",
|
||||
self.name,
|
||||
self.frames,
|
||||
self.duration.as_secs_f64(),
|
||||
if self.complete {
|
||||
""
|
||||
} else {
|
||||
" (ring evicted some of this phase)"
|
||||
},
|
||||
)?;
|
||||
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
|
||||
writeln!(
|
||||
f,
|
||||
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
|
||||
self.p50.as_secs_f64() * 1000.0,
|
||||
self.p90.as_secs_f64() * 1000.0,
|
||||
self.p99.as_secs_f64() * 1000.0,
|
||||
)?;
|
||||
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
|
||||
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
|
||||
@@ -41,6 +113,11 @@ pub struct FrameReport {
|
||||
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
|
||||
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
|
||||
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// The absolute (0-based, since the last `reset`) frame index each
|
||||
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
|
||||
/// slices against `PhaseMark::start_index` to tell which recorded
|
||||
/// frames fall in which phase.
|
||||
index_ring: Box<[u64; RING_CAPACITY]>,
|
||||
/// How many of `ring`'s slots hold a real sample -- saturates at
|
||||
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
|
||||
len: usize,
|
||||
@@ -50,6 +127,12 @@ pub struct FrameReport {
|
||||
/// correct even once the ring itself only holds the most recent frames.
|
||||
total_frames: u64,
|
||||
janky_frames: u64,
|
||||
/// `mark_phase` calls since the last `reset`, oldest first -- see
|
||||
/// `phase_stats`. Empty on an ordinary run that never calls
|
||||
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
|
||||
/// prints no "per phase:" section at all, matching RUST.md's "empty/
|
||||
/// absent on an ordinary 'Copy' press, which never marks a phase."
|
||||
phases: Vec<PhaseMark>,
|
||||
}
|
||||
|
||||
/// One resolved reading. `Display` is the log line both the "Frame report"
|
||||
@@ -107,10 +190,12 @@ impl FrameReport {
|
||||
Self {
|
||||
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||
index_ring: Box::new([0; RING_CAPACITY]),
|
||||
len: 0,
|
||||
pos: 0,
|
||||
total_frames: 0,
|
||||
janky_frames: 0,
|
||||
phases: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +216,7 @@ impl FrameReport {
|
||||
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
|
||||
self.ring[self.pos] = total;
|
||||
self.submit_ring[self.pos] = submit_to_present;
|
||||
self.index_ring[self.pos] = self.total_frames;
|
||||
self.pos = (self.pos + 1) % RING_CAPACITY;
|
||||
self.len = (self.len + 1).min(RING_CAPACITY);
|
||||
self.total_frames += 1;
|
||||
@@ -142,12 +228,89 @@ impl FrameReport {
|
||||
/// Clears every counter and every sample -- what the "Reset frame
|
||||
/// report" control calls, so a report covers only what was scrolled
|
||||
/// after the button was pressed (the same reason `FrameStats.kt`'s
|
||||
/// `reset()` exists on the Compose side).
|
||||
/// `reset()` exists on the Compose side). Also clears every phase
|
||||
/// mark, so a fresh run starts with no "per phase:" section until it
|
||||
/// marks one of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.len = 0;
|
||||
self.pos = 0;
|
||||
self.total_frames = 0;
|
||||
self.janky_frames = 0;
|
||||
self.phases.clear();
|
||||
}
|
||||
|
||||
/// Marks the start of a named phase at the current moment -- every
|
||||
/// frame recorded from here until the next `mark_phase` (or `reset`)
|
||||
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
|
||||
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||
/// can slice one whole run's frames by what was happening during each.
|
||||
pub fn mark_phase(&mut self, name: &str) {
|
||||
self.phases.push(PhaseMark {
|
||||
name: name.to_string(),
|
||||
start_index: self.total_frames,
|
||||
start_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
|
||||
/// oldest first. `now` closes the last phase's wall-clock span (there
|
||||
/// is no "next phase" instant to use for it); `refresh_hz` is what
|
||||
/// each phase's own `late`/`late_percent` is judged against, read from
|
||||
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
|
||||
/// count/% against the display's refresh rate."
|
||||
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
|
||||
if self.phases.is_empty() || refresh_hz <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||
self.phases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, phase)| {
|
||||
let (end_index, end_at) = match self.phases.get(i + 1) {
|
||||
Some(next) => (next.start_index, next.start_at),
|
||||
None => (self.total_frames, now),
|
||||
};
|
||||
let frames = end_index.saturating_sub(phase.start_index);
|
||||
let mut samples: Vec<Duration> = (0..self.len)
|
||||
.filter(|&j| {
|
||||
let idx = self.index_ring[j];
|
||||
idx >= phase.start_index && idx < end_index
|
||||
})
|
||||
.map(|j| self.ring[j])
|
||||
.collect();
|
||||
let complete = samples.len() as u64 >= frames;
|
||||
if samples.is_empty() {
|
||||
return PhaseStats {
|
||||
name: phase.name.clone(),
|
||||
frames,
|
||||
duration: end_at.saturating_duration_since(phase.start_at),
|
||||
late: 0,
|
||||
late_percent: 0.0,
|
||||
p50: Duration::ZERO,
|
||||
p90: Duration::ZERO,
|
||||
p99: Duration::ZERO,
|
||||
worst: Duration::ZERO,
|
||||
complete,
|
||||
};
|
||||
}
|
||||
samples.sort_unstable();
|
||||
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
|
||||
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
|
||||
PhaseStats {
|
||||
name: phase.name.clone(),
|
||||
frames,
|
||||
duration: end_at.saturating_duration_since(phase.start_at),
|
||||
late,
|
||||
late_percent: 100.0 * late as f64 / samples.len() as f64,
|
||||
p50: pct(50),
|
||||
p90: pct(90),
|
||||
p99: pct(99),
|
||||
worst: *samples.last().expect("checked not empty above"),
|
||||
complete,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `None` if nothing has been recorded since the last reset -- the
|
||||
@@ -186,6 +349,28 @@ impl FrameReport {
|
||||
gpu_wait_p50: median(submit_samples),
|
||||
})
|
||||
}
|
||||
|
||||
/// `(late count, late percent)` over every sample still in the ring,
|
||||
/// judged against `refresh_hz`'s own frame budget rather than the
|
||||
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
|
||||
/// count/% against the display's refresh rate... print 'at N Hz (X ms
|
||||
/// budget)' like Compose does." A separate method from `report()`
|
||||
/// rather than a parameter on it, so `report()`'s own `janky_percent`
|
||||
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
|
||||
/// unaffected for every existing caller that never measured a real
|
||||
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
|
||||
/// `refresh_hz`.
|
||||
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
|
||||
if self.len == 0 || refresh_hz <= 0.0 {
|
||||
return (0, 0.0);
|
||||
}
|
||||
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||
let late = self.ring[..self.len]
|
||||
.iter()
|
||||
.filter(|&&d| d > budget)
|
||||
.count() as u64;
|
||||
(late, 100.0 * late as f64 / self.len as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrameReport {
|
||||
@@ -296,4 +481,68 @@ mod tests {
|
||||
// same pattern here.
|
||||
assert!(stats.worst <= Duration::from_millis(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marks_means_no_phases() {
|
||||
let mut r = FrameReport::new();
|
||||
r.record(Duration::from_millis(5));
|
||||
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phases_slice_frames_by_when_they_were_marked() {
|
||||
let mut r = FrameReport::new();
|
||||
r.mark_phase("a");
|
||||
for _ in 0..5 {
|
||||
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
|
||||
}
|
||||
r.mark_phase("b");
|
||||
for _ in 0..3 {
|
||||
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
|
||||
}
|
||||
let now = Instant::now();
|
||||
let phases = r.phase_stats(now, 60.0);
|
||||
assert_eq!(phases.len(), 2);
|
||||
assert_eq!(phases[0].name, "a");
|
||||
assert_eq!(phases[0].frames, 5);
|
||||
assert_eq!(phases[0].late, 0);
|
||||
assert_eq!(phases[0].worst, Duration::from_millis(10));
|
||||
assert_eq!(phases[1].name, "b");
|
||||
assert_eq!(phases[1].frames, 3);
|
||||
assert_eq!(phases[1].late, 3);
|
||||
assert_eq!(phases[1].late_percent, 100.0);
|
||||
assert_eq!(phases[1].worst, Duration::from_millis(20));
|
||||
assert!(phases[0].complete);
|
||||
assert!(phases[1].complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_phase_runs_until_now() {
|
||||
let mut r = FrameReport::new();
|
||||
r.mark_phase("only");
|
||||
r.record(Duration::from_millis(1));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
let now = Instant::now();
|
||||
let phases = r.phase_stats(now, 60.0);
|
||||
assert_eq!(phases.len(), 1);
|
||||
assert!(phases[0].duration >= Duration::from_millis(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_phase_marks() {
|
||||
let mut r = FrameReport::new();
|
||||
r.mark_phase("a");
|
||||
r.record(Duration::from_millis(1));
|
||||
r.reset();
|
||||
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
|
||||
let mut r = FrameReport::new();
|
||||
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
|
||||
r.record(Duration::from_millis(10));
|
||||
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
|
||||
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,10 @@ impl<'a> Painter<'a> {
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
let density = self.state.density;
|
||||
let ui = self.rsc.ui_mut();
|
||||
ui.text.render(buffer, attrs, width, &mut ui.textures)
|
||||
ui.text
|
||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||
}
|
||||
|
||||
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||
@@ -210,6 +212,12 @@ impl<'a> Painter<'a> {
|
||||
self.state.output_size
|
||||
}
|
||||
|
||||
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
|
||||
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
|
||||
pub fn density(&self) -> f32 {
|
||||
self.state.density
|
||||
}
|
||||
|
||||
pub fn px_size(&mut self) -> Vec2 {
|
||||
self.region.size().to_abs(self.state.output_size)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,32 @@ pub struct UiRenderState {
|
||||
pub active: HashMap<WidgetId, ActiveData>,
|
||||
pub layers: PrimitiveLayers,
|
||||
pub(super) output_size: Vec2,
|
||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||
/// unscaled display) until a backend that knows its own density calls
|
||||
/// `set_density` (Android's `content_scale`, read at `surface_changed`
|
||||
/// time); the winit backend has no analogous per-monitor value wired up
|
||||
/// yet and stays at the default.
|
||||
pub(super) density: f32,
|
||||
|
||||
old_root: Option<WidgetId>,
|
||||
resized: bool,
|
||||
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
|
||||
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
||||
/// stated in terms of these two: an unchanged frame must cost 0 of
|
||||
@@ -35,9 +56,11 @@ impl UiRenderState {
|
||||
active: Default::default(),
|
||||
layers: Default::default(),
|
||||
output_size: Vec2::ZERO,
|
||||
density: 1.0,
|
||||
old_root: None,
|
||||
resized: false,
|
||||
draw_started: Default::default(),
|
||||
captured: Default::default(),
|
||||
draw_count: 0,
|
||||
region_mut_count: 0,
|
||||
mov_count: 0,
|
||||
@@ -60,6 +83,20 @@ impl UiRenderState {
|
||||
self.resized = true;
|
||||
}
|
||||
|
||||
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
|
||||
/// resolves against from the next layout pass on -- see `density`'s
|
||||
/// field doc. Not folded into `resize` because the two change on
|
||||
/// different triggers (a surface resize on every rotation or keyboard
|
||||
/// open; a density change only if the app follows the display to a
|
||||
/// different screen, which Android surfaces separately).
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
self.density = density;
|
||||
}
|
||||
|
||||
pub fn density(&self) -> f32 {
|
||||
self.density
|
||||
}
|
||||
|
||||
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
||||
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||
// decide whether to panic or not
|
||||
@@ -311,7 +348,7 @@ impl UiRenderState {
|
||||
};
|
||||
let from = active
|
||||
.size
|
||||
.to_uivec2()
|
||||
.to_uivec2(self.density)
|
||||
.align(RegionAlign::TOP_LEFT)
|
||||
.within(&active.region);
|
||||
let slot = active.move_slot;
|
||||
@@ -336,6 +373,13 @@ impl UiRenderState {
|
||||
active.textures.clear();
|
||||
rsc.ui_mut().textures.free();
|
||||
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
|
||||
// (the self-ownership ref taken when it was allocated) and
|
||||
// the up-link ref it held on its parent's slot -- read from
|
||||
@@ -408,6 +452,27 @@ impl UiRenderState {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.active.iter().filter_map(move |(&id, inst)| {
|
||||
let l = widgets.label(id);
|
||||
|
||||
@@ -68,12 +68,15 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
span.push(text);
|
||||
span.push(img);
|
||||
span.pad(8.0).background(rect(tint)).add_strong(rsc).any()
|
||||
span.pad(dp(8.0))
|
||||
.background(rect(tint))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
} else {
|
||||
wtext(row_text(i))
|
||||
.wrap(true)
|
||||
.color(text_color)
|
||||
.pad(8.0)
|
||||
.pad(dp(8.0))
|
||||
.background(rect(tint))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
|
||||
@@ -12,6 +12,10 @@ impl<T: HasAndroidUiState> FocusHost for T {
|
||||
self.android_state_mut().focus = id;
|
||||
}
|
||||
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.android_state().focus == Some(id)
|
||||
}
|
||||
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
|
||||
// and this runs deep inside the platform-agnostic sensor dispatch
|
||||
|
||||
@@ -49,6 +49,52 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
|
||||
self.state.android_state().focus
|
||||
}
|
||||
|
||||
/// Tell Gboard where the caret/selection and the composing region
|
||||
/// actually are, via `InputMethodManager.updateSelection` -- every one
|
||||
/// of android-view's own demo's `set_composing_text_internal`/`render`
|
||||
/// calls this, and this bridge never did, which is what left Gboard's
|
||||
/// own model of the field diverging from `TextEdit`'s real one after
|
||||
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
|
||||
/// hit space, and also doesn't move cursor forward" -- Gboard holds
|
||||
/// its composing keystrokes back until it believes the app has caught
|
||||
/// up, and without this call it never does). Called from
|
||||
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
|
||||
/// callback already runs through, rather than duplicated at each of
|
||||
/// this file's mutating methods.
|
||||
///
|
||||
/// `candidates_start`/`candidates_end` report the composing region;
|
||||
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
|
||||
/// convention. `compose_len` is tracked in `char`s (this module's doc
|
||||
/// comment), so this reports it as that many UTF-16 units back from the
|
||||
/// caret -- exact for the common BMP case, the same approximation
|
||||
/// `set_composing_text` already makes.
|
||||
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
|
||||
let Some(focus) = self.focus() else { return };
|
||||
let text = &self.rsc[focus];
|
||||
let Some(sel) = text.selection_range() else {
|
||||
return;
|
||||
};
|
||||
let content = text.text();
|
||||
let sel_start = byte_to_utf16(content, sel.start) as i32;
|
||||
let sel_end = byte_to_utf16(content, sel.end) as i32;
|
||||
let compose_len = self.state.android_state().compose_len;
|
||||
let (comp_start, comp_end) = if compose_len > 0 {
|
||||
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
|
||||
(caret - compose_len as i32, caret)
|
||||
} else {
|
||||
(-1, -1)
|
||||
};
|
||||
let imm = ctx.view.input_method_manager(&mut ctx.env);
|
||||
imm.update_selection(
|
||||
&mut ctx.env,
|
||||
&ctx.view,
|
||||
sel_start,
|
||||
sel_end,
|
||||
comp_start,
|
||||
comp_end,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
|
||||
@@ -23,7 +23,8 @@ mod view;
|
||||
pub use insets::Insets;
|
||||
pub use render::AndroidRenderer;
|
||||
pub use view::{
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, new_peer,
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
|
||||
new_peer,
|
||||
};
|
||||
|
||||
/// Registers the extra native methods this backend needs beyond what
|
||||
|
||||
+21
-21
@@ -208,16 +208,14 @@ impl AndroidRenderer {
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let encoder = Self::create_encoder(&device);
|
||||
// Logical size (physical / `content_scale`) -- see
|
||||
// `android::view::AndroidUiState::content_scale`'s field comment
|
||||
// for why this crate now divides at all (RUST.md's P0 box, "text
|
||||
// is far too small"). The swapchain above stays at the real
|
||||
// physical `width`/`height` for a sharp framebuffer.
|
||||
let logical_size = iris_core::util::Vec2::new(
|
||||
width as f32 / content_scale,
|
||||
height as f32 / content_scale,
|
||||
);
|
||||
let ui = match UiRenderNode::new(&device, &queue, &config, logical_size) {
|
||||
// Physical pixels, matching the swapchain's own `width`/`height`
|
||||
// exactly -- see `android::view::AndroidUiState::content_scale`'s
|
||||
// field comment for why this is no longer divided into a separate
|
||||
// logical space (that stopgap is what made text blurry, RUST.md's
|
||||
// P0 box). `Len::dp` folds the density in at layout time instead,
|
||||
// so nothing here needs to know it at all.
|
||||
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
|
||||
Ok(ui) => ui,
|
||||
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
|
||||
};
|
||||
@@ -400,25 +398,27 @@ impl AndroidRenderer {
|
||||
submit_start.elapsed()
|
||||
}
|
||||
|
||||
/// Logical size (physical / `content_scale`) -- the unit layout and
|
||||
/// hit-testing use, matching the window uniform's own units. See
|
||||
/// Physical pixels -- the unit layout and hit-testing use, matching
|
||||
/// the window uniform's own units. See
|
||||
/// `android::view::AndroidUiState::content_scale`'s field comment.
|
||||
pub fn size(&self) -> iris_core::util::Vec2 {
|
||||
iris_core::util::Vec2::new(
|
||||
self.config.width as f32 / self.content_scale,
|
||||
self.config.height as f32 / self.content_scale,
|
||||
)
|
||||
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
|
||||
}
|
||||
|
||||
/// Reconfigures the surface and rewrites the window uniform for a new
|
||||
/// physical size -- deliberately the *only* two things this does.
|
||||
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
|
||||
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
|
||||
/// invalidates a glyph the CPU-side cache already placed in the atlas.
|
||||
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
|
||||
/// why that distinction matters -- it is what keeps text on screen
|
||||
/// across an IME resize.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
let logical = iris_core::util::Vec2::new(
|
||||
width as f32 / self.content_scale,
|
||||
height as f32 / self.content_scale,
|
||||
);
|
||||
self.ui.resize(logical, &self.queue);
|
||||
let size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
self.ui.resize(size, &self.queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+140
-50
@@ -70,19 +70,31 @@ pub struct AndroidUiState {
|
||||
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
||||
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
||||
pub frame_report: FrameReport,
|
||||
/// `DisplayMetrics.density` (`new_peer`'s doc comment), read once at
|
||||
/// view construction: physical pixels per dp on this device. Neither
|
||||
/// this crate nor `default::` had ever divided by it before RUST.md's
|
||||
/// P0 box's phone report ("text is far too small") -- `window_size`
|
||||
/// below and `surface_changed`'s call into `UiRenderState::resize` both
|
||||
/// report *logical* (physical / `content_scale`) dimensions now, which
|
||||
/// is what makes a `font_size: 16.0` 16 dp rather than 16 raw device
|
||||
/// pixels on a ~3x-density phone. The actual wgpu surface/swapchain
|
||||
/// stays at the real physical resolution (`AndroidRenderer`'s own
|
||||
/// `config.width/height`) for a sharp framebuffer; only the *logical*
|
||||
/// coordinate system layout, hit-testing and the window uniform agree
|
||||
/// on is scaled. Touch coordinates (`on_touch_event`) are divided by
|
||||
/// this too, so they land in the same space layout is using.
|
||||
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
|
||||
/// per dp on this device, read once at view construction and carried
|
||||
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
|
||||
/// then on -- every `Len::dp` in the widget tree resolves against it at
|
||||
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
|
||||
/// "density-independent length unit" item, 2026-09-06).
|
||||
///
|
||||
/// **Everything else in this module is physical pixels, matching the
|
||||
/// real wgpu surface/swapchain resolution** -- window size, touch
|
||||
/// coordinates, insets. That is a correction from an earlier version
|
||||
/// of this comment, which had `window_size`/`surface_changed`'s
|
||||
/// `UiRenderState::resize` call divide by `content_scale` into a
|
||||
/// *logical* coordinate space instead, as a global stopgap for
|
||||
/// RUST.md's P0 box's phone report ("text is far too small"). That
|
||||
/// stopgap fixed the size but not the *sharpness*: dividing to logical
|
||||
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
|
||||
/// then implicitly upscaled ~3x by the NDC mapping onto the real
|
||||
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
|
||||
/// logical size and stretched by the scale" Iris reported next.
|
||||
/// Resolving `dp` at layout time replaces it: a widget author writes
|
||||
/// `dp(16)` for a size that should look the same physical size on any
|
||||
/// density, and everything downstream (layout, hit-testing, the window
|
||||
/// uniform, and the font size handed to the text shaper) works in the
|
||||
/// display's own physical pixels throughout, so nothing is
|
||||
/// rasterised at one resolution and displayed at another.
|
||||
pub content_scale: f32,
|
||||
/// The last insets `render()` saw -- compared each frame so
|
||||
/// `AndroidAppState::on_insets_changed` fires only when they actually
|
||||
@@ -154,21 +166,26 @@ pub trait AndroidAppState: HasAndroidUiState {
|
||||
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
|
||||
/// the two top buttons sitting under it, because nothing read `.top`
|
||||
/// at all), and again on a rotation or the keyboard opening/closing.
|
||||
/// `insets` is in the same *logical* units `content_scale` converts
|
||||
/// everything else to (physical / `content_scale`), so a widget can add
|
||||
/// it to a layout size directly. The default does nothing -- most
|
||||
/// screens have no chrome that sits under a system bar.
|
||||
/// `insets` is in the same physical-pixel units everything else in the
|
||||
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
|
||||
/// a widget can add it to a layout size directly -- `dp(...) +
|
||||
/// abs(insets.top)` if the widget wants a density-independent size
|
||||
/// plus the system bar's own (already-physical) height. The default
|
||||
/// does nothing -- most screens have no chrome that sits under a
|
||||
/// system bar.
|
||||
#[allow(unused_variables)]
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: LogicalInsets) {}
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
|
||||
}
|
||||
|
||||
/// `insets::Insets`, converted from physical to logical units -- see
|
||||
/// `AndroidUiState::content_scale`'s field comment. A distinct type from
|
||||
/// `insets::Insets` (rather than dividing in place) so a reader at the call
|
||||
/// site can tell which unit a value is already in without checking where it
|
||||
/// came from.
|
||||
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
|
||||
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
|
||||
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
|
||||
/// Both are physical pixels; this used to divide by `content_scale` into a
|
||||
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
|
||||
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
|
||||
/// field comment for why that stopgap is gone.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq)]
|
||||
pub struct LogicalInsets {
|
||||
pub struct WindowInsets {
|
||||
pub left: f32,
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
@@ -176,14 +193,14 @@ pub struct LogicalInsets {
|
||||
pub ime_bottom: f32,
|
||||
}
|
||||
|
||||
impl LogicalInsets {
|
||||
fn from_physical(insets: Insets, content_scale: f32) -> Self {
|
||||
impl WindowInsets {
|
||||
fn from_physical(insets: Insets) -> Self {
|
||||
Self {
|
||||
left: insets.left as f32 / content_scale,
|
||||
top: insets.top as f32 / content_scale,
|
||||
right: insets.right as f32 / content_scale,
|
||||
bottom: insets.bottom as f32 / content_scale,
|
||||
ime_bottom: insets.ime_bottom as f32 / content_scale,
|
||||
left: insets.left as f32,
|
||||
top: insets.top as f32,
|
||||
right: insets.right as f32,
|
||||
bottom: insets.bottom as f32,
|
||||
ime_bottom: insets.ime_bottom as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +325,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
show_soft_input(&mut ctx.env, &ctx.view);
|
||||
}
|
||||
|
||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||
// after every edit to keep its own model of the field in sync, or
|
||||
// it holds keystrokes back rather than trusting a screen it
|
||||
// believes is stale. See `update_ime_selection`'s own doc.
|
||||
self.update_ime_selection(ctx);
|
||||
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.cursor.end_frame();
|
||||
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
|
||||
@@ -343,10 +367,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
let ui_state = self.state.android_state();
|
||||
let current_insets = ui_state.insets();
|
||||
if current_insets != ui_state.last_insets {
|
||||
let content_scale = ui_state.content_scale;
|
||||
let logical = LogicalInsets::from_physical(current_insets, content_scale);
|
||||
let physical = WindowInsets::from_physical(current_insets);
|
||||
self.state.android_state_mut().last_insets = current_insets;
|
||||
self.state.on_insets_changed(&mut self.rsc, logical);
|
||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||
}
|
||||
|
||||
let ui_state = self.state.android_state();
|
||||
@@ -504,16 +527,10 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
) -> bool {
|
||||
self.drain_tasks();
|
||||
let action = event.action_masked(&mut ctx.env);
|
||||
// Device (physical) pixels, same as every other Android coordinate
|
||||
// -- divided so a touch lands in the same *logical* space layout
|
||||
// now uses (`AndroidUiState::content_scale`'s field comment).
|
||||
// Without this, `window_size()` reporting logical dims while touch
|
||||
// stayed physical would land every tap off by exactly the density
|
||||
// factor on any phone denser than 1x.
|
||||
let ui_state = self.state.android_state();
|
||||
let content_scale = ui_state.content_scale;
|
||||
let x = event.x(&mut ctx.env) / content_scale;
|
||||
let y = event.y(&mut ctx.env) / content_scale;
|
||||
// Device (physical) pixels, same space layout now uses throughout
|
||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||
let x = event.x(&mut ctx.env);
|
||||
let y = event.y(&mut ctx.env);
|
||||
let ui_state = self.state.android_state_mut();
|
||||
match action {
|
||||
MotionAction::Down => {
|
||||
@@ -564,7 +581,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
height: i32,
|
||||
) {
|
||||
self.drain_tasks();
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// The layout engine's own notion of the canvas size is separate
|
||||
// from the wgpu surface's -- winit's backend sets it from
|
||||
// `WindowEvent::Resized`, and there is no equivalent automatic
|
||||
@@ -573,11 +589,56 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// nothing but the clear colour: the widget tree laid out against
|
||||
// whatever size `UiRenderState::new` starts at instead of the
|
||||
// surface's real one.
|
||||
self.render.resize((width as u32, height as u32));
|
||||
// Drop the old renderer (and the surface it owns) before building
|
||||
// one from the new window -- see `AndroidRenderer`'s doc comment.
|
||||
//
|
||||
// **Physical pixels, matching `AndroidRenderer`'s own
|
||||
// `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
|
||||
// field comment. This call sets `UiRenderState::output_size`, which
|
||||
// every `rel`/`rest` length resolves against and every `abs`
|
||||
// pixel-region compares to directly; a `dp(56)` height now folds
|
||||
// in the density at `Len::apply_rest` time instead of this call
|
||||
// dividing the whole window into a separate logical space, which
|
||||
// is what used to make every `abs`-unit size (a fixed `.height(56)`
|
||||
// in particular) mean something different from a `rest`-based one.
|
||||
self.render.resize((width as f32, height as f32));
|
||||
|
||||
// **Reuse the existing renderer (device, atlas, buffers, bind
|
||||
// groups) when one is already live -- only reconfigure the
|
||||
// surface.** `surfaceChanged` fires on *every* size or format
|
||||
// change, not only on a genuinely new `Surface`/window: showing
|
||||
// the IME under `adjustResize` resizes the same `SurfaceView` and
|
||||
// is reported through this exact callback. Rebuilding the whole
|
||||
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
|
||||
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
|
||||
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
|
||||
// atlas coordinates it had already handed out against the *old*
|
||||
// atlas. Every glyph then drew from a UV rectangle that pointed
|
||||
// into a texture that had just been recreated empty, so text
|
||||
// vanished on the first keyboard open while rects (which never go
|
||||
// through the atlas) kept drawing -- exactly the "rectangles stay,
|
||||
// glyphs disappear" Iris reported. Confirmed by reading this path
|
||||
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
|
||||
// only in `AndroidRenderer::new`) before changing anything, per
|
||||
// AGENTS.md's "verify before finishing".
|
||||
//
|
||||
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
|
||||
// rewrites the window uniform -- device, atlas, buffers and bind
|
||||
// groups are untouched, so the glyph cache's coordinates stay
|
||||
// valid. A genuinely new surface (after `surface_destroyed`, e.g.
|
||||
// backgrounding) still goes through `AndroidRenderer::new` below,
|
||||
// since `renderer` is `None` in that case.
|
||||
let already_live = self.state.android_state().renderer.is_some();
|
||||
if already_live {
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.renderer = None;
|
||||
ui_state
|
||||
.renderer
|
||||
.as_mut()
|
||||
.expect("checked Some above")
|
||||
.resize(width as u32, height as u32);
|
||||
self.render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// `AndroidRenderer::new` used to panic here through wgpu's own
|
||||
// default uncaptured-error handler on a bind-group-layout
|
||||
// validation failure -- exactly what aborted the P0 bench APK on
|
||||
@@ -588,9 +649,30 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// the one place in the app that can turn it into something a
|
||||
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
|
||||
// Java side) are only in scope inside a `ViewPeer` callback.
|
||||
//
|
||||
// `content_scale` reaches `AndroidRenderer` only for the
|
||||
// Diagnostics page's report text now -- window size and the
|
||||
// shader's window uniform are physical pixels throughout (see the
|
||||
// `resize` call above), not divided by it.
|
||||
let content_scale = self.state.android_state().content_scale;
|
||||
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
|
||||
Ok(renderer) => {
|
||||
// A genuinely new renderer means a genuinely new GPU device
|
||||
// and a fresh, empty glyph atlas -- the CPU-side glyph
|
||||
// cache (`TextData::atlas`) and the texture bookkeeping it
|
||||
// is built on (`UiData::textures`) both outlive `renderer`
|
||||
// itself (they live on `self.rsc`, not on `AndroidRenderer`),
|
||||
// so without this they would keep pointing at the *old*
|
||||
// device's now-gone textures -- the app-switch counterpart
|
||||
// to the keyboard-resize glyph wipe this same function's
|
||||
// `already_live` branch above already fixed by reusing the
|
||||
// renderer instead of rebuilding it. One mechanism either
|
||||
// way: this call only runs on the branch that actually
|
||||
// builds a new renderer, exactly where invalidation is
|
||||
// needed, never on the reuse branch, where it would throw
|
||||
// away perfectly valid GPU state for nothing.
|
||||
self.rsc.ui.text.atlas.clear();
|
||||
self.rsc.ui.textures.reset();
|
||||
self.state.android_state_mut().renderer = Some(renderer);
|
||||
self.render(ctx);
|
||||
}
|
||||
@@ -740,6 +822,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
.resources(&mut env)
|
||||
.display_metrics(&mut env)
|
||||
.density(&mut env);
|
||||
log::info!("iris: new_peer content_scale={content_scale}");
|
||||
let vm = env.get_java_vm().unwrap();
|
||||
let global_view = env.new_global_ref(&view.0).unwrap();
|
||||
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
|
||||
@@ -751,15 +834,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
state: Default::default(),
|
||||
_state: PhantomData,
|
||||
};
|
||||
// See `TextData::density`'s field doc for why this is set alongside
|
||||
// `render.set_density` below rather than read from there.
|
||||
rsc.ui.text.density = content_scale;
|
||||
let shared = Rc::new(RefCell::new(Shared::default()));
|
||||
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
|
||||
let mut state = State::new(ui_state, &mut rsc);
|
||||
let platform_vm = env.get_java_vm().unwrap();
|
||||
let platform_view = env.new_global_ref(&view.0).unwrap();
|
||||
state.platform_ready(&mut rsc, platform_vm, platform_view);
|
||||
let mut render = UiRenderState::new();
|
||||
// Every `Len::dp` in the tree resolves against this from now on -- see
|
||||
// `UiRenderState::density`'s field doc and `Len::dp`'s.
|
||||
render.set_density(content_scale);
|
||||
let peer = IrisViewPeer {
|
||||
rsc,
|
||||
render: UiRenderState::new(),
|
||||
render,
|
||||
state,
|
||||
task_recv,
|
||||
};
|
||||
|
||||
+67
-9
@@ -22,6 +22,13 @@ pub trait FocusHost {
|
||||
/// it was hit in (`None` when the widget could not be located, which
|
||||
/// happens for one it was just deselected from).
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||
/// 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
|
||||
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
|
||||
/// swipe over the input bar it brings up the keyboard") from a drag
|
||||
/// continuing inside a field that was already focused (an ordinary
|
||||
/// drag-to-select, unaffected).
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
|
||||
}
|
||||
|
||||
/// Helper shared by every `FocusHost` impl, so the double-click window is
|
||||
@@ -33,6 +40,17 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
|
||||
recent
|
||||
}
|
||||
|
||||
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
|
||||
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
|
||||
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
|
||||
/// frame of a gesture and can tell a completed tap from a drag itself,
|
||||
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
|
||||
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
|
||||
/// brings up the keyboard").
|
||||
fn press_track() -> CursorSenses {
|
||||
CursorSense::click() | CursorSense::Pressing(CursorButton::Left) | CursorSense::unclick()
|
||||
}
|
||||
|
||||
pub struct Selector;
|
||||
|
||||
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
||||
@@ -42,7 +60,7 @@ where
|
||||
type Input = WeakWidget<TextEdit>;
|
||||
|
||||
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
||||
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
|
||||
rsc.register_event(container, press_track(), move |ctx, rsc| {
|
||||
let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
|
||||
let id_pos = region.top_left;
|
||||
let container_pos = ctx
|
||||
@@ -53,14 +71,14 @@ where
|
||||
.top_left;
|
||||
let pos = ctx.data.pos + container_pos - id_pos;
|
||||
let size = region.size();
|
||||
select(
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
pos,
|
||||
size,
|
||||
ctx.data.sense.is_dragging(),
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -75,31 +93,71 @@ where
|
||||
type Input = ();
|
||||
|
||||
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
|
||||
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| {
|
||||
select(
|
||||
rsc.register_event(id, press_track(), move |ctx, rsc| {
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
ctx.data.pos,
|
||||
ctx.data.size,
|
||||
ctx.data.sense.is_dragging(),
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn select(
|
||||
/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
|
||||
/// selectable field. A field that is *already* focused behaves exactly as
|
||||
/// `click_or_drag` always did -- every frame updates the selection, which
|
||||
/// is what lets a finger already inside a focused field drag out a
|
||||
/// selection. A field that is **not** focused withholds `select`'s
|
||||
/// focus-granting side effects (and so the platform-specific `focus_gained`
|
||||
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
|
||||
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
|
||||
/// press began. A drag recognised before release simply cancels the
|
||||
/// pending tap and does nothing further here -- it is not consumed, so
|
||||
/// whatever is behind the field (a list to pan) still sees every frame of
|
||||
/// it, the same as a drag that never touched a selectable field at all.
|
||||
fn on_press(
|
||||
rsc: &mut impl UiRsc,
|
||||
render: &UiRenderState,
|
||||
state: &mut impl FocusHost,
|
||||
id: WeakWidget<TextEdit>,
|
||||
pos: Vec2,
|
||||
size: Vec2,
|
||||
dragging: bool,
|
||||
sense: CursorSense,
|
||||
) {
|
||||
if state.is_focused(id) {
|
||||
let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click();
|
||||
id.edit(rsc).select(pos, size, sense.is_dragging(), recent);
|
||||
return;
|
||||
}
|
||||
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
}
|
||||
CursorSense::Pressing(_) => {
|
||||
let ctx = id.edit(rsc);
|
||||
if let Some(origin) = ctx.text.press_origin
|
||||
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
|
||||
{
|
||||
// Past the slop before release: this is a drag, not a tap
|
||||
// -- give up the pending focus rather than granting it once
|
||||
// the finger lifts wherever it happens to be by then.
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
}
|
||||
CursorSense::PressEnd(_) => {
|
||||
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
|
||||
if was_tap {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).select(pos, size, dragging, recent);
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
state.set_focus(Some(id));
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ impl<T: HasDefaultUiState> FocusHost for T {
|
||||
self.default_state_mut().focus = id;
|
||||
}
|
||||
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.default_state().focus == Some(id)
|
||||
}
|
||||
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
let state = self.default_state_mut();
|
||||
let Some(region) = region else { return };
|
||||
|
||||
@@ -19,8 +19,7 @@ impl Input {
|
||||
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
|
||||
match event {
|
||||
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) / scale_factor;
|
||||
self.cursor.exists = true;
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
@@ -79,7 +78,10 @@ impl DefaultUiState {
|
||||
let window = self.renderer.window();
|
||||
let size = window.inner_size();
|
||||
let scale_factor = window.scale_factor() as f32;
|
||||
Vec2::new(size.width as f32 / scale_factor, size.height as f32 / scale_factor)
|
||||
Vec2::new(
|
||||
size.width as f32 / scale_factor,
|
||||
size.height as f32 / scale_factor,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> &CursorState {
|
||||
|
||||
@@ -163,7 +163,10 @@ impl UiRenderer {
|
||||
// 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 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!");
|
||||
|
||||
|
||||
@@ -183,3 +183,80 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
||||
assert_eq!(mask_delta_before, [0.0, 0.0]);
|
||||
assert_eq!(mask_delta_after, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
|
||||
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
|
||||
/// `rest`-width `TextEdit`, itself the second child of an outer
|
||||
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
|
||||
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
|
||||
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
|
||||
/// is this crate's layout engine or something specific to the real
|
||||
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
|
||||
/// insert exercises the exact redraw path a keystroke does.
|
||||
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
|
||||
let field = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(18)
|
||||
.color(UiColor::WHITE)
|
||||
.add(rsc);
|
||||
let bar = (field.pad(dp(12)).width(rest(1)),)
|
||||
.span(Dir::RIGHT)
|
||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||
.add(rsc);
|
||||
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
|
||||
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
|
||||
(field, tree)
|
||||
}
|
||||
|
||||
/// The reproduction itself. A window this tall stands in for the keyboard
|
||||
/// closed; the second, shorter `resize` stands in for `adjustResize`
|
||||
/// shrinking the surface when the IME opens -- exactly the sequence
|
||||
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
|
||||
/// happens both before and after, since Iris's report was specifically
|
||||
/// that text typed *after* the keyboard was already up did not appear.
|
||||
#[test]
|
||||
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (field, root) = composer_like_tree(&mut rsc);
|
||||
let mut render = UiRenderState::new();
|
||||
|
||||
render.resize((1080.0, 2298.0));
|
||||
render.update(&root, &mut rsc);
|
||||
field.edit(&mut rsc).insert("a");
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let before_px = render.window_region(&field, &rsc).unwrap();
|
||||
// The field is one line plus 12dp of padding on a 2298-tall window --
|
||||
// nowhere near the whole window's height, and anchored at the bottom.
|
||||
assert!(
|
||||
before_px.bot_right.y - before_px.top_left.y < 200.0,
|
||||
"before a resize: {before_px:?}"
|
||||
);
|
||||
assert!(
|
||||
before_px.top_left.y > 1800.0,
|
||||
"expected the bar near the bottom before a resize: {before_px:?}"
|
||||
);
|
||||
|
||||
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
|
||||
// window, then a further keystroke -- the redraw that must land in the
|
||||
// bar's new (also short) region, not whatever region a provisional
|
||||
// measurement pass used along the way.
|
||||
render.resize((1080.0, 1478.0));
|
||||
render.update(&root, &mut rsc);
|
||||
field.edit(&mut rsc).insert("b");
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let after_px = render.window_region(&field, &rsc).unwrap();
|
||||
assert!(
|
||||
after_px.bot_right.y - after_px.top_left.y < 200.0,
|
||||
"after a resize + keystroke: {after_px:?}"
|
||||
);
|
||||
assert!(
|
||||
after_px.top_left.y > 1200.0,
|
||||
"expected the bar near the bottom of the shorter window: {after_px:?}"
|
||||
);
|
||||
}
|
||||
+633
-4
@@ -1,5 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
ops::{BitOr, Deref, DerefMut},
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
@@ -21,6 +22,14 @@ pub enum CursorSense {
|
||||
Hovering,
|
||||
HoverEnd,
|
||||
Scroll,
|
||||
/// Delivered exactly once, in place of `PressEnd`, to whichever widget
|
||||
/// currently holds pointer capture (`UiRenderState::capture_pointer`)
|
||||
/// when the button lifts -- see `iris::sense`'s pointer-capture doc
|
||||
/// and `DragGesture`. A widget must register this explicitly (it is
|
||||
/// never bundled into `click_or_drag`/`unclick`, since most widgets
|
||||
/// never call `capture_pointer` and have no use for it) to receive it
|
||||
/// at all; ordinary hit-tested widgets keep seeing `PressEnd`.
|
||||
Drop,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -30,6 +39,21 @@ impl Event for CursorSenses {
|
||||
type Data<'a> = CursorData<'a>;
|
||||
type State = SensorState;
|
||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||
// `Drop` is never derived from raw cursor/hover state below (the
|
||||
// free `should_run`'s own arm for it is only ever asked here,
|
||||
// never independently true or false against the button) -- it is
|
||||
// set exclusively by `run_sensors`' pointer-capture branch, which
|
||||
// has already decided this exact frame is the captured widget's
|
||||
// terminal event. Matching it by identity, ahead of the general
|
||||
// derivation, matters because a captured widget's registration
|
||||
// list very likely also carries `PressEnd` (`unclick()`, for the
|
||||
// ordinary un-captured case) -- the same button-lift condition
|
||||
// `PressEnd` matches on, so falling through to the loop below
|
||||
// would let whichever of the two happens to be registered first
|
||||
// win, silently swallowing the `Drop` a caller relied on.
|
||||
if data.sense == CursorSense::Drop {
|
||||
return self.contains(&CursorSense::Drop).then(|| data.clone());
|
||||
}
|
||||
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
|
||||
let mut data = data.clone();
|
||||
data.sense = sense;
|
||||
@@ -176,6 +200,48 @@ impl SensorUi for UiRenderState {
|
||||
cursor: CursorState,
|
||||
window_size: Vec2,
|
||||
) {
|
||||
// Exclusive pointer capture (`UiRenderState::capture_pointer`,
|
||||
// `DragGesture`): once some widget has committed to a drag, every
|
||||
// other widget sees nothing from this pointer at all -- no hover,
|
||||
// no click, no press -- until it releases. This is what lets a
|
||||
// fast pan or a selection keep going once the finger has moved
|
||||
// off whatever hit region first noticed the press (including
|
||||
// right off the end of the gesture, at `PressEnd`/`Cancel`): a
|
||||
// per-widget hit test would otherwise silently stop delivering to
|
||||
// *anyone* the moment the pointer left every registered region,
|
||||
// which is exactly what used to leave a fling never started (no
|
||||
// widget ever saw the release). The captured widget keeps getting
|
||||
// ordinary `Pressing` frames while the button is down and gets
|
||||
// exactly one `Drop` -- not `PressEnd` -- the frame it lifts,
|
||||
// which also releases the capture.
|
||||
if let Some(id) = self.captured_pointer() {
|
||||
let Some(shape) = self.resolved_region(&id, rsc) else {
|
||||
self.release_pointer();
|
||||
return;
|
||||
};
|
||||
let region = shape.to_px(window_size);
|
||||
let button_down = cursor.buttons.select(&CursorButton::Left).is_on();
|
||||
let sense = if button_down {
|
||||
CursorSense::Pressing(CursorButton::Left)
|
||||
} else {
|
||||
CursorSense::Drop
|
||||
};
|
||||
let data = CursorData {
|
||||
pos: cursor.pos - region.top_left,
|
||||
size: region.bot_right - region.top_left,
|
||||
scroll_delta: cursor.scroll_delta,
|
||||
hover: ActivationState::On,
|
||||
cursor: cursor.clone(),
|
||||
sense,
|
||||
render: self,
|
||||
};
|
||||
rsc.run_event::<CursorSense>(id, data, state);
|
||||
if !button_down {
|
||||
self.release_pointer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// in order to remove this take, need to store active list in UiRenderState somehow
|
||||
// this would probably be done through a generic parameter that adds yet another rsc /
|
||||
// state like thing, but local to render state, and is passed to UiRsc events so you can
|
||||
@@ -265,6 +331,15 @@ pub fn should_run(
|
||||
CursorSense::Hovering => hover.is_on(),
|
||||
CursorSense::HoverEnd => hover.is_end(),
|
||||
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
|
||||
// Never derived here -- `Drop` only ever fires through
|
||||
// `CursorSenses::should_run`'s own special case, ahead of this
|
||||
// loop, for the one widget `run_sensors`' capture branch is
|
||||
// delivering it to this frame. If this arm answered from raw
|
||||
// button state instead, an ordinary hit-tested widget that
|
||||
// happened to register `Drop` (with no capture involved at
|
||||
// all) would see it fire on every plain button-up under the
|
||||
// cursor.
|
||||
CursorSense::Drop => false,
|
||||
} {
|
||||
return Some(*sense);
|
||||
}
|
||||
@@ -491,7 +566,25 @@ impl DragArbiter {
|
||||
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||
self.state = ArbiterState::Panning;
|
||||
self.last = pos;
|
||||
DragOutcome::Pan(dy)
|
||||
// `dy` here is the *whole* drag since `press_start`,
|
||||
// not since the last frame -- nothing panned while
|
||||
// `Undecided` was withholding the slop, so applying it
|
||||
// in full on this one frame is a visible jump the
|
||||
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
|
||||
// "scrolling down sometimes jitters the text," root-
|
||||
// caused by tracing `List`'s per-frame offset against
|
||||
// a synthetic monotonic drag: the offset held flat for
|
||||
// every `Undecided` frame, then stepped by several
|
||||
// frames' worth of motion at once on the frame slop
|
||||
// was crossed, before resuming ordinary per-frame
|
||||
// deltas). Only the excess past the slop threshold is
|
||||
// real, undecided motion the reader hasn't seen
|
||||
// reflected yet -- so only that excess is applied now,
|
||||
// the same way Android's own touch handling consumes
|
||||
// `ViewConfiguration.getScaledTouchSlop()` once from
|
||||
// the first scroll past it rather than replaying the
|
||||
// whole pre-threshold drag in one step.
|
||||
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
|
||||
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||
&& dx.abs() <= DRAG_SLOP
|
||||
&& dy.abs() <= DRAG_SLOP
|
||||
@@ -510,6 +603,522 @@ impl DragArbiter {
|
||||
pub fn release(&mut self) {
|
||||
self.state = ArbiterState::Idle;
|
||||
}
|
||||
|
||||
/// Whether the arbiter's current gesture (if any) has committed to
|
||||
/// panning -- what a caller checks at release time to decide whether
|
||||
/// to hand the tracked velocity to [`crate::widget::List::fling`], per
|
||||
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
|
||||
/// a pan, never a text selection that happened to end with the finger
|
||||
/// still moving.
|
||||
pub fn is_panning(&self) -> bool {
|
||||
matches!(self.state, ArbiterState::Panning)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
|
||||
/// one further state a shared gesture needs: the drag ending, with the
|
||||
/// released velocity if (and only if) it had committed to panning.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum GestureOutcome {
|
||||
Undecided,
|
||||
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
|
||||
/// convention (`List::scroll`'s, for a transcript) to apply.
|
||||
Pan(f32),
|
||||
SelectStart,
|
||||
SelectExtend,
|
||||
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
|
||||
/// `Some(velocity)` only if the gesture had committed to panning
|
||||
/// (never a tap, a long-press selection, or one still `Undecided`);
|
||||
/// same units as `Pan`, so a caller hands it to `List::fling` with
|
||||
/// whatever sign flip it already applies to `Pan`.
|
||||
Released(Option<f32>),
|
||||
}
|
||||
|
||||
/// Bundles a [`DragArbiter`] and a [`VelocityTracker`] into the one thing
|
||||
/// most drag-driven widgets need: arbitrate pan-vs-hold, track the pan's
|
||||
/// velocity, and take pointer capture (`UiRenderState::capture_pointer`)
|
||||
/// the moment the gesture commits so the rest of it -- including the
|
||||
/// terminal release -- keeps reaching the same widget even after the
|
||||
/// finger has moved off whatever hit region first noticed the press. Iris
|
||||
/// asked for this to live here rather than in `transcript-ui::Selection`
|
||||
/// (2026-09-06, recorded in `IRIS.md`): "dragging should be part of the
|
||||
/// default input system ... anything that provides good performance and
|
||||
/// can be generalized well is part of iris rather than the app." A caller
|
||||
/// still decides what a committed pan or a completed selection *means*
|
||||
/// (transcript-ui's pan-vs-select is one call site; a slider or a plain
|
||||
/// scroll area is another) -- this only owns the *mechanics* every one of
|
||||
/// them would otherwise duplicate.
|
||||
pub struct DragGesture {
|
||||
arbiter: DragArbiter,
|
||||
velocity: VelocityTracker,
|
||||
}
|
||||
|
||||
impl Default for DragGesture {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DragGesture {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
arbiter: DragArbiter::new(),
|
||||
velocity: VelocityTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this gesture has no press in flight -- a thin passthrough
|
||||
/// to the underlying `DragArbiter::is_idle`, for a caller (a test, a
|
||||
/// diagnostic) that wants to observe the recovery behaviour `handle`'s
|
||||
/// idle-recovery branch documents without reaching into a private
|
||||
/// field.
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.arbiter.is_idle()
|
||||
}
|
||||
|
||||
/// Feed one frame of a gesture through. `id` is the widget iris should
|
||||
/// give exclusive pointer input to once this gesture commits to
|
||||
/// panning or selecting -- a stable widget that outlives the gesture
|
||||
/// (a `List`'s own id, not one of its virtualised rows, which can be
|
||||
/// retired mid-drag as content scrolls). `render` is `CursorData`'s
|
||||
/// own field, already in hand at every call site. `already_selected`
|
||||
/// only matters for the first frame of a gesture (`PressStart`, or the
|
||||
/// recovery branch below) -- see `DragArbiter::press_start`'s doc.
|
||||
pub fn handle(
|
||||
&mut self,
|
||||
render: &UiRenderState,
|
||||
id: WidgetId,
|
||||
sense: CursorSense,
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
already_selected: bool,
|
||||
) -> GestureOutcome {
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
self.velocity.reset();
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.dispatch(render, id, pos_window, now)
|
||||
}
|
||||
CursorSense::Drop | CursorSense::PressEnd(_) => {
|
||||
let released = if self.arbiter.is_panning() {
|
||||
Some(self.velocity.velocity())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.arbiter.release();
|
||||
render.release_pointer();
|
||||
GestureOutcome::Released(released)
|
||||
}
|
||||
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
|
||||
// arrive with no matching `PressStart` if the touch-down
|
||||
// landed outside whichever hit region first noticed it.
|
||||
_ if self.arbiter.is_idle() => {
|
||||
self.velocity.reset();
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.dispatch(render, id, pos_window, now)
|
||||
}
|
||||
_ => self.dispatch(render, id, pos_window, now),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
&mut self,
|
||||
render: &UiRenderState,
|
||||
id: WidgetId,
|
||||
pos: Vec2,
|
||||
now: Instant,
|
||||
) -> GestureOutcome {
|
||||
match self.arbiter.update(pos, now) {
|
||||
DragOutcome::Undecided => GestureOutcome::Undecided,
|
||||
DragOutcome::Pan(dy) => {
|
||||
render.capture_pointer(id);
|
||||
self.velocity.add_sample(dy, now);
|
||||
GestureOutcome::Pan(dy)
|
||||
}
|
||||
DragOutcome::SelectStart => {
|
||||
render.capture_pointer(id);
|
||||
GestureOutcome::SelectStart
|
||||
}
|
||||
DragOutcome::SelectExtend => {
|
||||
render.capture_pointer(id);
|
||||
GestureOutcome::SelectExtend
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far back a [`VelocityTracker`] looks when estimating a fling's
|
||||
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
|
||||
/// short window so a gesture's last flick dominates over its slower start.
|
||||
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
|
||||
/// a release can be handed a realistic initial velocity for
|
||||
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
|
||||
/// noisy last delta. Fed one timestamped pan delta per frame
|
||||
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
|
||||
/// `Pan` outcome already carries) and answers `velocity()` in units per
|
||||
/// second, matching whatever unit the deltas were in.
|
||||
#[derive(Default)]
|
||||
pub struct VelocityTracker {
|
||||
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
|
||||
/// on every `add_sample` -- so this never grows past however many
|
||||
/// frames land in that window.
|
||||
samples: VecDeque<(Instant, f32)>,
|
||||
}
|
||||
|
||||
impl VelocityTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Forget everything -- called on a fresh press, so a new gesture's
|
||||
/// velocity is never contaminated by the tail of the previous one.
|
||||
pub fn reset(&mut self) {
|
||||
self.samples.clear();
|
||||
}
|
||||
|
||||
/// Record one frame's motion. `delta` is this frame's movement since
|
||||
/// the last sample, not a cumulative position.
|
||||
pub fn add_sample(&mut self, delta: f32, at: Instant) {
|
||||
self.samples.push_back((at, delta));
|
||||
while let Some(&(when, _)) = self.samples.front() {
|
||||
if at.duration_since(when) > VELOCITY_WINDOW {
|
||||
self.samples.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The estimated speed, in units-per-second, over whatever samples
|
||||
/// currently fall inside the tracking window: total motion divided by
|
||||
/// the elapsed time between the oldest and newest sample still held.
|
||||
/// `0.0` with fewer than two samples (no time span to divide by).
|
||||
pub fn velocity(&self) -> f32 {
|
||||
if self.samples.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
||||
let span = self
|
||||
.samples
|
||||
.back()
|
||||
.unwrap()
|
||||
.0
|
||||
.duration_since(self.samples.front().unwrap().0)
|
||||
.as_secs_f32();
|
||||
if span <= 0.0 { 0.0 } else { total / span }
|
||||
}
|
||||
}
|
||||
|
||||
/// Android's fling deceleration curve, ported from AOSP's
|
||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
||||
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
||||
/// asked for the same physics a reader's muscle memory already expects
|
||||
/// from every other Android scroll view.
|
||||
///
|
||||
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
|
||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
||||
/// between the two bracketing samples.
|
||||
mod android_fling_spline {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const NB_SAMPLES: usize = 100;
|
||||
/// Where the two cubic tension lines cross (AOSP's own constant name
|
||||
/// and value, `SplineOverScroller.INFLEXION`).
|
||||
pub(super) const INFLEXION: f32 = 0.35;
|
||||
const START_TENSION: f32 = 0.5;
|
||||
const END_TENSION: f32 = 1.0;
|
||||
const P1: f32 = START_TENSION * INFLEXION;
|
||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||
|
||||
pub(super) struct Spline {
|
||||
position: [f32; NB_SAMPLES + 1],
|
||||
time: [f32; NB_SAMPLES + 1],
|
||||
}
|
||||
|
||||
fn build() -> Spline {
|
||||
let mut position = [0.0f32; NB_SAMPLES + 1];
|
||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
||||
for i in 0..NB_SAMPLES {
|
||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||
|
||||
let mut x_max = 1.0f32;
|
||||
let (mut x, mut coef);
|
||||
loop {
|
||||
x = x_min + (x_max - x_min) / 2.0;
|
||||
coef = 3.0 * x * (1.0 - x);
|
||||
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||
if (tx - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if tx > alpha {
|
||||
x_max = x;
|
||||
} else {
|
||||
x_min = x;
|
||||
}
|
||||
}
|
||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||
|
||||
let mut y_max = 1.0f32;
|
||||
let (mut y, mut coef_y);
|
||||
loop {
|
||||
y = y_min + (y_max - y_min) / 2.0;
|
||||
coef_y = 3.0 * y * (1.0 - y);
|
||||
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
|
||||
if (dy - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if dy > alpha {
|
||||
y_max = y;
|
||||
} else {
|
||||
y_min = y;
|
||||
}
|
||||
}
|
||||
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
|
||||
}
|
||||
position[NB_SAMPLES] = 1.0;
|
||||
time[NB_SAMPLES] = 1.0;
|
||||
Spline { position, time }
|
||||
}
|
||||
|
||||
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
||||
|
||||
/// The fraction of total distance covered at `time_fraction` (0..=1
|
||||
/// of the fling's total duration). Finds the bracketing samples in
|
||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
||||
/// .flingPosition` does.
|
||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
||||
let spline = SPLINE.get_or_init(build);
|
||||
let t = time_fraction.clamp(0.0, 1.0);
|
||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
||||
let t_inf = spline.time[index];
|
||||
let t_sup = spline.time[index + 1];
|
||||
let d_inf = spline.position[index];
|
||||
let d_sup = spline.position[index + 1];
|
||||
let span = t_sup - t_inf;
|
||||
if span <= 0.0 {
|
||||
d_inf
|
||||
} else {
|
||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AOSP `SplineOverScroller`'s two other physical constants: the default
|
||||
/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a
|
||||
/// friction of `0.84` per frame at 60Hz corresponds to
|
||||
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
||||
const FLING_FRICTION: f32 = 0.015;
|
||||
fn deceleration_rate() -> f32 {
|
||||
(0.78f32.ln()) / (0.9f32.ln())
|
||||
}
|
||||
const GRAVITY_EARTH: f32 = 9.80665;
|
||||
|
||||
/// Turns an initial fling velocity into a total travel distance and
|
||||
/// duration, following AOSP `SplineOverScroller`'s own closed-form
|
||||
/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) --
|
||||
/// ported the same way Compose's `FlingCalculator` is, including its
|
||||
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
||||
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
||||
/// velocity/distance units cancel algebraically as long as velocity and
|
||||
/// the returned distance share one pixel space (physical or logical) --
|
||||
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
|
||||
/// avoid needing a display density of its own, since iris's `List`
|
||||
/// already works in logical (density-independent) pixels throughout.
|
||||
pub struct FlingCalculator {
|
||||
physical_coefficient: f32,
|
||||
}
|
||||
|
||||
impl FlingCalculator {
|
||||
pub fn new(density: f32) -> Self {
|
||||
Self {
|
||||
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
|
||||
}
|
||||
}
|
||||
|
||||
fn deceleration_for(&self, velocity: f32) -> f32 {
|
||||
(android_fling_spline::INFLEXION * velocity.abs()
|
||||
/ (FLING_FRICTION * self.physical_coefficient))
|
||||
.ln()
|
||||
}
|
||||
|
||||
/// Total signed distance the fling travels before settling, in the
|
||||
/// same pixel units `velocity` was given in.
|
||||
pub fn distance(&self, velocity: f32) -> f32 {
|
||||
if velocity == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let l = self.deceleration_for(velocity);
|
||||
let rate = deceleration_rate();
|
||||
let magnitude =
|
||||
FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp();
|
||||
magnitude.copysign(velocity)
|
||||
}
|
||||
|
||||
/// How long the fling takes to settle.
|
||||
pub fn duration(&self, velocity: f32) -> Duration {
|
||||
if velocity == 0.0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let l = self.deceleration_for(velocity);
|
||||
let rate = deceleration_rate();
|
||||
Duration::from_secs_f32((l / (rate - 1.0)).exp())
|
||||
}
|
||||
|
||||
/// The signed distance covered by `elapsed` into a fling of this
|
||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
||||
/// `duration()`, so a caller need not special-case "past the end."
|
||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||
let duration = self.duration(velocity);
|
||||
if duration.is_zero() {
|
||||
return 0.0;
|
||||
}
|
||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_tracker_tests {
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// A single fixed base rather than a fresh `Instant::now()` per call --
|
||||
// computing it once per test keeps every sample's spacing exact
|
||||
// instead of at the mercy of however long the test itself takes to
|
||||
// run between calls, the same reasoning `drag_arbiter_tests::t` uses.
|
||||
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||
|
||||
fn t(ms: u64) -> Instant {
|
||||
*BASE + Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_samples_reports_zero() {
|
||||
let mut v = VelocityTracker::new();
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
v.add_sample(10.0, t(0));
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steady_drag_reports_its_own_speed() {
|
||||
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
|
||||
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
|
||||
// span between the oldest and newest held sample" definition.
|
||||
let mut v = VelocityTracker::new();
|
||||
for i in 0..=10 {
|
||||
v.add_sample(5.0, t(i * 10));
|
||||
}
|
||||
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_100ms_of_samples_count() {
|
||||
// An old, fast burst well outside the window followed by a slow,
|
||||
// steady drag should report the recent speed, not the average of
|
||||
// both -- otherwise a flick that trails off would still fling at
|
||||
// its earlier, faster speed. The burst sits 110ms before the last
|
||||
// sample, just past the 100ms window, so it is evicted.
|
||||
let mut v = VelocityTracker::new();
|
||||
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
|
||||
for i in 1..=11 {
|
||||
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
|
||||
}
|
||||
assert!(
|
||||
(v.velocity() - 110.0).abs() < 5.0,
|
||||
"old burst leaked into the window: got {}",
|
||||
v.velocity()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_forgets_prior_samples() {
|
||||
let mut v = VelocityTracker::new();
|
||||
v.add_sample(500.0, t(0));
|
||||
v.add_sample(500.0, t(10));
|
||||
assert!(v.velocity() != 0.0);
|
||||
v.reset();
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fling_calculator_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_velocity_flings_nowhere() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
assert_eq!(calc.distance(0.0), 0.0);
|
||||
assert_eq!(calc.duration(0.0), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_grows_with_velocity_and_keeps_its_sign() {
|
||||
let calc = FlingCalculator::new(2.75); // a typical phone's density
|
||||
let d_slow = calc.distance(2000.0);
|
||||
let d_fast = calc.distance(12000.0);
|
||||
assert!(d_slow > 0.0);
|
||||
assert!(d_fast > d_slow);
|
||||
assert_eq!(calc.distance(-12000.0), -d_fast);
|
||||
}
|
||||
|
||||
/// Summing the spline's own per-frame position deltas across the
|
||||
/// whole fling has to land within 1% of the closed-form `distance()`
|
||||
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads
|
||||
/// of `position_at` actually add up to the total the fling promised,
|
||||
/// not merely that the two formulas look plausible independently.
|
||||
#[test]
|
||||
fn integrating_position_at_matches_the_closed_form_distance() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] {
|
||||
let total = calc.distance(velocity);
|
||||
let duration = calc.duration(velocity);
|
||||
let final_position = calc.position_at(velocity, duration);
|
||||
let err = (final_position - total).abs() / total.abs();
|
||||
assert!(
|
||||
err < 0.01,
|
||||
"velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
let velocity = 12000.0f32;
|
||||
let duration = calc.duration(velocity);
|
||||
let total = calc.distance(velocity);
|
||||
let mut last = 0.0;
|
||||
let mut t = Duration::ZERO;
|
||||
while t < duration {
|
||||
let p = calc.position_at(velocity, t);
|
||||
assert!(p >= last - 0.01, "position went backwards at {t:?}");
|
||||
last = p;
|
||||
t += Duration::from_millis(16);
|
||||
}
|
||||
// Well past the end, it stays pinned at the total -- a caller
|
||||
// must be able to ask "where would this fling be" without first
|
||||
// checking whether it has already settled.
|
||||
assert_eq!(
|
||||
calc.position_at(velocity, duration + Duration::from_secs(5)),
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,9 +1143,13 @@ mod drag_arbiter_tests {
|
||||
fn a_vertical_drag_pans_immediately() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
// The transition frame applies only the motion past `DRAG_SLOP`
|
||||
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
|
||||
// `Pan` arm's own comment for why replaying the whole withheld
|
||||
// drag in one step is the scroll-jitter bug this guards against.
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
// Subsequent frames keep panning, by the delta since last frame.
|
||||
assert_eq!(
|
||||
@@ -545,6 +1158,22 @@ mod drag_arbiter_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct regression test for the fix: a slow drag that crosses
|
||||
/// `DRAG_SLOP` by only a fraction of a pixel must not still produce a
|
||||
/// visible jump -- the amount applied on the crossing frame should
|
||||
/// itself shrink toward zero as the crossing gets closer to exactly
|
||||
/// `DRAG_SLOP`, rather than always dumping the whole pre-threshold
|
||||
/// distance at once.
|
||||
#[test]
|
||||
fn crossing_the_slop_by_a_little_pans_by_a_little() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
|
||||
DragOutcome::Pan(0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
||||
let mut a = DragArbiter::new();
|
||||
@@ -603,7 +1232,7 @@ mod drag_arbiter_tests {
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,7 +1275,7 @@ mod drag_arbiter_tests {
|
||||
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 720.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
assert!(!a.is_idle());
|
||||
}
|
||||
|
||||
@@ -122,3 +122,127 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
+394
-8
@@ -102,7 +102,7 @@
|
||||
|
||||
use crate::prelude::*;
|
||||
use iris_core::util::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::{collections::VecDeque, sync::Arc, time::Instant};
|
||||
|
||||
/// A stable identifier for a loaded row, reused across pages so that a row
|
||||
/// already measured and drawn is not treated as new when data is inserted
|
||||
@@ -213,6 +213,39 @@ pub struct List {
|
||||
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
|
||||
/// however many rows are currently loaded.
|
||||
heights: HashMap<RowKey, f32>,
|
||||
/// A fling in progress, or `None` if the list is at rest -- see
|
||||
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
|
||||
/// momentum."
|
||||
fling: Option<Fling>,
|
||||
/// What `tick_fling` re-arms every frame a fling is still running, so
|
||||
/// the list keeps animating without needing a caller to poll it --
|
||||
/// set once via `set_redraw_handle` by whoever owns the surface this
|
||||
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
|
||||
/// hands out elsewhere). `None` for a list that never flings
|
||||
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||
/// `bench_client.rs`'s scripted phases do).
|
||||
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||
/// 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
|
||||
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||
/// the start against. Stale (from whatever the last draw found) on a
|
||||
/// list that hasn't drawn yet; `false` by default, matching "assume
|
||||
/// there is more content until a draw proves otherwise."
|
||||
at_start: bool,
|
||||
/// The mirror of `at_start` for the newest end.
|
||||
at_end: bool,
|
||||
}
|
||||
|
||||
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
|
||||
/// much of its total distance has already been applied to the anchor, so
|
||||
/// `tick_fling` only ever moves the list by this frame's *incremental*
|
||||
/// delta -- matching every other place in this widget that scrolls by
|
||||
/// writing `anchor.offset`.
|
||||
struct Fling {
|
||||
calc: FlingCalculator,
|
||||
velocity: f32,
|
||||
started_at: Instant,
|
||||
applied: f32,
|
||||
}
|
||||
|
||||
impl List {
|
||||
@@ -226,6 +259,10 @@ impl List {
|
||||
snap_end: true,
|
||||
viewport_len: 0.0,
|
||||
last_viewport_len: 0.0,
|
||||
fling: None,
|
||||
redraw: None,
|
||||
at_start: false,
|
||||
at_end: false,
|
||||
pending_tap: None,
|
||||
extents: HashMap::default(),
|
||||
heights: HashMap::default(),
|
||||
@@ -361,6 +398,115 @@ impl List {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give this list a way to ask for another frame on its own, so a
|
||||
/// fling keeps animating without a caller polling it every tick --
|
||||
/// see the `redraw` field's doc. Pass the same handle
|
||||
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
|
||||
/// that never calls this can still `fling`, but has to be driven by a
|
||||
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
|
||||
/// exactly that, since they need to await settling rather than let it
|
||||
/// run in the background).
|
||||
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
|
||||
self.redraw = Some(handle);
|
||||
}
|
||||
|
||||
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
|
||||
/// space, same sign convention as `scroll`'s `amt`: positive continues
|
||||
/// moving later content into view). Cancels any fling already in
|
||||
/// progress. A caller with a live touch/press must cancel this on the
|
||||
/// next touch-down (`cancel_fling`) -- `AndroidFlingSpline`'s curve
|
||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||
/// relies on the view calling `abortAnimation` for the same reason.
|
||||
///
|
||||
/// Density cancels out of the underlying spline as long as velocity
|
||||
/// and the distance it produces share one pixel space (see
|
||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
||||
/// it is the correct density for a self-consistent unit system.
|
||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
||||
self.fling = None;
|
||||
return;
|
||||
}
|
||||
self.fling = Some(Fling {
|
||||
calc: FlingCalculator::new(1.0),
|
||||
velocity: velocity_px_per_s,
|
||||
started_at: Instant::now(),
|
||||
applied: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a fling is currently animating. What a caller's own
|
||||
/// per-frame loop polls to know when to stop driving `tick_fling`
|
||||
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
|
||||
/// "moving on its own" for any other purpose.
|
||||
pub fn is_scrolling(&self) -> bool {
|
||||
self.fling.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any fling in progress with no further movement -- the next
|
||||
/// touch-down's job, per `fling`'s own doc.
|
||||
pub fn cancel_fling(&mut self) {
|
||||
self.fling = None;
|
||||
}
|
||||
|
||||
/// Advance an in-flight fling to `now`, applying this call's share of
|
||||
/// its total travel via `scroll` and re-arming this list's own redraw
|
||||
/// handle (if it has one) for another frame. Returns whether the
|
||||
/// fling is still going after this call -- `false` either because it
|
||||
/// settled on its own spline-decided schedule or because it reached
|
||||
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
|
||||
/// the list past content that does not exist, unlike an ordinary
|
||||
/// touch-pan, which this widget already leaves unclamped by design).
|
||||
///
|
||||
/// Safe to call even with no fling active (a no-op returning `false`),
|
||||
/// so a caller does not need to check `is_scrolling` first.
|
||||
pub fn tick_fling(&mut self, now: Instant) -> bool {
|
||||
let Some(f) = &mut self.fling else {
|
||||
return false;
|
||||
};
|
||||
let elapsed = now.saturating_duration_since(f.started_at);
|
||||
let target = f.calc.position_at(f.velocity, elapsed);
|
||||
let delta = target - f.applied;
|
||||
f.applied = target;
|
||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||
let velocity = f.velocity;
|
||||
self.scroll(delta);
|
||||
|
||||
// Clamp: a fling moving toward the start that has already reached
|
||||
// it (or one moving toward the end that has already reached that)
|
||||
// stops rather than continuing to spend its remaining distance on
|
||||
// a part of the list that will never scroll further.
|
||||
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
|
||||
|
||||
if settled_on_schedule || hit_bound {
|
||||
self.fling = None;
|
||||
return false;
|
||||
}
|
||||
if let Some(redraw) = &self.redraw {
|
||||
redraw.request_redraw();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The anchor's own row index and pixel offset, formatted the same
|
||||
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
|
||||
/// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling
|
||||
/// phase reads before/after/between its fling runs so the two apps'
|
||||
/// travel can be compared directly. `more_before`/`more_after`
|
||||
/// sentinels print as `idx=more-before`/`idx=more-after` rather than
|
||||
/// leaking their internal `isize` representation; `idx=none` if the
|
||||
/// list has never drawn (no anchor yet -- e.g. right after
|
||||
/// `jump_to_end` and before the next frame runs `repair_anchor`).
|
||||
pub fn anchor_position_display(&self) -> String {
|
||||
match self.anchor {
|
||||
None => "idx=none".to_string(),
|
||||
Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(),
|
||||
Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(),
|
||||
Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap to the newest content (last item, or the `more_after`
|
||||
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
||||
pub fn jump_to_end(&mut self) {
|
||||
@@ -406,6 +552,20 @@ impl List {
|
||||
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 {
|
||||
match slot {
|
||||
BEFORE_SLOT => self.more_before.is_some(),
|
||||
@@ -606,9 +766,10 @@ impl List {
|
||||
let axis = self.axis;
|
||||
let output_len = painter.output_size().axis(axis);
|
||||
let container_len = painter.region().axis(axis).len();
|
||||
let density = painter.density();
|
||||
let resolve = move |used: Size| -> f32 {
|
||||
used.axis(axis)
|
||||
.apply_rest()
|
||||
.apply_rest(density)
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len)
|
||||
};
|
||||
@@ -724,26 +885,33 @@ impl Widget for List {
|
||||
};
|
||||
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
||||
|
||||
let mut idx = anchor.slot;
|
||||
let mut idx_top = anchor.slot;
|
||||
while top > 0.0 {
|
||||
let Some(prev) = self.prev_slot(idx) else {
|
||||
let Some(prev) = self.prev_slot(idx_top) else {
|
||||
break;
|
||||
};
|
||||
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
||||
top = t;
|
||||
idx = prev;
|
||||
idx_top = prev;
|
||||
}
|
||||
|
||||
idx = anchor.slot;
|
||||
let mut idx_bottom = anchor.slot;
|
||||
while bottom < self.viewport_len {
|
||||
let Some(next) = self.next_slot(idx) else {
|
||||
let Some(next) = self.next_slot(idx_bottom) else {
|
||||
break;
|
||||
};
|
||||
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
||||
bottom = b;
|
||||
idx = next;
|
||||
idx_bottom = next;
|
||||
}
|
||||
|
||||
// What `tick_fling` clamps a fling against -- see `at_start`'s
|
||||
// field doc. `top`/`bottom` are the extreme edges actually placed
|
||||
// this frame, and `prev_slot`/`next_slot` returning `None` is what
|
||||
// "no more content" means everywhere else in this widget.
|
||||
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
||||
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
||||
|
||||
self.update_snap_end();
|
||||
Size::REST
|
||||
}
|
||||
@@ -1177,4 +1345,222 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Enough rows, tall enough, that a fling toward the start has real
|
||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||
/// tests below.
|
||||
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
|
||||
let (list_weak, root) = add_list(rsc, list);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 600.0));
|
||||
render.update(&root, rsc);
|
||||
(list_weak, root, render)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_moves_the_list_and_then_settles() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
|
||||
// A fling toward the start: negative velocity, matching `scroll`'s
|
||||
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
|
||||
// downward finger motion revealing older content).
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_still_scrolling = true;
|
||||
for step in 0..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !last_still_scrolling {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!last_still_scrolling,
|
||||
"fling never settled within 600 steps"
|
||||
);
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_distance_is_positive_toward_the_end() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// Start scrolled away from the newest end so there is room for an
|
||||
// end-ward fling to actually move.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
render.update(&root, &mut rsc);
|
||||
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0];
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||
let start = Instant::now();
|
||||
for step in 0..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);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
// Row 0 either scrolled out of the loaded extents (flung well past
|
||||
// it) or moved upward (smaller top) -- either way, real motion
|
||||
// happened toward the end rather than staying put.
|
||||
if let Some(after) = list_ref.extents.get(&0) {
|
||||
assert!(
|
||||
after.top < before.top,
|
||||
"fling toward the end did not move content up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_fling_stops_it_with_no_further_movement() {
|
||||
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().fling(-8000.0);
|
||||
let start = Instant::now();
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.tick_fling(start + std::time::Duration::from_millis(16));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||
// A tick after cancelling must be a no-op -- this is what a fresh
|
||||
// touch-down relies on to stop a fling in its tracks.
|
||||
let still = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.tick_fling(start + std::time::Duration::from_millis(200));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(!still);
|
||||
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_toward_the_start_stops_at_the_first_row() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// An enormous velocity that would travel far past all 200 rows if
|
||||
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
|
||||
// better for stress testing" fling asks for.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
|
||||
let start = Instant::now();
|
||||
for step in 0..2000 {
|
||||
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);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(
|
||||
list_ref.at_start,
|
||||
"fling should have clamped at the first row"
|
||||
);
|
||||
let first = list_ref.extents[&0];
|
||||
assert!(
|
||||
first.top >= -0.5,
|
||||
"clamped fling overshot the first row's top: {}",
|
||||
first.top
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_position_display_before_any_draw_is_none() {
|
||||
let list = List::new(Axis::Y);
|
||||
assert_eq!(list.anchor_position_display(), "idx=none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_position_display_reports_slot_and_offset() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
let _ = (&root, &mut render);
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(list_ref.anchor_position_display().starts_with("idx="));
|
||||
assert!(!list_ref.anchor_position_display().contains("none"));
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,15 @@ impl Widget for Aligned {
|
||||
// already-resolved region double-applies that composition and is
|
||||
// wrong for any widget nested below the root.
|
||||
let used = painter.widget(&self.inner);
|
||||
let density = painter.density();
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }),
|
||||
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
|
||||
(Some(x), None) => {
|
||||
let x = used.x.apply_rest().align(x);
|
||||
let x = used.x.apply_rest(density).align(x);
|
||||
UiRegion::new(x, UiSpan::FULL)
|
||||
}
|
||||
(None, Some(y)) => {
|
||||
let y = used.y.apply_rest().align(y);
|
||||
let y = used.y.apply_rest(density).align(y);
|
||||
UiRegion::new(UiSpan::FULL, y)
|
||||
}
|
||||
(None, None) => UiRegion::FULL,
|
||||
|
||||
@@ -9,12 +9,12 @@ pub struct MaxSize {
|
||||
impl MaxSize {
|
||||
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
||||
/// rel/abs/rest components are not otherwise comparable.
|
||||
fn clamp(len: Len, max: Option<Len>, output: f32) -> Len {
|
||||
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
|
||||
let Some(max) = max else {
|
||||
return len;
|
||||
};
|
||||
let len_px = len.apply_rest().to_abs(output);
|
||||
let max_px = max.apply_rest().to_abs(output);
|
||||
let len_px = len.apply_rest(density).to_abs(output);
|
||||
let max_px = max.apply_rest(density).to_abs(output);
|
||||
if len_px > max_px { max } else { len }
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ impl MaxSize {
|
||||
/// start, if it does not. Needed so the child is never painted bigger
|
||||
/// than the size this widget reports for it -- see the identical
|
||||
/// requirement noted on `Sized::draw`.
|
||||
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32) -> UiSpan {
|
||||
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
|
||||
let Some(max) = max else {
|
||||
return UiSpan::FULL;
|
||||
};
|
||||
let max_scalar = max.apply_rest();
|
||||
let max_scalar = max.apply_rest(density);
|
||||
let max_px = max_scalar.to_abs(output);
|
||||
if offered_px > max_px {
|
||||
max_scalar.align(AxisAlign::Neg)
|
||||
@@ -41,15 +41,16 @@ impl MaxSize {
|
||||
impl Widget for MaxSize {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let output = painter.output_size();
|
||||
let density = painter.density();
|
||||
let offered = painter.px_size();
|
||||
let region = UiRegion {
|
||||
x: Self::clamp_region(offered.x, self.x, output.x),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y),
|
||||
x: Self::clamp_region(offered.x, self.x, output.x, density),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||
};
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
Size {
|
||||
x: Self::clamp(used.x, self.x, output.x),
|
||||
y: Self::clamp(used.y, self.y, output.y),
|
||||
x: Self::clamp(used.x, self.x, output.x, density),
|
||||
y: Self::clamp(used.y, self.y, output.y, density),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,12 @@ pub struct Pad {
|
||||
|
||||
impl Widget for Pad {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let used = painter.widget_within(&self.inner, self.padding.region());
|
||||
let width = self.padding.left + self.padding.right;
|
||||
let height = self.padding.top + self.padding.bottom;
|
||||
let density = painter.density();
|
||||
let used = painter.widget_within(&self.inner, self.padding.region(density));
|
||||
let width =
|
||||
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
||||
let height =
|
||||
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
||||
Size {
|
||||
x: used.x + Len::abs(width),
|
||||
y: used.y + Len::abs(height),
|
||||
@@ -17,23 +20,29 @@ impl Widget for Pad {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
|
||||
/// against the display's density the same way any other size does -- see
|
||||
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
|
||||
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
|
||||
/// `Len`, exactly as a bare number always has meant elsewhere in this
|
||||
/// crate.
|
||||
pub struct Padding {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: Len,
|
||||
pub right: Len,
|
||||
pub top: Len,
|
||||
pub bottom: Len,
|
||||
}
|
||||
|
||||
impl Padding {
|
||||
pub const ZERO: Self = Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
};
|
||||
|
||||
pub fn uniform(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn uniform(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
@@ -41,80 +50,84 @@ impl Padding {
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
pub fn region(&self) -> UiRegion {
|
||||
pub fn region(&self, density: f32) -> UiRegion {
|
||||
let mut region = UiRegion::FULL;
|
||||
region.x.start.abs += self.left;
|
||||
region.y.start.abs += self.top;
|
||||
region.x.end.abs -= self.right;
|
||||
region.y.end.abs -= self.bottom;
|
||||
region.x.start.abs += self.left.apply_rest(density).abs;
|
||||
region.y.start.abs += self.top.apply_rest(density).abs;
|
||||
region.x.end.abs -= self.right.apply_rest(density).abs;
|
||||
region.y.end.abs -= self.bottom.apply_rest(density).abs;
|
||||
region
|
||||
}
|
||||
pub fn x(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn x(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
}
|
||||
}
|
||||
pub fn y(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn y(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: amt,
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top(amt: impl UiNum) -> Self {
|
||||
pub fn top(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.top = amt.to_f32();
|
||||
s.top = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn bottom(amt: impl UiNum) -> Self {
|
||||
pub fn bottom(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.bottom = amt.to_f32();
|
||||
s.bottom = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn left(amt: impl UiNum) -> Self {
|
||||
pub fn left(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.left = amt.to_f32();
|
||||
s.left = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn right(amt: impl UiNum) -> Self {
|
||||
pub fn right(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.right = amt.to_f32();
|
||||
s.right = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn with_top(mut self, amt: impl UiNum) -> Self {
|
||||
self.top = amt.to_f32();
|
||||
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.top = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
|
||||
self.bottom = amt.to_f32();
|
||||
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.bottom = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_left(mut self, amt: impl UiNum) -> Self {
|
||||
self.left = amt.to_f32();
|
||||
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.left = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_right(mut self, amt: impl UiNum) -> Self {
|
||||
self.right = amt.to_f32();
|
||||
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.right = amt.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: UiNum> From<T> for Padding {
|
||||
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
|
||||
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
|
||||
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
|
||||
/// reflexive case of the same bound.
|
||||
impl<T: Into<Len>> From<T> for Padding {
|
||||
fn from(amt: T) -> Self {
|
||||
Self::uniform(amt.to_f32())
|
||||
Self::uniform(amt.into())
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ impl Widget for Scroll {
|
||||
|
||||
self.content_len = used
|
||||
.axis(axis)
|
||||
.apply_rest()
|
||||
.apply_rest(painter.density())
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len);
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ impl Widget for Sized {
|
||||
// learn its size, then moves it into place with a pure
|
||||
// translation; that translation is only valid if what got painted
|
||||
// is already the reported size, anchored the same way both times.
|
||||
let density = painter.density();
|
||||
let mut region = UiRegion::FULL;
|
||||
if let Some(x) = self.x {
|
||||
region.x = x.apply_rest().align(AxisAlign::Neg);
|
||||
region.x = x.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
if let Some(y) = self.y {
|
||||
region.y = y.apply_rest().align(AxisAlign::Neg);
|
||||
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
Size {
|
||||
|
||||
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
|
||||
pub struct Span {
|
||||
pub children: Vec<StrongWidget>,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
|
||||
/// density the same way any other size in the tree does -- see
|
||||
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
|
||||
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
|
||||
/// were never supported for a gap and still are not.
|
||||
pub gap: Len,
|
||||
}
|
||||
|
||||
impl Widget for Span {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let axis = self.dir.axis;
|
||||
let gap = self.gap.apply_rest(painter.density()).abs;
|
||||
|
||||
// Phase 1: draw each child once, at the ambient (unmodified, full)
|
||||
// region a size-only query used to see before this migration, to
|
||||
@@ -25,7 +31,7 @@ impl Widget for Span {
|
||||
.map(|child| painter.widget(child).axis(axis))
|
||||
.collect();
|
||||
|
||||
let gap_total = self.gap * self.children.len().saturating_sub(1) as f32;
|
||||
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
|
||||
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
|
||||
|
||||
// Phase 2: place each child for real, using the lengths just
|
||||
@@ -54,7 +60,7 @@ impl Widget for Span {
|
||||
child_region.flip(axis);
|
||||
}
|
||||
let used = painter.widget_within(child, child_region);
|
||||
start.abs += self.gap;
|
||||
start.abs += gap;
|
||||
|
||||
let ortho = used.axis(!axis);
|
||||
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
||||
@@ -82,12 +88,12 @@ impl Span {
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -103,7 +109,7 @@ impl Span {
|
||||
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
|
||||
pub children: Wa,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
pub gap: Len,
|
||||
_pd: PhantomData<(State, Tag)>,
|
||||
}
|
||||
|
||||
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
|
||||
Self {
|
||||
children,
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
_pd: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ pub struct TextEdit {
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
history: Vec<(String, Option<Selection>)>,
|
||||
double_hit: Option<usize>,
|
||||
/// Where an in-flight press over this field began, while it is still
|
||||
/// undecided whether the gesture is a tap (focus/show the IME) or a
|
||||
/// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe
|
||||
/// over the composer must not summon the keyboard). `None` both before
|
||||
/// any press and once the gesture has been decided either way --
|
||||
/// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than
|
||||
/// behind an accessor since it is pure bookkeeping with no invariant
|
||||
/// beyond "some press is undecided," same shape as `double_hit` above.
|
||||
pub(crate) press_origin: Option<Vec2>,
|
||||
pub mode: EditMode,
|
||||
}
|
||||
|
||||
@@ -48,6 +57,7 @@ impl TextEdit {
|
||||
selection: None,
|
||||
history: Default::default(),
|
||||
double_hit: None,
|
||||
press_origin: None,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
@@ -141,7 +151,8 @@ impl<'a> TextEditCtx<'a> {
|
||||
fn layout(&mut self) -> &Layout<UiColor> {
|
||||
let attrs = self.text.view.attrs.clone();
|
||||
let width = self.text.view.wrap_width();
|
||||
self.text.view.buf.shape(self.data, &attrs, width);
|
||||
let density = self.data.density;
|
||||
self.text.view.buf.shape(self.data, &attrs, width, density);
|
||||
self.text.view.buf.layout()
|
||||
}
|
||||
|
||||
@@ -697,6 +708,67 @@ mod tests {
|
||||
assert_eq!(content(&t), "に");
|
||||
}
|
||||
|
||||
/// `android/ime.rs`'s `set_composing_text` calls `replace` and expects
|
||||
/// the caret to land right after the inserted text, growing with it on
|
||||
/// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't
|
||||
/// enter it until I hit space, and also doesn't move cursor forward").
|
||||
#[test]
|
||||
fn composing_advances_the_caret_with_the_growing_text() {
|
||||
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).set_caret(0);
|
||||
ctx(&mut t, &mut d).replace(0, "h");
|
||||
assert_eq!(t.caret(), Some(1));
|
||||
ctx(&mut t, &mut d).replace(1, "hi");
|
||||
assert_eq!(content(&t), "hi");
|
||||
assert_eq!(t.caret(), Some(2));
|
||||
ctx(&mut t, &mut d).replace(2, "hit");
|
||||
assert_eq!(content(&t), "hit");
|
||||
assert_eq!(t.caret(), Some(3));
|
||||
}
|
||||
|
||||
/// The IME's `commitText` (`android_view::InputConnection::commit_text`'s
|
||||
/// default body): finish a composition in place, same as a real word
|
||||
/// boundary (a space) landing after Gboard's composing span.
|
||||
#[test]
|
||||
fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() {
|
||||
let (mut t, mut d) = edit("say ", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).set_caret(4);
|
||||
ctx(&mut t, &mut d).replace(0, "hi");
|
||||
assert_eq!(content(&t), "say hi");
|
||||
// `finish_composing_text`/`commit_text` do not themselves touch the
|
||||
// buffer -- only the IME's own `compose_len` bookkeeping resets, in
|
||||
// `android/ime.rs`. Confirms the buffer already holds committed
|
||||
// text as plain, uncomposed content: a further `replace(0, " ")`
|
||||
// (the space that ends the word) appends rather than overwriting.
|
||||
ctx(&mut t, &mut d).replace(0, " ");
|
||||
assert_eq!(content(&t), "say hi ");
|
||||
assert_eq!(t.caret(), Some(7));
|
||||
}
|
||||
|
||||
/// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry
|
||||
/// point once `android/ime.rs` has converted UTF-16 code units to
|
||||
/// bytes -- exercised directly here in bytes, since the UTF-16 math
|
||||
/// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`,
|
||||
/// outside this widget-only test module.
|
||||
#[test]
|
||||
fn delete_byte_range_removes_exactly_that_range() {
|
||||
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).delete_byte_range(5, 11);
|
||||
assert_eq!(content(&t), "hello");
|
||||
assert_eq!(t.caret(), Some(5));
|
||||
}
|
||||
|
||||
/// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a
|
||||
/// caret at the given byte offset regardless of any span that was there.
|
||||
#[test]
|
||||
fn set_cursor_byte_collapses_to_a_caret_there() {
|
||||
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).select_all();
|
||||
ctx(&mut t, &mut d).set_cursor_byte(5);
|
||||
assert_eq!(t.selected_text(), None);
|
||||
assert_eq!(t.caret(), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_moves_the_caret_and_shift_extends_a_span() {
|
||||
let (mut t, mut d) = edit("abc", EditMode::SingleLine);
|
||||
|
||||
@@ -8,14 +8,58 @@
|
||||
//! measures whatever vertical space is left each frame -- nothing here
|
||||
//! computes a height by hand, and growing this field is exactly the
|
||||
//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured.
|
||||
//!
|
||||
//! **Rebuilt 2026-09-06** (Iris's phone report on the dc01f88 build: the
|
||||
//! grey bar drawn as a short, fixed strip with the typed text ~150px below
|
||||
//! it on black, and empty black between the bar and the keyboard). One
|
||||
//! widget now, top to bottom: an opaque background sized to its content
|
||||
//! (`.background`, the same `Stack` idiom the header row's `HEADER_SURFACE`
|
||||
//! already uses), the field inside `dp` padding and capped at
|
||||
//! [`MAX_LINES`] before it scrolls instead of growing forever, and an
|
||||
//! outer [`Pad`] whose `bottom` [`TranscriptScreen::set_bottom_inset`]
|
||||
//! rewrites in place whenever the keyboard opens/closes -- never rebuilt,
|
||||
//! since `field` is strongly owned inside this tree and this crate's
|
||||
//! widgets cannot be re-parented once added (this module's own comment
|
||||
//! below on why `build_composer` hands back a **weak** id).
|
||||
|
||||
use iris::prelude::*;
|
||||
|
||||
/// Caps the field's growth at roughly six lines of its own 18px text
|
||||
/// before it scrolls instead of consuming the whole screen -- an
|
||||
/// approximation (line-height and padding folded into one round `dp`
|
||||
/// number) rather than a value derived from the font's real metrics,
|
||||
/// which nothing in this crate exposes to a caller today.
|
||||
const MAX_LINES: f32 = 6.0;
|
||||
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||
const FIELD_PAD_DP: f32 = 12.0;
|
||||
|
||||
/// `field` is exposed so the caller can read its content on submit
|
||||
/// (`field.edit(rsc).text()`) and clear it afterward
|
||||
/// (`field.edit(rsc).set("")`).
|
||||
pub struct Composer {
|
||||
pub field: WeakWidget<TextEdit>,
|
||||
/// The bar's own outer padding -- only `bottom` is ever changed, by
|
||||
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
|
||||
/// a rebuilt tree, because `field` lives inside it and cannot be
|
||||
/// re-added to a new wrapper once it is strongly owned here.
|
||||
outer_pad: WeakWidget<Pad>,
|
||||
}
|
||||
|
||||
impl Composer {
|
||||
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
|
||||
/// whenever the space below the bar changes: the IME's own inset while
|
||||
/// it is open, the navigation-bar inset otherwise. Takes a plain
|
||||
/// `f32` in the caller's own physical-pixel units rather than an
|
||||
/// Android-specific insets type, so this crate stays usable from the
|
||||
/// winit backend too, which has no navigation bar to report.
|
||||
/// Rewrites the existing `Pad` in place (marking it dirty through the
|
||||
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
|
||||
/// so the field's focus, selection and in-progress text are untouched.
|
||||
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
||||
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||
pad.padding.bottom = Len::abs(inset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the composer plus its own bar as a **weak** id -- the caller
|
||||
@@ -39,10 +83,20 @@ where
|
||||
.label("Message")
|
||||
.add(rsc);
|
||||
|
||||
let bar: WeakWidget = (field.pad(12).width(rest(1)),)
|
||||
.span(Dir::RIGHT)
|
||||
// One widget: an opaque bar sized to its own content (`.background`'s
|
||||
// `Stack{child: 1}`, the header row's own idiom) wrapping the padded,
|
||||
// height-capped field -- not a background rect and a field drawn as
|
||||
// two independent siblings, which is what let the two disagree on
|
||||
// where the bar actually was.
|
||||
let content = field
|
||||
.pad(dp(FIELD_PAD_DP))
|
||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||
.scrollable()
|
||||
.width(rest(1))
|
||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||
.add(rsc);
|
||||
|
||||
(Composer { field }, bar)
|
||||
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||
|
||||
(Composer { field, outer_pad }, outer_pad)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ pub mod selection;
|
||||
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
||||
use iris::prelude::*;
|
||||
use selection::Selection;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
||||
|
||||
pub struct TranscriptScreen {
|
||||
/// The transcript's own `List` -- exposed so a caller can read
|
||||
@@ -220,6 +220,43 @@ where
|
||||
})
|
||||
.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| {
|
||||
let pos = ctx.data.pos;
|
||||
let row = list(rsc).key_at(pos.y).and_then(|key| {
|
||||
let (top, bottom) = list(rsc).extent(key)?;
|
||||
Some((
|
||||
key,
|
||||
Vec2::new(pos.x, pos.y - top),
|
||||
Vec2::new(ctx.data.size.x, bottom - top),
|
||||
))
|
||||
});
|
||||
selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
row,
|
||||
ctx.data.cursor.pos,
|
||||
ctx.data.sense,
|
||||
Instant::now(),
|
||||
ctx.data.render,
|
||||
);
|
||||
},
|
||||
)
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||
|
||||
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
||||
|
||||
@@ -137,20 +137,26 @@ where
|
||||
|
||||
field
|
||||
// `| 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`).
|
||||
// -- this row'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| {
|
||||
selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
key,
|
||||
ctx.data.pos,
|
||||
ctx.data.size,
|
||||
Some((key, ctx.data.pos, ctx.data.size)),
|
||||
ctx.data.cursor.pos,
|
||||
ctx.data.sense,
|
||||
Instant::now(),
|
||||
ctx.data.render,
|
||||
);
|
||||
},
|
||||
)
|
||||
@@ -174,8 +180,8 @@ where
|
||||
|
||||
(header, field.width(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.gap(4)
|
||||
.pad(10)
|
||||
.gap(dp(4))
|
||||
.pad(dp(10))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
@@ -36,11 +36,15 @@ use std::{collections::BTreeMap, time::Instant};
|
||||
pub struct Selection {
|
||||
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
|
||||
anchor: Option<(RowKey, 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
|
||||
/// pan wanting the same touch gesture). See `drag` below, and
|
||||
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
||||
arbiter: DragArbiter,
|
||||
/// `iris::sense::DragGesture`'s own doc for the arbitration, velocity
|
||||
/// tracking and pointer-capture mechanics this no longer owns itself
|
||||
/// -- Iris's 2026-09-06 ask (`IRIS.md`) that a drag's *mechanics* live
|
||||
/// in iris's default input layer, with only the pan-vs-select
|
||||
/// *decision* staying here.
|
||||
gesture: DragGesture,
|
||||
}
|
||||
|
||||
impl Default for Selection {
|
||||
@@ -54,7 +58,7 @@ impl Selection {
|
||||
Self {
|
||||
rows: BTreeMap::new(),
|
||||
anchor: None,
|
||||
arbiter: DragArbiter::new(),
|
||||
gesture: DragGesture::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,67 +162,66 @@ impl Selection {
|
||||
/// row, is what makes that consistent as a drag crosses row
|
||||
/// 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
|
||||
/// 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)]
|
||||
pub fn drag(
|
||||
&mut self,
|
||||
ui: &mut impl UiRsc,
|
||||
list: WeakWidget<List>,
|
||||
key: RowKey,
|
||||
pos_row: Vec2,
|
||||
size: Vec2,
|
||||
row: Option<(RowKey, Vec2, Vec2)>,
|
||||
pos_window: Vec2,
|
||||
sense: CursorSense,
|
||||
now: Instant,
|
||||
render: &UiRenderState,
|
||||
) {
|
||||
let outcome = match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
if matches!(sense, CursorSense::PressStart(_)) {
|
||||
// A fresh touch-down cancels any fling still coasting from
|
||||
// the previous gesture -- `List::fling`'s own doc, and
|
||||
// Android's `Scroller::abortAnimation` for the same reason.
|
||||
list(ui).cancel_fling();
|
||||
}
|
||||
let already_selected = self.has_selection(ui);
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.arbiter.update(pos_window, now)
|
||||
}
|
||||
CursorSense::PressEnd(_) => {
|
||||
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);
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.arbiter.update(pos_window, now)
|
||||
}
|
||||
_ => self.arbiter.update(pos_window, now),
|
||||
};
|
||||
let outcome =
|
||||
self.gesture
|
||||
.handle(render, list.id(), sense, pos_window, now, already_selected);
|
||||
match outcome {
|
||||
DragOutcome::Undecided => {}
|
||||
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||
DragOutcome::SelectStart => {
|
||||
// Grep-able on "iris selection" the way the frame report is
|
||||
// on "iris frame report" -- selection has no accessibility
|
||||
// label of its own yet, so this is the smallest way to
|
||||
// confirm a real on-device long-press-then-drag actually
|
||||
// reached here (RUST.md's I5 box, "Measurements taken" (c)).
|
||||
GestureOutcome::Undecided => {}
|
||||
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||
GestureOutcome::SelectStart => {
|
||||
if let Some((key, pos_row, size)) = row {
|
||||
// Grep-able on "iris selection" the way the frame
|
||||
// report is on "iris frame report" -- selection has no
|
||||
// accessibility label of its own yet, so this is the
|
||||
// smallest way to confirm a real on-device long-
|
||||
// press-then-drag actually reached here (RUST.md's I5
|
||||
// box, "Measurements taken" (c)).
|
||||
log::info!("iris selection: begin at row {key:?}");
|
||||
self.begin(ui, key, pos_row, size);
|
||||
}
|
||||
DragOutcome::SelectExtend => {
|
||||
}
|
||||
GestureOutcome::SelectExtend => {
|
||||
if let Some((key, pos_row, size)) = row {
|
||||
log::info!("iris selection: extend to row {key:?}");
|
||||
self.extend(ui, key, pos_row, size);
|
||||
}
|
||||
}
|
||||
// A fling only ever follows a pan -- never a selection that
|
||||
// happened to end with the finger still moving, and never a
|
||||
// tap/long-press that never left `Undecided` -- exactly what
|
||||
// `DragGesture`'s `Some(v)` already encodes.
|
||||
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
|
||||
GestureOutcome::Released(None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The concatenated selected text, in row order, `None` if nothing is
|
||||
@@ -319,8 +322,9 @@ mod tests {
|
||||
|
||||
let mut sel = Selection::new();
|
||||
sel.register(1, field);
|
||||
assert!(sel.arbiter.is_idle());
|
||||
assert!(sel.gesture.is_idle());
|
||||
|
||||
let render = UiRenderState::new();
|
||||
let now = Instant::now();
|
||||
let size = Vec2::new(100.0, 20.0);
|
||||
// No `PressStart` is ever sent -- only the `Pressing` frames a
|
||||
@@ -328,15 +332,14 @@ mod tests {
|
||||
sel.drag(
|
||||
&mut rsc,
|
||||
list,
|
||||
1,
|
||||
Vec2::ZERO,
|
||||
size,
|
||||
Some((1, Vec2::ZERO, size)),
|
||||
Vec2::new(540.0, 700.0),
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
now,
|
||||
&render,
|
||||
);
|
||||
assert!(
|
||||
!sel.arbiter.is_idle(),
|
||||
!sel.gesture.is_idle(),
|
||||
"a Pressing frame with the arbiter still Idle must recover \
|
||||
the press rather than leaving it stuck"
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user