Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb0e033d5 | ||
|
|
b8ea723718 | ||
|
|
7485d78d50 | ||
|
|
b87f5a597e | ||
|
|
80a75c128e | ||
|
|
50e69995b6 | ||
|
|
85869d02f8 | ||
|
|
f99ae4c366 | ||
|
|
203f53470c | ||
|
|
b38e797db3 | ||
|
|
92985ba8e3 | ||
|
|
181ba64606 | ||
|
|
a6a100edc6 | ||
|
|
ff1d6ea932 | ||
|
|
2b20bb2c91 | ||
|
|
3c80d9d696 | ||
|
|
06b8a1f4b0 | ||
|
|
e10582a2cd | ||
|
|
551c01398f | ||
|
|
7e79ec11e0 | ||
|
|
2ec0fee84c | ||
|
|
992c472975 | ||
|
|
729098756d | ||
|
|
d8562d96a3 | ||
|
|
22210a42f5 | ||
|
|
ade572973a | ||
|
|
9b27e858b5 | ||
|
|
7e4e26a335 | ||
|
|
84a13e806b | ||
|
|
452c44249f | ||
|
|
238057ad5e | ||
|
|
896c93a59a | ||
|
|
690161e5e9 | ||
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 |
No files matched your search
Generated
+2
@@ -82,7 +82,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+16
-1
@@ -404,12 +404,27 @@ done
|
||||
# Percent-encoded because the app URL-decodes the deep link's query: a
|
||||
# token with '+' in it enrols as one with a space, and nothing reports it.
|
||||
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
||||
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
|
||||
# the DER, which needs no percent-encoding). The Compose app ignores it and
|
||||
# pins the copy its APK was built with; the iris app has no baked copy at
|
||||
# all -- it is cross-compiled and could be pointed at any machine -- so
|
||||
# without this it enrols and then trusts nothing. Minted here rather than by
|
||||
# `--enroll-link` because this token is the sandbox's own, carried across
|
||||
# restarts so the emulator stays enrolled (see the top of this file).
|
||||
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
|
||||
import base64, sys
|
||||
pem = open(sys.argv[1]).read()
|
||||
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
|
||||
der = base64.b64decode("".join(body.split()))
|
||||
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
|
||||
CA
|
||||
)
|
||||
cat <<INFO
|
||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||
|
||||
enrol the emulator (once; it survives sandbox restarts):
|
||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
|
||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
|
||||
|
||||
drive it:
|
||||
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||
|
||||
Generated
+2
@@ -46,7 +46,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -37,6 +37,15 @@ ureq = { version = "3", features = ["json"] }
|
||||
# the same parser at the same version, rather than a hand-written splitter
|
||||
# that would drift from it.
|
||||
pulldown-cmark = "0.13.4"
|
||||
# The enrollment link's `ca` parameter is base64url of the CA's DER
|
||||
# (`config::parse_link`). Same version `wg-app-link` already pins for the
|
||||
# minting half, so a workspace that has both resolves one copy.
|
||||
base64 = "0.23"
|
||||
# The logging facade only -- `log_ring` implements a `log::Log` backend and
|
||||
# wraps whichever real one the platform installed (`android_logger` on the
|
||||
# phone, `env_logger` on the desktop), which is why neither of those is a
|
||||
# dependency here. See `log_ring`'s module doc.
|
||||
log = { version = "0.4.28", features = ["std"] }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+232
-16
@@ -6,33 +6,57 @@
|
||||
//! the same text a phone would scan as a QR, with no second format
|
||||
//! invented for it (RUST.md's E4).
|
||||
//!
|
||||
//! What this type deliberately does not decide: where it is persisted, and
|
||||
//! under what file permissions. A phone seals its token in the Android
|
||||
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
||||
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
||||
//! never in the repo). Both are caller-specific, so they stay out of this
|
||||
//! crate per the code rules' "ask for the least you need" -- see
|
||||
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
||||
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
|
||||
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
|
||||
//! desktop app, the app-private files directory on Android. **Which**
|
||||
//! directory is the only part left to the platform: the format, the file
|
||||
//! mode and the "nothing saved yet is not an error" answer are the same on
|
||||
//! both, and were written twice before this.
|
||||
//!
|
||||
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
||||
//! rules (`format`) are for configs a person hand-edits, and this file
|
||||
//! never is one -- only the app itself writes or reads it.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
||||
/// a public certificate rather than a secret, and where to find it differs
|
||||
/// by caller (a phone pins the one its APK was built against; a desktop
|
||||
/// client is told a path).
|
||||
/// with `token` as a bearer header.
|
||||
///
|
||||
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||
/// because an app built on the machine its server runs on pins the CA at
|
||||
/// build time and needs nothing from the link; one built elsewhere -- the
|
||||
/// iris Android client is cross-compiled in a VM and run against the
|
||||
/// host's server -- has no other way to get it. A public certificate
|
||||
/// rather than a secret, so it costs the link nothing but length.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnrolledServer {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// `#[serde(default)]` so an enrollment saved before this field
|
||||
/// existed still loads, as the enrolled server it always was.
|
||||
#[serde(default)]
|
||||
pub ca_pem: Option<String>,
|
||||
}
|
||||
|
||||
impl EnrolledServer {
|
||||
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
||||
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
||||
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
||||
/// contain `+`, which turns into a space if left to a naive splitter.
|
||||
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
|
||||
/// does not matter; unrecognised keys are ignored). `token` is
|
||||
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
|
||||
/// a raw token can contain `+`, which turns into a space if left to a
|
||||
/// naive splitter.
|
||||
///
|
||||
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||
/// here, because that is what every consumer of it wants
|
||||
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||
/// at). A `ca` that does not decode fails the whole link rather than
|
||||
/// enrolling a server with no trust anchor: the link said which
|
||||
/// certificate to pin, and quietly not pinning it is the one outcome
|
||||
/// nothing downstream could notice.
|
||||
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||
format!(
|
||||
@@ -44,6 +68,7 @@ impl EnrolledServer {
|
||||
let mut host = None;
|
||||
let mut port = None;
|
||||
let mut token = None;
|
||||
let mut ca = None;
|
||||
for pair in query.split('&') {
|
||||
let Some((key, value)) = pair.split_once('=') else {
|
||||
continue;
|
||||
@@ -53,6 +78,7 @@ impl EnrolledServer {
|
||||
"host" => host = Some(value),
|
||||
"port" => port = Some(value),
|
||||
"token" => token = Some(value),
|
||||
"ca" => ca = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +89,14 @@ impl EnrolledServer {
|
||||
.parse()
|
||||
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
||||
|
||||
Ok(Self { host, port, token })
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
ca_pem,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a `client_core::api::UreqTransport` reaches this server.
|
||||
@@ -73,6 +105,80 @@ impl EnrolledServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
|
||||
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(ca.as_bytes())
|
||||
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
||||
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
||||
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
||||
for line in body.as_bytes().chunks(64) {
|
||||
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
||||
pem.push('\n');
|
||||
}
|
||||
pem.push_str("-----END CERTIFICATE-----\n");
|
||||
Ok(pem)
|
||||
}
|
||||
|
||||
/// Where one client keeps the enrollment it should not have to be told
|
||||
/// about a second time. `dir` is the caller's, because that is the only
|
||||
/// part that differs by platform -- see this module's doc.
|
||||
pub struct EnrollmentStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl EnrollmentStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn file(&self) -> PathBuf {
|
||||
self.dir.join("enrollment.json")
|
||||
}
|
||||
|
||||
/// Writes `server` under `dir`, creating it if needed, and sets the
|
||||
/// file owner-only -- it carries a bearer token, the same reason
|
||||
/// `server/`'s own token store is 0600.
|
||||
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let path = self.file();
|
||||
let json = serde_json::to_vec_pretty(server)
|
||||
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||
std::fs::write(&path, json)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
|
||||
/// -- "not enrolled" is an ordinary first-run state, not a failure
|
||||
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
|
||||
/// just as well to a file that simply hasn't been written yet).
|
||||
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||
let path = self.file();
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(server))
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
@@ -108,6 +214,7 @@ mod tests {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "abcDEF123".to_string(),
|
||||
ca_pem: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||
@@ -141,6 +248,115 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The CA travels as base64url of the DER and comes back out as the
|
||||
/// PEM every consumer of it wants -- the same round trip
|
||||
/// `wg_app_link::enroll::ca_param` mints.
|
||||
#[test]
|
||||
fn a_ca_in_the_link_comes_back_as_pem() {
|
||||
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
||||
let server =
|
||||
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
||||
.unwrap();
|
||||
let pem = server.ca_pem.expect("the link carried a CA");
|
||||
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
||||
assert!(
|
||||
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
||||
"{pem}"
|
||||
);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(
|
||||
pem.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect::<String>()
|
||||
)
|
||||
.unwrap(),
|
||||
der
|
||||
);
|
||||
}
|
||||
|
||||
/// A link with no `ca` is an ordinary link, not a broken one: an app
|
||||
/// that pins at build time mints and reads exactly these.
|
||||
#[test]
|
||||
fn no_ca_parameter_is_none_not_an_error() {
|
||||
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||
assert_eq!(server.ca_pem, None);
|
||||
}
|
||||
|
||||
/// The half that cannot be noticed later: a `ca` that does not decode
|
||||
/// must fail the link rather than enrolling with nothing pinned.
|
||||
#[test]
|
||||
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||
let err =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
||||
.unwrap_err();
|
||||
assert!(err.contains("ca"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_enrollment_reads_back_the_same() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
let server = EnrolledServer {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "tok".to_string(),
|
||||
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
||||
};
|
||||
store.save(&server).unwrap();
|
||||
assert_eq!(store.load().unwrap(), Some(server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_saved_yet_is_none_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||
}
|
||||
|
||||
/// An enrollment written before `ca_pem` existed still loads.
|
||||
#[test]
|
||||
fn an_enrollment_without_a_ca_still_loads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
std::fs::create_dir_all(dir.path()).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("enrollment.json"),
|
||||
br#"{"host":"h","port":1,"token":"t"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn the_saved_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
store
|
||||
.save(&EnrolledServer {
|
||||
host: "h".to_string(),
|
||||
port: 1,
|
||||
token: "t".to_string(),
|
||||
ca_pem: None,
|
||||
})
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_is_named_in_the_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
||||
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
||||
assert!(err.to_string().contains("enrollment.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_numeric_port_is_named_in_the_error() {
|
||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod config;
|
||||
pub mod durations;
|
||||
pub mod event_stream;
|
||||
pub mod highlight;
|
||||
pub mod log_ring;
|
||||
pub mod markdown_blocks;
|
||||
pub mod notifications;
|
||||
pub mod sse;
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
//! The app's own recent log, held in memory so it can be read back
|
||||
//! without `logcat`.
|
||||
//!
|
||||
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
|
||||
//! no `adb`, and Android forbids one app reading another's logcat, so
|
||||
//! nothing outside the process can recover what it wrote. The only way a
|
||||
//! line reaches her is for the app to carry its own copy. This is that
|
||||
//! copy: a bounded ring every `log::info!` in the process lands in, on top
|
||||
//! of whichever platform logger was already installed (`android_logger`,
|
||||
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
|
||||
//!
|
||||
//! Two consumers, both reading the same ring rather than each keeping
|
||||
//! their own: the bench app's `Copy report`/`Diagnostics` (which reads
|
||||
//! [`LogRing::tail_text`] and [`LogRing::summary`]) and whatever hands the
|
||||
//! log out of the process -- on Android, the `DevLogProvider` Dev Updater
|
||||
//! queries, which reads [`LogRing::since`] and [`LogRing::newest_seq`].
|
||||
//! That is why reading does not consume: a line already handed over must
|
||||
//! still be in the report, and a report taken twice must say the same
|
||||
//! thing.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// How many lines a default ring holds, and how many bytes of message.
|
||||
///
|
||||
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||
/// modes are different: a flood of short lines exhausts the count, and one
|
||||
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
|
||||
/// only by bytes can be emptied by a single line.
|
||||
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// How many of the ring's newest lines [`LogRing::tail_text`] includes.
|
||||
/// Sized for a phone's share sheet rather than for the ring itself: 150
|
||||
/// lines of `HH:MM:SS.mmm LEVEL target: message` is a few KiB, comfortably
|
||||
/// short of whatever made pasting the full (up to 2000-line) ring into a
|
||||
/// chat's message box laggy on Iris's phone. The full ring is still
|
||||
/// reachable through `devlog`'s provider, so this only bounds what a
|
||||
/// report inlines.
|
||||
pub const COPY_REPORT_TAIL_LINES: usize = 150;
|
||||
|
||||
/// One recorded line. `seq` is assigned by the ring and only ever
|
||||
/// increases, so a reader that remembers where it got to can ask for what
|
||||
/// came after -- and a gap in the sequence is exactly the lines the bound
|
||||
/// dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LogLine {
|
||||
pub seq: u64,
|
||||
/// Milliseconds since the unix epoch, from the app's own clock. The
|
||||
/// app's rather than the receiver's: a line is timestamped when it
|
||||
/// happened, and an upload can be minutes later or never.
|
||||
pub at_ms: u64,
|
||||
pub level: log::Level,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
/// Roughly what the line costs the ring. The two `String`s dominate;
|
||||
/// the fixed fields are counted as a flat overhead so a ring of empty
|
||||
/// messages still has a bound.
|
||||
fn weight(&self) -> usize {
|
||||
self.target.len() + self.message.len() + 32
|
||||
}
|
||||
|
||||
/// `12:34:56.789 INFO iris::android: the message`, the shape a
|
||||
/// person skims. Time of day only -- the date is in the report's own
|
||||
/// header, and a ring never spans one.
|
||||
pub fn format(&self) -> String {
|
||||
format!(
|
||||
"{} {:<5} {}: {}",
|
||||
clock_time(self.at_ms),
|
||||
self.level,
|
||||
self.target,
|
||||
self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
|
||||
/// library: the only field this needs is the time of day, and dividing out
|
||||
/// the day is the whole calculation. Deliberately not local time -- the
|
||||
/// phone's offset is not knowable here, and a report that says UTC is
|
||||
/// comparable with the server's log, which is what it gets read against.
|
||||
fn clock_time(at_ms: u64) -> String {
|
||||
let ms = at_ms % 1000;
|
||||
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
secs_of_day / 3600,
|
||||
(secs_of_day % 3600) / 60,
|
||||
secs_of_day % 60,
|
||||
ms
|
||||
)
|
||||
}
|
||||
|
||||
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
|
||||
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
|
||||
/// the app down for.
|
||||
pub fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
lines: VecDeque<LogLine>,
|
||||
bytes: usize,
|
||||
max_lines: usize,
|
||||
max_bytes: usize,
|
||||
next_seq: u64,
|
||||
/// How many lines the bounds have discarded since the ring was made.
|
||||
/// Reported rather than inferred, so "the log starts here" and "the
|
||||
/// log was cut off here" are distinguishable -- the unknown state the
|
||||
/// UI rules ask for.
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
|
||||
/// there is one per process and every holder sees the same lines.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||
|
||||
impl LogRing {
|
||||
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
|
||||
assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
"a ring with no room holds nothing"
|
||||
);
|
||||
Self(Arc::new(Mutex::new(Inner {
|
||||
lines: VecDeque::new(),
|
||||
bytes: 0,
|
||||
max_lines,
|
||||
max_bytes,
|
||||
next_seq: 0,
|
||||
dropped: 0,
|
||||
})))
|
||||
}
|
||||
|
||||
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
|
||||
/// [`DEFAULT_MAX_BYTES`].
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// A poisoned lock is a bug in a panicking logger, not a reason to
|
||||
/// take the app down a second time -- the ring is a diagnostic, and
|
||||
/// losing it must not be worse than the fault it was recording.
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
|
||||
let mut guard = match self.0.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
/// Records a line, evicting the oldest until both bounds hold again.
|
||||
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||
self.with(|inner| {
|
||||
let line = LogLine {
|
||||
seq: inner.next_seq,
|
||||
at_ms: now_ms(),
|
||||
level,
|
||||
target: target.to_string(),
|
||||
message,
|
||||
};
|
||||
inner.next_seq += 1;
|
||||
inner.bytes += line.weight();
|
||||
inner.lines.push_back(line);
|
||||
// `!is_empty()` rather than `len() > 1`: one line larger than
|
||||
// the whole byte bound is kept, because dropping it would
|
||||
// leave the ring silently empty while lines were arriving.
|
||||
while inner.lines.len() > inner.max_lines
|
||||
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
|
||||
{
|
||||
if let Some(evicted) = inner.lines.pop_front() {
|
||||
inner.bytes -= evicted.weight();
|
||||
inner.dropped += 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Every line held, oldest first.
|
||||
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||
}
|
||||
|
||||
/// The lines with a sequence number at or after `seq`, oldest first,
|
||||
/// and the sequence to ask from next time. Does not consume: see this
|
||||
/// module's doc for why.
|
||||
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||
self.with(|inner| {
|
||||
let lines: Vec<LogLine> = inner
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.seq >= seq)
|
||||
.cloned()
|
||||
.collect();
|
||||
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
|
||||
(lines, next)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.with(|inner| inner.lines.len())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.with(|inner| inner.dropped)
|
||||
}
|
||||
|
||||
/// The sequence number of the newest line held, or `None` for a ring
|
||||
/// nothing has been written to.
|
||||
///
|
||||
/// What a reader needs to notice that this process **restarted**: the
|
||||
/// ring is in memory, so a new process starts again at zero, and a
|
||||
/// reader holding a cursor from the previous one would otherwise ask
|
||||
/// for lines after a number nothing will reach for hours and see
|
||||
/// nothing at all -- silently, which is worse than seeing the log
|
||||
/// begin again. Answering `None` rather than 0 for an empty ring is
|
||||
/// the same distinction [`Self::summary`] draws: "nothing has been
|
||||
/// logged" is not a sequence number.
|
||||
pub fn newest_seq(&self) -> Option<u64> {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.seq))
|
||||
}
|
||||
|
||||
/// When the newest line was written, in unix milliseconds, or `None`
|
||||
/// for a ring nothing has been written to.
|
||||
pub fn last_at_ms(&self) -> Option<u64> {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||
}
|
||||
|
||||
/// Every line held, formatted one per line -- what `Copy report`
|
||||
/// appends.
|
||||
pub fn to_text(&self) -> String {
|
||||
self.snapshot()
|
||||
.iter()
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The newest `max_lines` lines, formatted, with a first line naming
|
||||
/// how many older ones were left out of *this* text when the ring held
|
||||
/// more than that -- what `Copy report` appends instead of
|
||||
/// [`Self::to_text`].
|
||||
///
|
||||
/// Iris's own report: pasting the full ring (over a thousand lines on
|
||||
/// a session that ran with tracing on) into a phone's message box was
|
||||
/// what "causes a lot of lag" meant (docs/IRIS_TODO.md, 2026-09-07
|
||||
/// night) -- nothing is actually lost, since `devlog`'s provider still
|
||||
/// hands Dev Updater's Runtime tab the whole ring; this only caps what
|
||||
/// gets inlined into a share.
|
||||
pub fn tail_text(&self, max_lines: usize) -> String {
|
||||
let lines = self.snapshot();
|
||||
if lines.len() <= max_lines {
|
||||
return lines
|
||||
.iter()
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
}
|
||||
let omitted = lines.len() - max_lines;
|
||||
let tail = lines[lines.len() - max_lines..]
|
||||
.iter()
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("{omitted} earlier lines omitted; full log in Dev Updater's Runtime tab\n{tail}")
|
||||
}
|
||||
|
||||
/// One line for a diagnostics pane: how much is held, how much was
|
||||
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||
/// wording rather than a count of zero with a made-up time, because
|
||||
/// "nothing has been logged" and "logging is not running" would
|
||||
/// otherwise look the same.
|
||||
pub fn summary(&self) -> String {
|
||||
let (len, dropped, last) = self.with(|inner| {
|
||||
(
|
||||
inner.lines.len(),
|
||||
inner.dropped,
|
||||
inner.lines.back().map(|line| line.at_ms),
|
||||
)
|
||||
});
|
||||
match last {
|
||||
None => "app log: no lines yet".to_string(),
|
||||
Some(at) => {
|
||||
let dropped = if dropped > 0 {
|
||||
format!(", {dropped} dropped")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"app log: {len} lines held{dropped}, last {}",
|
||||
clock_time(at)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a target belongs to this app's own crates (`iris` or
|
||||
/// `client_core`) rather than a dependency's -- `starts_with` guarded by an
|
||||
/// exact match or a `::` so an unrelated crate that merely begins with the
|
||||
/// same letters (there is no such crate today, but the check should not
|
||||
/// rely on that) is never mistaken for one of ours.
|
||||
fn is_own_target(target: &str) -> bool {
|
||||
target == "iris"
|
||||
|| target.starts_with("iris::")
|
||||
|| target == "client_core"
|
||||
|| target.starts_with("client_core::")
|
||||
}
|
||||
|
||||
/// Whether a line at `level` from `target` belongs in the ring, given
|
||||
/// whether tracing is on right now.
|
||||
///
|
||||
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||
/// asked for, applied once here rather than at each `debug!` call site:
|
||||
/// Info and above always ring, from anything, because a real warning or
|
||||
/// error from a dependency is worth keeping. Debug and Trace ring only
|
||||
/// from this app's own targets, and only while tracing is switched on --
|
||||
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
|
||||
/// (the process logger's own level, set once at install and unrelated to
|
||||
/// tracing), which is what filled the ring with 1339 lines of it and
|
||||
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
|
||||
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
|
||||
/// (commit 992c472); this is the backstop for lines this crate does not
|
||||
/// control.
|
||||
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||
}
|
||||
|
||||
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
|
||||
/// logger the platform already installs, so nothing that reads the
|
||||
/// platform's log (`logcat`, a terminal) changes.
|
||||
///
|
||||
/// The inner logger is passed in rather than chosen here: `client-core`
|
||||
/// has no business depending on `android_logger` or `env_logger`, and
|
||||
/// which one is right is exactly what differs between the two platforms
|
||||
/// (the sharing rule in AGENTS.md).
|
||||
pub struct RingLogger {
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
|
||||
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
|
||||
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
|
||||
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
|
||||
/// direction"), so the platform crate that depends on both is the one
|
||||
/// that wires this closure through, the same way it already supplies
|
||||
/// `inner`.
|
||||
trace_enabled: fn() -> bool,
|
||||
}
|
||||
|
||||
impl RingLogger {
|
||||
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
|
||||
Self {
|
||||
ring,
|
||||
inner,
|
||||
trace_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for RingLogger {
|
||||
/// True for anything `log`'s own max level lets through: the ring
|
||||
/// wants everything the *inner* logger might also want, even where the
|
||||
/// platform logger would filter it out. Which lines the ring itself
|
||||
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
|
||||
self.ring
|
||||
.push(record.level(), record.target(), record.args().to_string());
|
||||
}
|
||||
if self.inner.enabled(record.metadata()) {
|
||||
self.inner.log(record);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a [`RingLogger`] as the process logger and answers the ring it
|
||||
/// records into.
|
||||
///
|
||||
/// Fails only if a logger is already installed, which is a programmer
|
||||
/// error (two initialisation paths) rather than a recoverable condition --
|
||||
/// the caller is named in the error so it is findable.
|
||||
pub fn install(
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
trace_enabled: fn() -> bool,
|
||||
) -> Result<(), log::SetLoggerError> {
|
||||
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
|
||||
log::set_max_level(max_level);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one ring this process records into.
|
||||
///
|
||||
/// **A deliberate process-global, where this project's rules otherwise say
|
||||
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||
/// has exactly one backend per process, set once, and every `log::info!`
|
||||
/// anywhere in the binary goes to it. A ring handed around as a parameter
|
||||
/// would be a *second* answer to "which lines exist" -- the report would
|
||||
/// show one ring while the logger filled another, and which one a caller
|
||||
/// got would depend on how far down the call tree it was. The tests above
|
||||
/// all use their own [`LogRing`], so nothing here needs this to be
|
||||
/// testable.
|
||||
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||
|
||||
/// The process's ring, created on first use with the default bounds.
|
||||
/// Safe to call before [`install_process_logger`] -- it will simply be
|
||||
/// empty.
|
||||
pub fn process_ring() -> &'static LogRing {
|
||||
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||
}
|
||||
|
||||
/// Installs [`process_ring`] as the recording half of the process logger,
|
||||
/// forwarding to `inner` (the platform's own logger, already configured).
|
||||
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
|
||||
/// else is shared. `trace_enabled` is the platform's own trace toggle
|
||||
/// (`iris::diagnostics::trace_enabled` on Android) -- see
|
||||
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
|
||||
/// passed in rather than called directly.
|
||||
pub fn install_process_logger(
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
trace_enabled: fn() -> bool,
|
||||
) -> Result<(), log::SetLoggerError> {
|
||||
install(process_ring().clone(), inner, max_level, trace_enabled)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Level;
|
||||
|
||||
fn fill(ring: &LogRing, count: usize) {
|
||||
for n in 0..count {
|
||||
ring.push(Level::Info, "test", format!("line {n}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_come_back_oldest_first() {
|
||||
let ring = LogRing::new(10, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(text, ["line 0", "line 1", "line 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_bound_drops_the_oldest_and_says_how_many() {
|
||||
let ring = LogRing::new(3, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
|
||||
assert_eq!(ring.len(), 3);
|
||||
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||
// Room for 1000 lines but only a few hundred bytes.
|
||||
let ring = LogRing::new(1000, 300);
|
||||
for n in 0..10 {
|
||||
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||
}
|
||||
assert!(
|
||||
ring.len() < 10,
|
||||
"the byte bound evicted: {} held",
|
||||
ring.len()
|
||||
);
|
||||
assert!(ring.dropped() > 0);
|
||||
assert!(
|
||||
ring.snapshot().last().unwrap().message.starts_with('9'),
|
||||
"and it evicted from the old end"
|
||||
);
|
||||
}
|
||||
|
||||
/// The case the `len() > 1` guard exists for: one line larger than the
|
||||
/// whole bound must still be readable, or a ring that is over budget
|
||||
/// reads as a ring nothing was written to.
|
||||
#[test]
|
||||
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||
let ring = LogRing::new(100, 64);
|
||||
ring.push(Level::Error, "t", "y".repeat(5000));
|
||||
assert_eq!(ring.len(), 1);
|
||||
assert_eq!(ring.dropped(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_numbers_only_increase_and_survive_eviction() {
|
||||
let ring = LogRing::new(2, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
|
||||
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_returns_only_what_is_new_and_the_next_cursor() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let (first, cursor) = ring.since(0);
|
||||
assert_eq!(first.len(), 3);
|
||||
assert_eq!(cursor, 3);
|
||||
|
||||
let (none, cursor) = ring.since(cursor);
|
||||
assert!(none.is_empty(), "nothing new yet");
|
||||
assert_eq!(cursor, 3, "and the cursor does not move");
|
||||
|
||||
ring.push(Level::Warn, "test", "later".into());
|
||||
let (more, cursor) = ring.since(cursor);
|
||||
assert_eq!(more.len(), 1);
|
||||
assert_eq!(more[0].message, "later");
|
||||
assert_eq!(cursor, 4);
|
||||
}
|
||||
|
||||
/// The restart signal: a reader that saw sequence 4 and is now told
|
||||
/// the newest is 0 knows the process is not the one it was reading.
|
||||
#[test]
|
||||
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
|
||||
fill(&ring, 5);
|
||||
assert_eq!(ring.newest_seq(), Some(4));
|
||||
|
||||
let restarted = LogRing::new(100, 1 << 20);
|
||||
fill(&restarted, 1);
|
||||
assert_eq!(
|
||||
restarted.newest_seq(),
|
||||
Some(0),
|
||||
"a fresh ring starts again, which is exactly what a reader has to notice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_does_not_consume() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 2);
|
||||
let (sent, _) = ring.since(0);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert_eq!(ring.len(), 2, "the report still has them after an upload");
|
||||
assert_eq!(ring.to_text().lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_text_is_the_whole_ring_untouched_when_under_the_cap() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
assert_eq!(ring.tail_text(150), ring.to_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_text_trims_to_the_newest_lines_and_says_how_many_were_left_out() {
|
||||
let ring = LogRing::new(1000, 1 << 20);
|
||||
fill(&ring, 200);
|
||||
let tail = ring.tail_text(150);
|
||||
let mut lines = tail.lines();
|
||||
assert_eq!(
|
||||
lines.next().unwrap(),
|
||||
"50 earlier lines omitted; full log in Dev Updater's Runtime tab"
|
||||
);
|
||||
let rest: Vec<&str> = lines.collect();
|
||||
assert_eq!(rest.len(), 150, "exactly the cap, after the header line");
|
||||
assert!(
|
||||
rest[0].ends_with("line 50"),
|
||||
"the oldest line kept is the 50th, not line 0: {}",
|
||||
rest[0]
|
||||
);
|
||||
assert!(rest.last().unwrap().ends_with("line 199"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||
let ring = LogRing::with_defaults();
|
||||
assert_eq!(ring.summary(), "app log: no lines yet");
|
||||
assert_eq!(ring.last_at_ms(), None);
|
||||
assert!(ring.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_names_dropped_lines_only_when_there_are_some() {
|
||||
let ring = LogRing::new(2, 1 << 20);
|
||||
fill(&ring, 2);
|
||||
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
|
||||
fill(&ring, 2);
|
||||
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_formats_as_time_level_target_message() {
|
||||
let line = LogLine {
|
||||
seq: 0,
|
||||
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
|
||||
// hand rather than against another clock.
|
||||
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||
level: Level::Info,
|
||||
target: "iris::android".into(),
|
||||
message: "surface created".into(),
|
||||
}
|
||||
.format();
|
||||
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||
}
|
||||
|
||||
/// The forwarding half: a line reaches the ring *and* the logger the
|
||||
/// platform already had, and one the inner logger filters out is still
|
||||
/// in the ring.
|
||||
#[test]
|
||||
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||
use log::Log;
|
||||
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
|
||||
impl Log for Collect {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= self.1
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.0.lock().unwrap().push(record.args().to_string());
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let ring = LogRing::with_defaults();
|
||||
// Own target, tracing on: this is the case where the ring and the
|
||||
// inner logger disagree, which is the thing under test -- a
|
||||
// foreign target is covered separately below.
|
||||
let logger = RingLogger::new(
|
||||
ring.clone(),
|
||||
Box::new(Collect(seen.clone(), Level::Info)),
|
||||
|| true,
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("kept"))
|
||||
.level(Level::Info)
|
||||
.target("iris::test")
|
||||
.build(),
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("filtered"))
|
||||
.level(Level::Debug)
|
||||
.target("iris::test")
|
||||
.build(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
["kept"],
|
||||
"the inner logger's own filter still applies"
|
||||
);
|
||||
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
["kept", "filtered"],
|
||||
"own-target debug still rings while tracing is on"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
|
||||
/// unconditionally, and used to flood the ring even though nothing in
|
||||
/// this app asked for their Debug output. A foreign target's Debug
|
||||
/// line must not ring even while tracing is on -- tracing controls
|
||||
/// this app's own diagnostics, not a dependency's chatter.
|
||||
#[test]
|
||||
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||
use log::Log;
|
||||
struct Discard;
|
||||
impl Log for Discard {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, _: &log::Record) {}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
let ring = LogRing::with_defaults();
|
||||
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("naga debug spam"))
|
||||
.level(Level::Debug)
|
||||
.target("naga::front")
|
||||
.build(),
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("naga warning"))
|
||||
.level(Level::Warn)
|
||||
.target("wgpu_core::device")
|
||||
.build(),
|
||||
);
|
||||
|
||||
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
["naga warning"],
|
||||
"Info-and-above always rings; foreign Debug never does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_accepts_is_own_target_debug_only_while_tracing() {
|
||||
assert!(
|
||||
ring_accepts(Level::Info, "wgpu_core::device", false),
|
||||
"Info+ from anything, tracing off"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Warn, "jni", true),
|
||||
"Info+ from anything, tracing on"
|
||||
);
|
||||
assert!(
|
||||
!ring_accepts(Level::Debug, "jni", true),
|
||||
"foreign Debug, tracing on: still excluded"
|
||||
);
|
||||
assert!(
|
||||
!ring_accepts(Level::Debug, "iris::sense", false),
|
||||
"own Debug, tracing off: excluded"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Debug, "iris::sense", true),
|
||||
"own Debug, tracing on: included"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Trace, "client_core::api", true),
|
||||
"own Trace, tracing on: included"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_own_target_matches_the_crate_or_its_modules_only() {
|
||||
assert!(is_own_target("iris"));
|
||||
assert!(is_own_target("iris::sense"));
|
||||
assert!(is_own_target("client_core"));
|
||||
assert!(is_own_target("client_core::log_ring"));
|
||||
assert!(!is_own_target("iris_something_else"));
|
||||
assert!(!is_own_target("naga::front"));
|
||||
assert!(!is_own_target("jni"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,157 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||
for iris API changes); this file is only the summary. Newest first. Items
|
||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
|
||||
## 2026-09-07 (platform fonts, not bundled ones)
|
||||
|
||||
- **Iris's own decision, carried out as directed**: removed the 3.6 MB of
|
||||
bundled Noto Sans/Noto Sans Mono TTFs from `iris-core` and load text
|
||||
from the platform's own font collection instead (`fontique`'s system
|
||||
discovery, already on by default). Matches what the Compose app does --
|
||||
it takes body text from `FontFamily.Default` and code text from
|
||||
`FontFamily.Monospace`, both platform-resolved, and ships no text font
|
||||
of its own. Rejected alternative (the one this pass had left open
|
||||
2026-09-06): subsetting the bundled Noto Sans to Latin/common
|
||||
punctuation instead of removing it outright, which would have kept
|
||||
identical rendering across devices for a smaller (not zero) size cost;
|
||||
Iris chose to match Compose instead.
|
||||
- `.so` **-3,748,136 bytes** (11,193,608 -> 7,445,472), matching the
|
||||
original 3.6 MB estimate. Fallback still lands on the platform's own
|
||||
tofu for a codepoint no resolved face has (checked with CJK + emoji on
|
||||
desktop) rather than blank space, so the UI_RULES unknown-glyph rule
|
||||
still holds.
|
||||
- **Gap found, then closed same day**: this fontique version's Android
|
||||
backend never resolved the `Monospace` generic family at all (confirmed
|
||||
on this checkout's emulator, `mono=None` in the startup diagnostic) --
|
||||
two pre-existing bugs in fontique's own `fonts.xml` parsing stacked (an
|
||||
ordering bug, and a `<family name="monospace">` declaration whose
|
||||
`<font>` children the backend's parser never reads), not something this
|
||||
change introduced, but this change is what stopped masking it (the
|
||||
bundled mono font used to be registered ahead of the broken platform
|
||||
lookup, so it always won). Checked `linebender/parley`'s `main` branch
|
||||
on GitHub: neither bug is fixed there, so there was no newer release to
|
||||
bump to. Fixed instead in `iris-core` itself
|
||||
(`TextData::patch_android_monospace`, Android-only): reads
|
||||
`/system/etc/fonts.xml`'s own `"monospace"` declaration for the font
|
||||
filename it names, then registers whichever of fontique's actually-
|
||||
scanned families owns that file as the `Monospace` generic -- the same
|
||||
authority Compose's `Typeface.MONOSPACE` resolves through, without
|
||||
pinning an OEM-specific family name. Verified on this checkout's
|
||||
emulator: `mono=Some("Droid Sans Mono")`, and a screenshot showing the
|
||||
bench-fixture's code block and tool-card values in a visibly monospaced
|
||||
face beside sans body text; the desktop `fontconfig` backend is
|
||||
unaffected (still resolves monospace correctly, confirmed unchanged).
|
||||
docs/RUST.md's "Platform fonts (2026-09-07)" has the full account.
|
||||
|
||||
## 2026-09-07 (a phone log reaches Iris through Dev Updater's own tab)
|
||||
|
||||
**Supersedes the "how a phone log reaches Iris" entry below, same day.**
|
||||
Iris's call once the route was working: put it in Dev Updater properly
|
||||
rather than smuggling the lines through `ai-server`'s log.
|
||||
|
||||
- **The app exposes its own log on the device, and Dev Updater reads it
|
||||
there.** A `ContentProvider` at `<applicationId>.devlog`, one table of
|
||||
lines queried with `?since=<seq>` so a poll is incremental, plus a
|
||||
`status` row (`held`, `dropped`, `newest_seq`). Dev Updater's phone app
|
||||
polls it while the component's **Runtime** tab is open and forwards what
|
||||
is new to its own build machine, into that APK component's runtime log
|
||||
-- so the same tab renders both kinds and the history outlives the
|
||||
phone. No tunnel, no token, no second enrolment: the two apps are on the
|
||||
same phone.
|
||||
|
||||
**It is a contract, not a feature for iris.** Written down in
|
||||
dev-updater's `README.md` ("An app's own log"), so any app that server
|
||||
delivers gets the tab by implementing it; the Compose app in `app/` can
|
||||
do the same later. That is the reason it beat the route below on its
|
||||
second look -- the earlier one only ever worked for the one project that
|
||||
had a server, and put a phone's lines under a *different component* than
|
||||
the one they came from.
|
||||
|
||||
- **Read access is `protectionLevel="normal"`, and that is a real trade.**
|
||||
`signature` is what this wants and is not available: Dev Updater and the
|
||||
apps it delivers are built on one machine but signed with different
|
||||
locally generated keys, so a signature permission would be held by
|
||||
nothing at all. What `normal` costs is that any app on that phone which
|
||||
requests `dev.updater.permission.READ_DEVLOG` by name can read another
|
||||
app's dev log. Accepted because these are development builds on a
|
||||
development phone and the alternative was no log; stated in the manifest
|
||||
beside the declaration and in dev-updater's README so it is not
|
||||
rediscovered as a surprise.
|
||||
|
||||
- **The provider polls rather than notifying.** `notifyChange` was not
|
||||
implemented: the ring is filled by a `log::Log` backend on whatever
|
||||
thread logged, and giving that a route to a `ContentProvider` means
|
||||
plumbing a callback through `client-core` for every platform. Dev
|
||||
Updater's contract therefore says it polls (about a second, only while
|
||||
the tab is open), which is what keeps implementing the contract cheap --
|
||||
a provider that does notify loses nothing.
|
||||
|
||||
- **What was deleted, so there is one mechanism**: `client-core`'s
|
||||
`log_upload` module, `POST /client-log` on `ai-server`, the
|
||||
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` baking in `iris/android-app/build.rs`
|
||||
(which left that file with nothing to do, so it is gone too), and the
|
||||
uploader fields on both Android clients. Kept: the ring, `RingLogger`,
|
||||
`install_process_logger`, and the Diagnostics line counting what is
|
||||
held. The upload-status line there is now **"devlog provider:
|
||||
content://<authority>"** -- named from what the provider registered
|
||||
rather than composed from the package here, so a screenshot of that pane
|
||||
is evidence the contract is live and says which package's log it is.
|
||||
|
||||
## 2026-09-07 (how a phone log reaches Iris) -- superseded, see above
|
||||
|
||||
- **The app sends its own log to `ai-server`, and Dev Updater shows it as
|
||||
`ai-server`'s runtime log.** Iris has no `adb`/`logcat` on her phone, and
|
||||
Android forbids one app reading another's logcat, so the app has to carry
|
||||
its own copy and post it somewhere. `POST /client-log` on `ai-server`
|
||||
re-emits each line into that server's own `tracing` output; Dev Updater
|
||||
already runs `ai-server` as a `Managed` component, whose stdout its own
|
||||
service script redirects to a file and reports through
|
||||
`GET /apps/{key}/components/{name}/logs?kind=runtime`, which the phone
|
||||
app's log dialog already offers as a **Runtime** tab for a `server`
|
||||
component. So **no change to Dev Updater at all** -- one route on
|
||||
`ai-server`, and the client in `client-core`.
|
||||
|
||||
**Rejected: posting to Dev Updater's own server** (the first candidate,
|
||||
and what the entry above went on to build -- the estimate below was
|
||||
right about the work and wrong about it being too much).
|
||||
It would need a new authenticated *write* route on a TLS surface whose
|
||||
module doc says every route on it "is, or decides, the bytes that get
|
||||
handed to `REQUEST_INSTALL_PACKAGES` next"; a per-app device-log store;
|
||||
a change to `component_logs` so an APK component can have a runtime log;
|
||||
a change to the phone app's `hasBothKinds = component.kind == "server"`
|
||||
gate and to what `hasRuntimeLogs` means on the wire; and -- the real
|
||||
cost -- a **second** enrollment for the iris app, since it has no CA or
|
||||
token for Dev Updater and Dev Updater mints tokens per device by QR.
|
||||
Five changes across two repos against one route, for the same line
|
||||
landing in the same viewer.
|
||||
|
||||
**Rejected: a share intent from a debug button** (a log file in the app's
|
||||
external files dir, shared by hand). It works today and needs no server,
|
||||
but every line costs Iris a manual export and a message, which is the
|
||||
round trip through a person this was meant to remove. It is still the
|
||||
fallback when the tunnel is down, and GrapheneOS's own per-app log export
|
||||
already covers the crash case (that is how the `ToolInput.highlighted`
|
||||
crash was reported).
|
||||
|
||||
- **The ring is in `client-core`, not in the Android crate.** A bounded
|
||||
in-memory ring (2000 lines or 256 KiB, whichever bites first) behind a
|
||||
`log::Log` backend that *forwards* to whichever logger the platform
|
||||
already installed, so `logcat` and a desktop terminal see exactly what
|
||||
they saw before. The platform supplies only its own logger and its
|
||||
destination. `Copy report` appends the ring to what goes on the
|
||||
clipboard, and flushes the uploader first.
|
||||
|
||||
- **The destination is baked in at build time, from the build machine's
|
||||
own files** (`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA) --
|
||||
*gone; the provider above replaced it.* What is worth keeping from it is
|
||||
the reason it went: an APK good only for the server that built it cannot
|
||||
be built in this VM for Iris's phone, which is the case that mattered.
|
||||
all three or none, never two. The same trust boundary the transcript
|
||||
config and the Compose APK's CA already use: nothing secret is
|
||||
committed, and an APK is good for the server that built it. A build told
|
||||
nothing still keeps its ring and still copies it; the diagnostics pane
|
||||
says which of "not tried yet", "failing -- <why>" and "no server
|
||||
configured" it is, because otherwise all three look like silence.
|
||||
|
||||
## 2026-09-06 (how a tool call looks, P1b)
|
||||
|
||||
- **A card that never got a result says "no result", in yellow, and it is
|
||||
@@ -442,3 +593,55 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||
have the full account. The iris-vs-Masonry choice itself is still
|
||||
Iris's to make.
|
||||
|
||||
## 2026-09-07: the enrolment link carries the CA, so an APK need not be built where its server runs
|
||||
|
||||
**Problem.** Every phone build pinned the CA of the machine that compiled
|
||||
it -- the Compose app from `GeneratePinnedCert`, the iris app from
|
||||
`build.rs` reading `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. That is fine
|
||||
while the two are the same machine and impossible when they are not, which
|
||||
is exactly the iris client's situation: cross-compiled in this VM,
|
||||
delivered to a phone, run against `ai-server` on the host. Baking the
|
||||
host/port/token as well made it worse -- a token in a built artifact.
|
||||
|
||||
**Decided: the CA rides in the enrolment link**, as `&ca=<base64url of the
|
||||
DER>` (`wg_app_link::enroll::ca_param`), optional and per mint. The app
|
||||
that opens the link pins what the link said, and an APK built anywhere
|
||||
works against whatever server it is pointed at.
|
||||
|
||||
Two alternatives were worked out and rejected.
|
||||
|
||||
- **A CA *fingerprint* in the link, pinned at the TLS handshake.** The
|
||||
smallest link (43 more characters) and the strongest shape, but `ureq`
|
||||
3.4 exposes no hook for a custom `rustls` `ServerCertVerifier`: its
|
||||
`TlsConfig` builds the `ClientConfig` itself, so this needs a hand-written
|
||||
`Connector` on the `unversioned` API and `rustls` as a direct dependency
|
||||
of `client-core`. A lot of machinery in the one crate that must stay
|
||||
light.
|
||||
- **A fingerprint in the link plus an unauthenticated `GET /ca.pem`.**
|
||||
Small code, but it needs a first connection with verification disabled,
|
||||
and it breaks a documented, tested posture -- `auth.rs`'s "gates every
|
||||
route with zero unauthenticated endpoints", which is a load-bearing
|
||||
decision rather than an implementation detail. Not something to change
|
||||
silently for this.
|
||||
|
||||
**What it costs**, measured rather than guessed: on this project's P-256
|
||||
CA the link goes from 89 bytes to 652, and `print_enrollment`'s terminal
|
||||
QR from 45x23 to 93x47 characters. That is why the parameter is the
|
||||
minter's choice per call: `ai-server` passes it (its iris client needs it),
|
||||
`dev-updater` passes `None` (its app is built on the machine it talks to,
|
||||
and its QR stays scannable in an 80-column terminal). The URI printed under
|
||||
the QR is the fallback either way, and is the path Dev Updater's Enroll
|
||||
button already uses -- it opens the link with `ACTION_VIEW`, so Android
|
||||
offers whichever apps registered the scheme, which needed no change here.
|
||||
|
||||
The CA is a public certificate, so putting it in the QR leaks nothing the
|
||||
token did not already: photographing the terminal still costs exactly the
|
||||
token, which is rotatable.
|
||||
|
||||
**The log upload's destination is moot**, so it is not wired to this. On
|
||||
the same day Iris decided Dev Updater will read an APK's runtime log from
|
||||
an on-device ContentProvider instead, which removes `log_upload`,
|
||||
`POST /client-log` and the `AI_APP_LOG_*` baking altogether -- so the
|
||||
enrolment landed without touching any of them, for that change to delete
|
||||
whole.
|
||||
+189
@@ -8,6 +8,128 @@ 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-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build
|
||||
|
||||
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
|
||||
include `GL`. A device that offers a Vulkan driver with no adapter behind
|
||||
it -- this checkout's emulator -- therefore had no adapter at all, and the
|
||||
`.expect` on that turned into a crash loop with nothing on screen. It now
|
||||
probes for a `PRIMARY` adapter first and falls back to `Backends::GL` when
|
||||
there is none, so **Vulkan still wins wherever it has an adapter** and
|
||||
nothing changes on a phone.
|
||||
|
||||
The probe deliberately runs on an instance that never touches the window:
|
||||
an Android window can be connected to one graphics API only, so an
|
||||
instance carrying both backends lets Vulkan claim the window and leaves
|
||||
the GLES surface unusable. That is why this is a second instance rather
|
||||
than one wider `Backends` value.
|
||||
|
||||
The other half a caller sees: `AndroidRenderer::new` already returned
|
||||
`Result<Self, String>`, and now **every** way it can fail goes through
|
||||
that -- no surface, no adapter, no device, as well as the bind-group
|
||||
validation failure it was originally written for. `surface_changed` puts
|
||||
that string on screen and in the log ring instead of aborting.
|
||||
|
||||
## 2026-09-07: `VelocityTracker` takes positions, not deltas
|
||||
|
||||
A flick released at the wrong speed because the tracker averaged. It now
|
||||
does what Compose's touch scrolling does, and that changes what a caller
|
||||
feeds it.
|
||||
|
||||
// before -- one frame's motion
|
||||
tracker.add_sample(dy, now);
|
||||
// after -- where the finger was
|
||||
tracker.add_position(pos.axis(axis), now);
|
||||
|
||||
`VelocityTracker::velocity` is a port of Compose's `VelocityTracker1D`
|
||||
with `Strategy.Lsq2`: a degree-2 least-squares fit through the last 20
|
||||
positions, differentiated at the newest sample, with Compose's 100ms
|
||||
horizon, 40ms stopped-gap and three-sample minimum. Positions rather than
|
||||
deltas because a fit needs points on a curve -- Compose itself throws on
|
||||
differential data for this strategy.
|
||||
|
||||
Three consequences a caller sees. **A gesture with fewer than three
|
||||
samples answers `0.0`**, where the average answered a number from two;
|
||||
that is Compose's answer too, and on the phone a 120Hz flick delivers
|
||||
four or five. **A finger that rests for more than 40ms before lifting
|
||||
answers `0.0`** rather than flinging at the speed it arrived with.
|
||||
**`add_position` must be called in time order** -- the same debug assert
|
||||
as before, now load-bearing for the fit's x-axis.
|
||||
|
||||
Also new: `VelocityTracker::samples_display` (the held samples as
|
||||
`t_ms:position`, printed by `DragGesture` at debug level so a flick
|
||||
reported from a phone can be replayed), `DragArbiter::axis`, and
|
||||
`sense::MAX_FLING_VELOCITY_DP_S` (8000, `ViewConfiguration`'s own).
|
||||
`List::fling` now applies that maximum against its own density and
|
||||
ignores anything at or under 1px/s, which is Compose's pair of thresholds
|
||||
exactly -- there is deliberately no 50dp/s minimum, because Compose's
|
||||
scrolling never consults the one in `ViewConfiguration`.
|
||||
|
||||
## 2026-09-07: `client-core` carries the app's own log
|
||||
|
||||
Not iris itself but the crate beside it, and it is a new public surface an
|
||||
app author will use: `client_core::log_ring`. Because Iris's phone has no
|
||||
`logcat`, an app now keeps a bounded copy of its own log and hands it to
|
||||
Dev Updater on the device.
|
||||
|
||||
Before, an app installed a platform logger and that was the end of it:
|
||||
|
||||
android_logger::init_once(config); // Android
|
||||
// nothing at all on the desktop
|
||||
|
||||
After, the platform's logger becomes the *inner* logger of a ring that
|
||||
records everything alongside it -- `logcat` and a terminal see exactly
|
||||
what they saw before:
|
||||
|
||||
client_core::log_ring::install_process_logger(
|
||||
Box::new(android_logger::AndroidLogger::new(config)),
|
||||
LevelFilter::Debug,
|
||||
)?;
|
||||
let ring = client_core::log_ring::process_ring(); // 2000 lines / 256 KiB
|
||||
ring.to_text(); // for a report
|
||||
ring.summary(); // "1801 lines held, 12 dropped, last 20:09:24"
|
||||
|
||||
// and, for whatever hands the log out of the process:
|
||||
let (lines, next) = ring.since(cursor); // inclusive of `cursor`
|
||||
ring.newest_seq(); // None for a ring nothing was written to
|
||||
|
||||
`process_ring` is a deliberate process-global, unusually for this project:
|
||||
`log` already has exactly one backend per process, and a ring passed around
|
||||
as a parameter would be a second answer to "which lines exist".
|
||||
|
||||
**Amended later the same day.** `client_core::log_upload` and
|
||||
`ai-server`'s `POST /client-log` are **gone** -- an app no longer sends
|
||||
its log anywhere. It exposes it on the device instead, and Dev Updater
|
||||
reads it there: on Android that is a `ContentProvider` at
|
||||
`<applicationId>.devlog`, which is Dev Updater's own contract (its
|
||||
`README.md`, "An app's own log") rather than anything iris-specific.
|
||||
`LogRing::newest_seq()` is the one addition that went with it: a reader
|
||||
holding a cursor uses it to notice the process **restarted**, since the
|
||||
ring is in memory and a new process starts again at sequence zero.
|
||||
The reasoning and the rejected alternatives are in docs/DECISIONS.md,
|
||||
2026-09-07.
|
||||
|
||||
## 2026-09-07: `TextData` no longer bundles a font
|
||||
|
||||
Iris's call: "remove the font for now; just match what compose does."
|
||||
`TextData::default()` used to embed six Noto Sans/Noto Sans Mono `.ttf`s
|
||||
(3.6 MB, `include_bytes!`) and register them ahead of the platform's own
|
||||
fonts in the `SansSerif`/`Monospace` fallback lists. That registration is
|
||||
gone; `TextData::default()`'s signature is unchanged, but what it produces
|
||||
now depends entirely on `fontique`'s platform discovery (already on by
|
||||
default, previously shadowed) -- Roboto/Roboto Flex on Android, whatever
|
||||
the desktop's fontconfig resolves on Linux. No caller-visible type or
|
||||
method changed, but every consumer of `iris-core` text now renders with
|
||||
whatever the host platform's fonts are, not a fixed bundled face -- worth
|
||||
knowing if you were relying on pixel-identical text across devices.
|
||||
`.so` shrank by 3.75 MB. One real gap surfaced by the switch: this
|
||||
fontique version's Android backend never resolves the `Monospace`
|
||||
generic family (a fontique ordering bug, not new in this change), so
|
||||
`Family::Monospace` text falls through to the same face as
|
||||
`SansSerif` on Android rather than a true monospaced one -- still
|
||||
visible, not blank, just not monospaced. docs/RUST.md's "Platform fonts
|
||||
(2026-09-07)" has the full account.
|
||||
|
||||
## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
|
||||
|
||||
Layer 1 and 2 of docs/RUST.md's "Three test layers".
|
||||
@@ -1067,3 +1189,70 @@ and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
|
||||
measured. With the counter it is a test: one delta into a 100-paragraph
|
||||
reply shapes exactly **1** text layout, the same as into a
|
||||
one-paragraph one.
|
||||
|
||||
## 2026-09-07: `iris::diagnostics` -- a trace toggle for input/frame lines, gating four existing per-frame `debug!` calls
|
||||
|
||||
One new public module and one behaviour change to four existing log
|
||||
lines, from Iris's "add another button to copy input event info ...
|
||||
instrument a lot of the code with timings" request (RUST.md's own
|
||||
section has the full account).
|
||||
|
||||
- **`iris::diagnostics::set_trace(bool)`/`trace_enabled() -> bool`**, a
|
||||
process-global switch, off by default. It gates two new diagnostics
|
||||
(`sense::log_input_event`, one line per platform pointer sample under
|
||||
target `iris::input`; `diagnostics::log_frame`, one line per frame
|
||||
under `iris::frame`, with the frame number, the frame clock, time
|
||||
since the last input, layout/draw durations, `RedrawKind`, primitives
|
||||
on screen, and whether something is animating) and, as of a same-day
|
||||
review finding (D1), four *older* `debug!` lines that were previously
|
||||
unconditional: `android::view`'s two `render():` lines, `widget::
|
||||
list`'s `iris fling tick:`, `widget::text`'s `iris text render:`, and
|
||||
`sense`'s `iris drag release samples:`. Not `log::log_enabled!`/
|
||||
`log::set_max_level`, because the app installs its logger at
|
||||
`LevelFilter::Debug` already and the ring records everything that
|
||||
level lets through regardless of target — the gate has to live on
|
||||
this side. **Not wired to a control**: the Diagnostics pane is in
|
||||
`bench_client.rs`, off-limits while another agent had it open; this
|
||||
is the whole surface a button needs.
|
||||
- **`UiRenderState` gained `RedrawKind`, `frame_number()`, `epoch()`,
|
||||
`last_layout_duration()`, `last_redraw_kind()`,
|
||||
`active_primitive_count()`, `note_input(Instant)` and
|
||||
`time_since_input(Instant) -> Option<Duration>`** (`iris-core`). All
|
||||
read back by `log_frame`; `note_input` is called once from
|
||||
`SensorUi::run_sensors`, which both backends and the harness already
|
||||
share, so a frame's `since_input` is comparable across all three
|
||||
without either platform doing its own bookkeeping.
|
||||
- **`iris::harness::TouchAction` gained `word() -> &'static str`**, the
|
||||
inverse of its own `parse` -- what a caller (here, `Harness::touch`)
|
||||
hands the input logger so a `.touch` file and an `iris::input` line
|
||||
agree on one spelling of each action.
|
||||
- **`iris_core::Axis` gained `Debug`** — a one-line derive, needed to log
|
||||
which axis a drag committed to.
|
||||
- **`iris/benches/report_to_touch.py`** (new): turns a report's
|
||||
`iris::input` lines back into a `.touch` file, expanding inline
|
||||
historical samples into their own lines first. Round-tripped against
|
||||
the harness in `iris/transcript-fixture/tests/input_log_roundtrip.rs`.
|
||||
|
||||
## 2026-09-07: the phone app is told which server to talk to, and pins from the link
|
||||
|
||||
Not an iris API change -- a client-facing one, in the crates around it,
|
||||
worth knowing because it changes what a build of the Android app *is*.
|
||||
|
||||
- **An iris APK is no longer tied to the machine that compiled it.** It
|
||||
used to have the server's host, port, token and CA compiled in, which
|
||||
made a build good for exactly one emulator/server pair and put a token
|
||||
in the artifact. Now it registers `aiapp://enroll` like the Compose app:
|
||||
open the link (Dev Updater's Enroll button already offers it, and the
|
||||
phone asks which app should take it) and the app stores where to go and
|
||||
what to trust.
|
||||
- **The CA rides in the link** as `&ca=<base64url DER>`, which is what
|
||||
makes the above possible at all -- a pinned certificate cannot be baked
|
||||
into an APK cross-compiled somewhere else. Optional, so the projects
|
||||
that do build on their own machine keep the short link and the small QR.
|
||||
docs/DECISIONS.md, 2026-09-07, has why not a fingerprint.
|
||||
- **`client_core::config` now holds the storage as well as the parsing**:
|
||||
`EnrolledServer` gained an optional `ca_pem`, and `EnrollmentStore` (the
|
||||
0600 JSON file, moved out of `desktop-app`) is one implementation for
|
||||
both the desktop and the phone -- only the directory differs.
|
||||
`desktop-app --ca` is now the override for a link that carried no CA
|
||||
rather than a required flag.
|
||||
+161
-1
@@ -934,7 +934,7 @@ do not duplicate it there.
|
||||
|
||||
## From the phone, 2026-09-07 (build from ed04d4c)
|
||||
|
||||
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
|
||||
- [x] **"Some transcript blocks will be hidden until I uncover enough of
|
||||
them."** Two screenshots of the bench app's transcript at the top
|
||||
edge, both wrong in opposite directions: in one, rows scrolled above
|
||||
the viewport are still drawn and bleed *through* the header bar
|
||||
@@ -955,3 +955,163 @@ do not duplicate it there.
|
||||
the header's bottom must be masked. Fix both with one rule: a row is
|
||||
drawn if any part of it intersects the viewport, and the viewport is
|
||||
the list's own region.
|
||||
|
||||
**Done, e922b73 + d507ae4.** Three causes, and the rule above is what
|
||||
they are all fixed with (`List::intersects_viewport`).
|
||||
`iris/transcript-fixture/tests/top_edge.rs` is the layer-1
|
||||
reproduction -- the real screen under a bench-app-shaped header --
|
||||
and each test was confirmed to fail on its own subject and no other.
|
||||
|
||||
1. *Drawn over the header*: **nothing was clipping the list at all**,
|
||||
and a row straddling an edge is drawn in full, so the part above
|
||||
the list was on screen. It could not be `.masked()` before, either:
|
||||
`Painter::set_mask` aborted when an ancestor already had a mask,
|
||||
and the list's own rows use `.masked()` (a code fence, a tool
|
||||
card's title). So masks nest now -- `Mask::parent`, walked in the
|
||||
fragment stage, chained rather than intersected on the CPU because
|
||||
each mask moves with its own widget. `the_list_is_clipped_to_its_
|
||||
own_box`.
|
||||
2. *Rows already scrolled past still drawn*: the layout walk runs from
|
||||
the anchor, `scroll` moves the anchor's offset and nothing else, so
|
||||
panning leaves the anchor's row further and further outside the
|
||||
viewport and **every row between it and the viewport was drawn,
|
||||
every frame** -- measured at 64 rows for a 2012px viewport after 8
|
||||
scrolls of 3000px. `place` skips a row whose known box does not
|
||||
overlap, and `rehome_anchor` puts the anchor back on a visible row
|
||||
each frame without moving anything drawn.
|
||||
`rows_that_have_left_the_viewport_are_not_drawn`.
|
||||
3. *The blank band*: not a culling rule at all -- the list could rest
|
||||
**past its own first row** (`fling_toward_the_start_stops_at_the_
|
||||
first_row` was leaving it 1398px below a 600px viewport, a blank
|
||||
screen, and that test's own assertion could not see it).
|
||||
`clamp_to_content` gives the gap back. Both ends:
|
||||
`scrolling_past_the_first_row_settles_on_it`,
|
||||
`scrolling_past_the_last_row_settles_on_it`. This is also the first
|
||||
item of the later report below.
|
||||
|
||||
What was suspected and is *not* what happened: the visible-range test
|
||||
never compared a row's top against the viewport's top (there was no
|
||||
culling test at all), and `03c6be8`'s header duplicate is untouched by
|
||||
any of this -- it stays open. A row straddling the top edge is drawn
|
||||
both before and after; the test that would catch that mistake
|
||||
(`the_row_across_the_top_edge_is_drawn`) is in place, and fails if the
|
||||
rule is written against the row's top instead of its bottom.
|
||||
|
||||
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
|
||||
|
||||
- [x] **"You shouldn't be able to scroll below the bottom (or above
|
||||
top)."** Done in e922b73, as `List::clamp_to_content` rather than as a
|
||||
clamp inside the scroll setter: nothing at the moment of a `scroll`
|
||||
call knows where the content ends (that is what walking the rows finds
|
||||
out), so the correction is measured from the ends the layout walk
|
||||
already placed and written to the anchor. In the app that lands in the
|
||||
same frame -- a scrolled list is dirty, and `redraw_updates` drains
|
||||
the mark the correction sets before the frame is submitted -- so
|
||||
nothing displaced is displayed; only a full-tree redraw (a resize)
|
||||
could show one frame of it. A fling that reaches an end already ends
|
||||
there (`tick_fling`'s `hit_bound`), and now stops *on* the end rather
|
||||
than wherever the spline's last step had put it. Layer-1 tests at both
|
||||
ends, listed in the item above. The list's offset is not clamped to its content range while
|
||||
dragging and/or flinging. Compose's `LazyColumn` never moves content
|
||||
past its ends -- the overscroll *effect* on Android 12+ is a stretch
|
||||
drawn over clamped content, not a displacement. Clamp the offset in
|
||||
one place (`List`'s scroll setter, so drag, fling, page-in and
|
||||
programmatic scroll all go through it) and end a fling that hits the
|
||||
clamp. Test at layer 1: a drag past either end leaves the offset at
|
||||
the end; a fling into the end stops there.
|
||||
- [x] **"Flinging now actually works but is slower than Compose's
|
||||
immediately after releasing the flick (the slow down seems
|
||||
correct)."** Done; RUST.md's "The fling started too slow" has the
|
||||
derivation and the table. On `flick-120hz.touch` the release velocity
|
||||
goes from **12250px/s to 15250px/s**, and on an accelerating flick --
|
||||
the shape a real finger makes, and what the recording is too short to
|
||||
show -- from 1080 to 2445px/s. The curve was right; `VelocityTracker`
|
||||
was averaging total motion over the sample span, which cannot tell an
|
||||
accelerating flick from a steady drag.
|
||||
**Two things the plan for this item had wrong, both found by reading
|
||||
the sources rather than remembering them.** Compose's touch path is
|
||||
**not** `Strategy.Impulse`: `scrollable`/`draggable` release through
|
||||
the 2D `VelocityTracker`, which on Android is two
|
||||
`VelocityTracker1D(strategy = Lsq2)` over absolute *positions* -- a
|
||||
degree-2 least-squares fit, differentiated at the newest sample.
|
||||
Impulse is reached only by `DifferentialVelocityTracker`, for mouse
|
||||
wheel and trackpad. And there is **no minimum** fling velocity on that
|
||||
path: `ViewConfiguration.minimumFlingVelocity`'s 50dp/s is used only by
|
||||
`NestedScrollInteropConnection`, while `DefaultFlingBehavior` skips
|
||||
`abs(v) <= 1f` to dodge a NaN from the spline. So iris ports Lsq2, caps
|
||||
at 8000dp/s, and floors at 1px/s -- no 50dp/s threshold Compose does
|
||||
not have. `iris/benches/velocity_reference.py` is the independent
|
||||
transcription the checked-in numbers come from; the negative control
|
||||
(reverting to the average) fails exactly the seven tests about the
|
||||
estimator and none of the rest. The release log gains a debug
|
||||
`iris drag release samples:` line so a flick reported from the phone can
|
||||
be replayed at layer 1.
|
||||
- [~] **Input-event and timing report from the phone.** Iris: "add
|
||||
another button to copy input event info so that I can do some stuff
|
||||
manually and then send the event log to you ... instrument a lot of
|
||||
the code with timings so I can give you time reports through the
|
||||
same button." **Built on the log ring, 2026-09-07** (docs/RUST.md's
|
||||
own section): `iris::sense::log_input_event` (one line per platform
|
||||
pointer sample -- Android's `MotionEvent`, historical samples inline;
|
||||
winit's `WindowEvent`; the harness's `TouchScript` line) and
|
||||
`iris::diagnostics::log_frame` (one line per frame: frame number,
|
||||
the frame clock, time since the last input, layout/draw durations,
|
||||
`redraw_all`/`redraw_updates`/neither, primitives on screen,
|
||||
whether something is animating), both under
|
||||
`iris::diagnostics::trace_enabled()`, off by default because the ring
|
||||
is only 2000 lines / 256 KiB and both targets at 120Hz fill that in
|
||||
seconds. `iris/benches/report_to_touch.py` turns a report's
|
||||
`iris::input` lines back into a `.touch` file for layer 1/2 replay --
|
||||
round-tripped in `iris/transcript-fixture/tests/
|
||||
input_log_roundtrip.rs`. **Not wired to a button**: the Diagnostics
|
||||
pane is `iris/android-app/src/bench_client.rs`, open under another
|
||||
agent at the time this landed; `set_trace(bool)` is the whole surface
|
||||
a control needs. `docs/REVIEW-2026-09-07.md`'s D1 (the ring already
|
||||
drowned in per-frame `debug!` lines that predated this pass) is fixed
|
||||
in the same change -- see RUST.md's section for which four call
|
||||
sites.
|
||||
|
||||
## From the phone, 2026-09-07, night (build 92985ba, ai-app-bench bf2088b)
|
||||
|
||||
Iris pasted a full Copy report (Mali-G715 Vulkan, 2.55, 120Hz). What it
|
||||
showed, beyond her words:
|
||||
|
||||
- [ ] **"Sometimes when I try to catch it while it's still moving
|
||||
(particularly if I drag) then it fails to stop & snap to where finger
|
||||
is."** The report's release lines show catches ending as
|
||||
`v=-41`/`v=-274` pans, so the gesture *does* reach `Panning`, but the
|
||||
content under the finger does not follow it while the fling is still
|
||||
running and the slop has not been crossed. Compose: a down while
|
||||
`isScrollInProgress` stops the fling *at the down* and starts the
|
||||
drag immediately with no touch slop (`scrollable`'s
|
||||
`startDragImmediately = isScrollInProgress`); the content is pinned to
|
||||
the finger from the first sample. Port that: `PressStart` on a list
|
||||
with a live fling ends the fling on that sample and enters `Panning`
|
||||
without waiting for `DRAG_SLOP`; a release with no movement is then a
|
||||
`Released(None)`, not a tap (Compose does not deliver a click either).
|
||||
Layer-1 test on a flick followed by a down + small drag 150 ms later:
|
||||
offset tracks the finger sample-for-sample from the down.
|
||||
- [x] **"The copy report button seemed impossible to hit until I hit the
|
||||
diagnostics one." (done 2026-09-07, b8ea723).** Not hit-testing: the
|
||||
button logged `iris bench report: nothing to copy -- run the benchmark
|
||||
first` six times and did nothing on screen. A control that silently
|
||||
declines is the UI_RULES failure "a failure is reported where it
|
||||
happened": `copy_report` now always copies something -- the
|
||||
diagnostics pane's own text (with a first line saying no benchmark has
|
||||
run) when nothing has run yet, or the last report otherwise -- and
|
||||
never depends on another button having been pressed first.
|
||||
- [x] **"The logs seem way too big to send in this message box, causes a
|
||||
lot of lag." (done 2026-09-07, 7485d78 + b8ea723).** Two causes. (1)
|
||||
The ring was 1339 lines of `naga::front` / `wgpu_core` / `jni` DEBUG
|
||||
output with 4050 dropped: the ring logger accepted every crate at
|
||||
Debug, and the trace gate (992c472) only covered iris's own lines.
|
||||
`client_core::log_ring::ring_accepts` is the one filter now, applied at
|
||||
the ring rather than per callsite: Debug/Trace only from `iris`/
|
||||
`client_core` targets when tracing is on, Info and above from
|
||||
everything else. (2) Copy report appended the whole ring; it now
|
||||
appends `LogRing::tail_text(COPY_REPORT_TAIL_LINES)` (150, named at the
|
||||
constant) with a first line saying how many older lines were left out
|
||||
-- the full ring is still what the devlog provider hands Dev Updater.
|
||||
- [x] Keyboard: the report shows `ime_bottom=891 ime_visible=true` then
|
||||
back to 0 on the phone, so the insets now arrive with a height; the
|
||||
push-up was not reported broken this time.
|
||||
@@ -0,0 +1,459 @@
|
||||
# Review, 2026-09-07 — `ba2afba..origin/rustify`
|
||||
|
||||
Read-only review of the day's 24 commits: the glyph-atlas fix, the fling
|
||||
spline and Lsq2 velocity estimator, keyboard/IME insets and `targetSdk`,
|
||||
historical touch samples and the input clock, list culling / clamp /
|
||||
anchor re-homing, nested masks and `draw_again`, the headless harness +
|
||||
`transcript-fixture` + `rig-input`, desktop density, the release profile,
|
||||
platform fonts + the Android monospace patch, and the client-core log ring
|
||||
with `POST /client-log`.
|
||||
|
||||
**Verified while reviewing** (working tree, which also carries three other
|
||||
agents' uncommitted edits — `iris/src/sense.rs`, `iris/core/src/ui/render_state.rs`,
|
||||
`iris/src/lib.rs`, `iris/core/src/orientation/axis.rs`, and an untracked
|
||||
`iris/src/diagnostics.rs`): `cargo fmt --check` clean in `iris/`,
|
||||
`client-core/` and `server/`; `cargo clippy --all-targets` clean in `iris/`
|
||||
and `client-core/`; `cargo test --lib -p iris` 101 passed, `cargo test -p
|
||||
transcript-fixture` 10 passed. The `iris` doctest target fails to link
|
||||
(`extern location for iris_core does not exist`) — a stale build artefact,
|
||||
not a code fault, but worth knowing before trusting `cargo test -p iris`
|
||||
as a whole.
|
||||
|
||||
The work is unusually well documented and the two "a test that compared
|
||||
the code with itself" findings the authors made themselves are real and
|
||||
were fixed correctly. What follows is what is left.
|
||||
|
||||
Counts: **5 defects, 7 risks, 3 tests that cannot fail in the bug's
|
||||
direction, 7 rule findings, 2 nits.**
|
||||
|
||||
## Fix pass, 2026-09-07 evening
|
||||
|
||||
Every finding below carries a **Status** line. In summary: **13 fixed**
|
||||
(D1, D4, D5, R1, R5, R7, T1, T2, T3 and four of the rule findings and both
|
||||
nits), **6 moot or deferred** (D2, D3, R3, R4 and two rule findings, all
|
||||
of them in the phone-logging route that `06b8a1f` deleted or in files the
|
||||
devlog agent held open), and **2 not done on purpose** (R2, which waits on
|
||||
docs/LAYOUT.md's mask redesign, and R6, which needs Iris's own phone).
|
||||
|
||||
The commits are `2ec0fee` (D4), `7e79ec1` (D5), `551c013` (R1), `e10582a`
|
||||
(T1-T3), `ff1d6ea` (R5, R7) and `a6a100e` (the rename and the nits). Each
|
||||
fix that the rig can express carries a test, and each of those was
|
||||
confirmed by breaking its subject on purpose -- the break is recorded
|
||||
beside the assertion, so the next reader does not have to re-derive it.
|
||||
|
||||
---
|
||||
|
||||
## Defects
|
||||
|
||||
### D1 — the app's own log ring is drowned by the same day's per-frame `debug!` lines, so the route built to get Iris's logs to her carries almost none of them
|
||||
|
||||
`iris/android-app/src/lib.rs:132` installs the ring at `LevelFilter::Debug`,
|
||||
and `client-core/src/log_ring.rs:279` (`RingLogger::enabled`) returns
|
||||
`true` unconditionally by design, so **every `log::debug!` in the process
|
||||
lands in a 2000-line / 256 KiB ring**. In the same commit range that ring
|
||||
became the only way a line reaches Iris, three ungated per-frame `debug!`
|
||||
callsites are live:
|
||||
|
||||
- `iris/src/android/view.rs:446` and `:509` — two lines *per rendered frame*.
|
||||
- `iris/src/widget/list.rs:576` — `iris fling tick:`, one line per fling tick.
|
||||
- `iris/src/widget/text/mod.rs:81` — one per text shape (many per frame while rows compose).
|
||||
|
||||
**Failure scenario.** Iris flicks the transcript on a 120 Hz phone. That is
|
||||
~240–360 debug lines a second; the ring's 2000-line bound is exhausted in
|
||||
**under ten seconds**, so by the time she presses `Copy report` every
|
||||
`log::info!` about what she was actually investigating has been evicted.
|
||||
The uploader makes it worse: it sends at most the ring per 10 s wake
|
||||
(2000 lines ≈ 200 lines/s) against ~350 lines/s produced, so it also runs
|
||||
permanently behind and pushes tens of KB/s of frame spam over the tunnel.
|
||||
|
||||
Note that another agent has already built the right mechanism — the
|
||||
untracked `iris/src/diagnostics.rs` has `set_trace`/`trace_enabled`, a
|
||||
default-off gate, and its module doc states this exact problem in as many
|
||||
words. It gates `iris::input`/`iris::frame`; it does **not** gate the four
|
||||
callsites above.
|
||||
|
||||
*Fix*: put `List::tick_fling`'s line and `view.rs`'s two `render():` lines
|
||||
behind `iris::diagnostics::trace_enabled()` (the mechanism that already
|
||||
exists for exactly this), and/or record into the ring at `Info` while
|
||||
leaving `android_logger` at `Debug`.
|
||||
|
||||
**Status:** fixed in `992c472` (verified 2026-09-07: all four callsites, plus `sense.rs`'s drag-release samples line, now sit behind `iris::diagnostics::trace_enabled`, and `input_log_roundtrip` proves both directions).
|
||||
|
||||
### D2 — `POST /client-log` can make `ai-server` write an unbounded runtime log at an authenticated client's request
|
||||
|
||||
`server/src/routes.rs:1473` bounds the **line count** (500) and nothing
|
||||
else. The route sits inside the router that applies
|
||||
`DefaultBodyLimit::max(32 * 1024 * 1024)` at `server/src/routes.rs:179`
|
||||
(raised for phone photos), so one request may carry 500 lines of ~64 KiB
|
||||
each, and each is re-emitted verbatim into `tracing`. There is no
|
||||
per-message cap on the server, no rate limit, and the runtime log
|
||||
`ai-server` writes is the file Dev Updater tails and never rotates.
|
||||
`MAX_MESSAGE_BYTES` (4096) exists only in the *client*
|
||||
(`client-core/src/log_upload.rs:33`), i.e. the server trusts a value the
|
||||
attacker controls.
|
||||
|
||||
**Failure scenario.** A buggy client (a `log::debug!` in a loop is enough —
|
||||
see D1) or one holding a leaked bearer token posts 32 MiB every 10 s; the
|
||||
host's disk fills and every other component's log goes with it.
|
||||
|
||||
*Fix*: give the route its own `DefaultBodyLimit` (the attachments route at
|
||||
`:175` is the precedent for a per-route limit) and truncate each `message`
|
||||
server-side to the same 4096 bytes rather than assuming the client did.
|
||||
|
||||
**Status:** moot -- `POST /client-log` was deleted with the whole upload route (`06b8a1f`), the app hands its log to Dev Updater through an on-device ContentProvider instead. Nothing to bound.
|
||||
|
||||
### D3 — lines the ring drops before the uploader sends them vanish with nothing saying so
|
||||
|
||||
`LogRing::since` (`client-core/src/log_ring.rs:169`) filters `seq >= cursor`
|
||||
and silently returns fewer lines when eviction has passed the cursor;
|
||||
`LogUploader::flush_once` (`:94`) then advances to whatever came back.
|
||||
`dropped` is counted (`log_ring.rs:109`) and shown in the *local*
|
||||
diagnostics pane, but it is never put in the upload body, and
|
||||
`ClientLogBody` has no field for it.
|
||||
|
||||
**Failure scenario.** The tunnel is down for two minutes; the ring wraps.
|
||||
When it comes back, the server log jumps from `#812` to `#5106` with no
|
||||
line saying anything was lost. This is precisely the "unknown state
|
||||
sharing a value with the empty state" UI_RULES asks to design first, and
|
||||
the module doc for `dropped` claims it is "reported rather than inferred"
|
||||
— it is, but only on the half of the path nobody is reading.
|
||||
|
||||
*Fix*: carry `dropped` (or `firstSeq`) in the batch and have `client_log`
|
||||
emit one `warn!` when the sequence is not contiguous with the last batch
|
||||
from that `source`.
|
||||
|
||||
**Status:** moot -- `client-core/src/log_upload.rs` was deleted with the route (`06b8a1f`). Whatever the ContentProvider does about eviction is that design's question, not this one's.
|
||||
|
||||
### D4 — the input clock anchors on the first event's *own* time, so that event's historical samples are dated before the anchor: the ordering assert fires, and release silently collapses them onto one instant
|
||||
|
||||
`iris/src/android/view.rs:628` takes the anchor as
|
||||
`(Instant::now(), event.event_time_nanos())` from the first `MotionEvent`
|
||||
the view ever sees, and `at()` computes
|
||||
`anchor_at + (sample_time - anchor_nanos).max(0)`. Historical samples of
|
||||
that same event are by definition **earlier** than its own `event_time`.
|
||||
|
||||
**Failure scenario.** The first event this view receives is an
|
||||
`ACTION_MOVE` (the `DOWN` was delivered to another view, or the view was
|
||||
attached mid-gesture). Its historical samples are, say, 12 ms before
|
||||
`anchor_nanos`; `at()` clamps all of them to `anchor_at`, so the tracker
|
||||
receives three samples with identical timestamps, the Lsq2 fit is
|
||||
degenerate, and the flick reads 0 px/s. In a debug build the
|
||||
`debug_assert!(ht >= previous)` at `:653` fires first — but `previous`
|
||||
starts at `anchor_nanos` (`:651`), which is a value from a *different*
|
||||
event, so that assert is also the wrong comparison for the first sample of
|
||||
every later event.
|
||||
|
||||
*Fix*: anchor on the earliest sample of the first event
|
||||
(`historical_event_time_nanos(0)` when `history_size() > 0`, else
|
||||
`event_time`), and seed `previous` from the previous event's last sample
|
||||
rather than from the anchor.
|
||||
|
||||
**Status:** fixed in `2ec0fee`. The arithmetic moved into `sense::PointerClock`, which anchors at `now - (event_time - oldest_sample)` and carries the last sample seen *across* events, so the ordering assert compares against the previous event's last sample rather than the anchor. It lives in `sense` because `iris::android` is `cfg`'d out everywhere but the device: `sense_tests.rs`'s `the_first_events_batched_samples_are_dated_apart` reports `[0ns, 0ns, 0ns]` against the old anchoring.
|
||||
|
||||
### D5 — the "before" velocity quoted in four places is not what the reference script prints
|
||||
|
||||
`iris/benches/velocity_reference.py`, run today, prints **12250 px/s** for
|
||||
`flick-120hz.touch`'s average and **12500 px/s** for "press and one move
|
||||
frame". Four places say 11750 for both:
|
||||
|
||||
- `docs/RUST.md:900` (`flick-120hz.touch | 11750 px/s`)
|
||||
- `docs/RUST.md:905` (`press + one move frame | 11750 px/s`)
|
||||
- `docs/IRIS_TODO.md:1026`
|
||||
- `iris/transcript-fixture/tests/phone_screen.rs:55`
|
||||
|
||||
`iris/src/sense.rs:1406` has the correct 12250, so the two halves of the
|
||||
same change disagree. The file that carries the wrong number is the one
|
||||
that says "every number below is printed by `velocity_reference.py` … do
|
||||
not 'fix' one by running the Rust and copying what it said". One of the
|
||||
two rows also being 11750 for a completely different sample set is the
|
||||
tell.
|
||||
|
||||
*Fix*: replace 11750 with the script's own 12250 / 12500 in those four
|
||||
places, or say which run produced 11750.
|
||||
|
||||
**Status:** fixed in `7e79ec1`. All four places now say 12250 / 12500, the 1.30x ratio becomes 1.24x, and RUST.md records where 11750 half came from (196 px over a 16.68 ms **60 Hz** frame rather than the recording's own 16 ms -- which explains the flick row and not the other one, so that one was copied).
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
### R1 — every new invariant guard is a `debug_assert!`, and the phone runs release
|
||||
|
||||
The five guards added today —
|
||||
`iris/src/widget/list.rs:1156` (a `List` must be inside a `.masked()`),
|
||||
`:1218` (`extents` holds only on-screen rows),
|
||||
`iris/src/android/view.rs:653` (historical sample ordering),
|
||||
`iris/src/sense.rs:1076` (`poly_fit_least_squares` sample count), and
|
||||
`iris/core/src/ui/painter.rs`'s doubled-`set_mask` check — are all
|
||||
`debug_assert!`. `docs/RUST.md` records that the bench APK **must** be
|
||||
installed as `release` on the emulator (the debug `libmain.so` is 325 MB
|
||||
and will not install) and Iris's phone gets release too. So none of these
|
||||
can fire on any build anybody actually runs; in release a `List` drawn
|
||||
without a mask silently paints over its surroundings again — the exact
|
||||
fault e922b73 was written to fix.
|
||||
|
||||
*Fix*: for the two that are cheap and once-per-draw (`is_masked`, the
|
||||
extents check), consider a plain `assert!` or a one-shot `log::error!`, so
|
||||
the guard survives into the build the defect was found in.
|
||||
|
||||
**Status:** fixed in `551c013`. `is_masked`, the `extents` check, `set_mask`'s doubled-call check, `Painter::glyphs`'s atlas generation and `List::fling`'s finiteness are `assert!`/`assert_eq!` now; `List::place`'s slot precondition, `poly_fit_least_squares`'s two, and `PointerClock::sample`'s ordering stay `debug_assert!` and say in a comment why. The layer-1 suites pass in `--release` as well as debug, which is what says the promoted ones do not fire on a real replayed flick.
|
||||
|
||||
### R2 — a straddling row is now invisible above the list and still tappable through the header
|
||||
|
||||
Masks are applied in the fragment shader
|
||||
(`iris/core/src/render/shader.wgsl:203`); the CPU hit path
|
||||
(`UiRenderState::resolved_region`, `iris/core/src/ui/render_state.rs:709`)
|
||||
does not consult `masks` at all. Before today the top of a straddling row
|
||||
was drawn over the header *and* hit-testable there; now it is clipped away
|
||||
but still hit-testable, which is worse — a tap on "Run benchmark" can land
|
||||
on an invisible link in the row behind it. `docs/LAYOUT.md:1012` ("Hit-
|
||||
testing applies the shape") is design, not code.
|
||||
|
||||
*Fix*: until LAYOUT.md's mask redesign lands, intersect a widget's hit
|
||||
region with its mask chain in `resolved_region`; the chain walk already
|
||||
exists on the GPU side.
|
||||
|
||||
**Status:** not done, deliberately -- docs/LAYOUT.md's mask redesign ("masks reference a drawn primitive instead of copying a shape", `1121d7c`) is where hit-testing gets the shape, and intersecting a chain in `resolved_region` now would be a second mechanism to unpick. Pointer left here rather than a fix.
|
||||
|
||||
### R3 — three copies of one wire contract, none of them linked
|
||||
|
||||
`client-core/src/log_upload.rs:28` (`MAX_LINES_PER_BATCH = 500`) and
|
||||
`server/src/routes.rs:1418` (`CLIENT_LOG_MAX_LINES = 500`) must agree, in
|
||||
different crates, with only a comment saying so; the body itself is built
|
||||
by hand with `serde_json::json!` on one side and parsed by a
|
||||
`#[serde(deny_unknown_fields)]` struct on the other. This project already
|
||||
has the mechanism for exactly this — `event-model`, a crate both `server`
|
||||
and `client-core` depend on precisely so "the app hand-mirroring it" stops
|
||||
happening (`server/Cargo.toml:16` says so).
|
||||
|
||||
**Failure scenario.** Somebody raises the client's batch to 1000. Every
|
||||
upload now returns 400, the uploader retries the *same* batch from the same
|
||||
cursor forever, and the only sign is one line in a diagnostics pane on a
|
||||
phone.
|
||||
|
||||
*Fix*: move `ClientLogLine`/`ClientLogBody` and the batch constant into a
|
||||
shared crate.
|
||||
|
||||
**Status:** moot -- both copies went with the route (`06b8a1f`). If a client/server contract comes back, `event-model` is still the answer.
|
||||
|
||||
### R4 — `build.rs` bakes in a CA it never asks Cargo to watch, and the bench build now has no rebuild trigger at all
|
||||
|
||||
`emit_log_config` (`iris/android-app/build.rs:92`) calls `read_pinned_ca()`
|
||||
but emits only `rerun-if-env-changed` for `AI_APP_LOG_HOST/_PORT/_TOKEN` —
|
||||
no `rerun-if-changed` for the CA *file*, and (because the bench build
|
||||
returns at `:65`, before the transcript path's declarations) no
|
||||
`rerun-if-env-changed=AI_APP_CA`/`XDG_CONFIG_HOME` either. Emitting any
|
||||
`rerun-if-*` directive turns off Cargo's default "rerun when anything in
|
||||
the package changes" heuristic, so the bench build lost the only trigger it
|
||||
had.
|
||||
|
||||
**Failure scenario.** `~/.config/ai-app` is wiped (AGENTS.md calls this the
|
||||
one-way door), `ai-server` mints a new CA, the APK is rebuilt — and
|
||||
`build.rs` does not re-run, so the APK still pins the dead CA and every
|
||||
upload fails with a TLS error nobody can attribute.
|
||||
|
||||
*Fix*: `println!("cargo:rerun-if-changed={}", ca_path.display())` inside
|
||||
`read_pinned_ca`, and move the `AI_APP_CA`/`XDG_CONFIG_HOME` declarations
|
||||
above the bench early-return.
|
||||
|
||||
**Status:** moot -- `iris/android-app/build.rs` was deleted (`06b8a1f`/`d8562d9`): the destination comes from the enrolment link now, so nothing is baked in at build time and there is nothing for Cargo to watch.
|
||||
|
||||
### R5 — desktop density is read once and never updated
|
||||
|
||||
`iris/src/default/mod.rs:254` reads `content_scale(window)` at startup and
|
||||
sets it on both `rsc.ui.text.density` and `render`. `WindowEvent::
|
||||
ScaleFactorChanged` is not handled, and `UiRenderer::resize` deliberately
|
||||
no longer consults `scale_factor`. Dragging the window to a monitor with a
|
||||
different scale leaves every `dp(...)` and every rasterised glyph at the
|
||||
old density — the same class of disagreement the commit removed elsewhere.
|
||||
It is invisible here (every display on this machine is 1.0), which is why
|
||||
it needs writing down.
|
||||
|
||||
**Status:** fixed in `ff1d6ea`. `WindowEvent::ScaleFactorChanged` re-reads `content_scale` -- through that function, so `IRIS_SCALE` still pins `--phone`'s density instead of following the monitor -- and `UiRenderState::set_density` marks the tree for a full redraw when the value actually changes, since `Text::shape` keys its cache on `(attrs, width, density)`.
|
||||
|
||||
### R6 — removing the bundled fonts removed the guard for a fault that was found on the phone, and the check was run on the desktop
|
||||
|
||||
`iris/core/src/primitive/text.rs`'s `register_bundled_fonts` existed
|
||||
because "bold spans on a real phone rendered as blank gaps of the correct
|
||||
advance width" — the deleted doc says so. Its removal is Iris's own call
|
||||
and is recorded properly in `docs/DECISIONS.md`, but the verification
|
||||
recorded there is "checked with CJK + emoji **on desktop**", which is the
|
||||
half that cannot fail: the fault was Android's font enumeration resolving
|
||||
a weight/style. `iris/transcript-ui/src/tool.rs:110`'s comment is honest
|
||||
that `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) are now "a bet"
|
||||
that the platform monospace face has them — which is UI_RULES' "don't rely
|
||||
on characters the platform might not have", stated and then accepted.
|
||||
|
||||
*Fix*: before the next phone build, look at a bold run and the three
|
||||
chevrons on Iris's device specifically; the emulator's font set is not
|
||||
evidence for hers.
|
||||
|
||||
**Status:** not done here -- it is a *look at it on Iris's phone* item, and no build in this VM is evidence about her device's font set. Carried forward as the review said: before the next phone build, look at a bold run and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on her device specifically.
|
||||
|
||||
### R7 — the least-squares fit clamps a degenerate norm instead of detecting it
|
||||
|
||||
`iris/src/sense.rs:1105`: `1.0 / dot(...).sqrt().max(1e-6)`. Compose's
|
||||
`polyFitLeastSquares` treats `norm < 1e-6` as "vectors are linearly
|
||||
dependent, no solution" and bails; clamping instead produces a `q` row of
|
||||
zeros, a zero on `r`'s diagonal, and a `0/0` that the `is_finite` check at
|
||||
`:1059` happens to catch. It works, but it works by accident and the escape
|
||||
is not the one the source it is transcribed from takes.
|
||||
|
||||
**Status:** fixed in `ff1d6ea`. `poly_fit_least_squares` returns `Option` and bails at `DEGENERATE_NORM` (Compose's `0.000001f`) instead of clamping; `velocity()` answers 0 on `None`. `a_fit_through_linearly_dependent_points_has_no_solution` reports `Some([NaN, NaN, NaN])` with the clamp back in place.
|
||||
|
||||
---
|
||||
|
||||
## Tests that cannot fail in the direction the bug would go
|
||||
|
||||
### T1 — `iris/transcript-fixture/tests/phone_screen.rs:64` computes the expected fling duration with the calculator under test, and asserts it one-sidedly
|
||||
|
||||
`let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);` then
|
||||
`assert!(ran_for <= expected + 2 frames)`. This is the same
|
||||
"calculator compared with itself" shape the fling-spline commit
|
||||
(73f956f) identified and fixed elsewhere, and the direction it can fail in
|
||||
is "the fling ran too long" — never "the fling stopped dead", which is
|
||||
literally Iris's reported symptom. The companion
|
||||
`assert_ne!(before, after)` passes on one pixel of travel. A fling that
|
||||
settles on the first tick passes this test.
|
||||
|
||||
*Fix*: add a lower bound from `velocity_reference.py`'s number (a fling at
|
||||
-15250 px/s at density 2.55 must run ≥ ~1.4 s and travel ≥ ~6000 px), not
|
||||
from `FlingCalculator`.
|
||||
|
||||
**Status:** fixed in `e10582a`. Both bounds come from `fling_spline_reference.py`, which gained this case's own line (`density=2.55 v=15250.0: distance=11057.424px duration=2.0716s`), and travel is measured in pixels from a row's own on-screen extent (10527px measured). Scaling `tick_fling`'s elapsed by 1000 reports "stopped after 8ms"; scaling its delta by 0.01 reports "travelled 111px".
|
||||
|
||||
### T2 — `top_edge.rs:150` checks a row *count* on the leg where the culling bug appeared, and the box only on the other leg
|
||||
|
||||
`rows_that_have_left_the_viewport_are_not_drawn` asserts `rows.len() <= 24`
|
||||
on the outbound leg and the per-row `inside the box` predicate only on the
|
||||
return leg. The doc explains why (an unmeasured row must be drawn to be
|
||||
measured), which is correct — but it means the test's name is only true of
|
||||
half of it, and a regression that draws 20 rows in the wrong *place* on the
|
||||
outbound leg passes.
|
||||
|
||||
**Status:** fixed in `e10582a`. The first leg still cannot assert the box (an unmeasured row has to be drawn to be measured), so there is a third leg -- back again, every height known. Widening `intersects_viewport` downwards passes all 40 forward steps and fails at "back 6".
|
||||
|
||||
### T3 — `top_edge.rs:116` checks that a mask exists and where it is, not that it reaches anything
|
||||
|
||||
`the_list_is_clipped_to_its_own_box` asserts `active.mask != MaskIdx::NONE`
|
||||
and that the mask's region lies within the list's box. It never checks the
|
||||
row primitives actually reference that mask, so a broken `Mask::parent`
|
||||
chain — the thing d507ae4 introduced — would leave this green while a code
|
||||
fence inside a row drew unclipped again.
|
||||
|
||||
*Fix*: assert that a row primitive's mask chain contains the list's mask
|
||||
slot.
|
||||
|
||||
**Status:** fixed in `e10582a`. It walks every row primitive's mask chain and requires the list's own slot on it, and rejects a chain that loops. Forcing `Painter::set_mask`'s `parent` to `NONE` fails it with "clips to [Id(1)], a chain that never reaches the list's own mask Id(0)".
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- **`iris/src/widget/list.rs:576` is a second mechanism for per-frame
|
||||
instrumentation.** `iris::diagnostics::trace_enabled` exists for exactly
|
||||
"a default-off `debug!` in a hot path" and this line does not use it.
|
||||
(Cause of D1; the gate is in the untracked `diagnostics.rs`, so at the
|
||||
reviewed commit the line is simply ungated.)
|
||||
- **`server/src/routes.rs:1518` (`client_log_time`) duplicates
|
||||
`client-core/src/log_ring.rs:76` (`clock_time`)** — the same arithmetic
|
||||
written twice in two crates, with a comment noting they must agree. Same
|
||||
shared-crate answer as R3.
|
||||
- **`client-core/src/log_ring.rs:301`'s doc claims more than the code
|
||||
delivers**: "the caller is named in the error so it is findable" —
|
||||
`log::SetLoggerError` names nobody. `iris/android-app/src/app_log.rs:44`
|
||||
repeats the claim.
|
||||
- **Stale comment: `iris/src/android/view.rs:624`** cites
|
||||
`VelocityTracker::add_sample`'s debug assert; the method was renamed to
|
||||
`add_position` in the same commit range.
|
||||
- **`MOVE_CHAIN_LIMIT` now bounds two different chains** (move offsets and
|
||||
masks) under a name that says one, in both
|
||||
`iris/core/src/ui/render_state.rs:63` and `shader.wgsl:97`. The shader's
|
||||
comment already calls it "the bound on the parent walk"; the constant
|
||||
should say that too, or masks should get their own.
|
||||
- **`iris/src/sense.rs:1434`'s stated negative control is not reproducible
|
||||
as written.** "Reverting `velocity` to `total / span` fails exactly this
|
||||
one, the flick recording, and `phone_screen.rs`" — but `samples` now
|
||||
holds *positions*, so `total / span` over them gives 2750 for the steady
|
||||
drag too, and the commit message for the same change says "exactly seven
|
||||
tests". Two numbers for one experiment.
|
||||
- **`iris/android-app/src/bench_client.rs:393`'s `ime_visible` is right and
|
||||
its sibling one line up is not.** `set_bottom_inset(rsc,
|
||||
insets.bottom.max(insets.ime_bottom))` still infers "make room" from a
|
||||
`max`, so during the slide-in the composer is padded by the system-bar
|
||||
inset while `ime_visible` already says the keyboard is up. Harmless
|
||||
today; it is the same conflation the comment beside it warns about.
|
||||
|
||||
**Status of the rule findings, 2026-09-07 evening.**
|
||||
|
||||
- `list.rs:576`'s ungated per-frame line -- **fixed in `992c472`** with
|
||||
the rest of D1.
|
||||
- `routes.rs:1518`'s `client_log_time` duplicating `log_ring.rs`'s
|
||||
`clock_time` -- **moot**: the route was deleted (`06b8a1f`).
|
||||
- `log_ring.rs:301`'s "the caller is named in the error" -- **deferred to
|
||||
the devlog agent**; `client-core/src/log_ring.rs` is its file this pass,
|
||||
and `app_log.rs` no longer repeats the claim.
|
||||
- `view.rs:624`'s stale `VelocityTracker::add_sample` -- **fixed in
|
||||
`2ec0fee`**; the paragraph was rewritten for the anchoring change and
|
||||
now names `PointerClock` rather than a method that no longer exists.
|
||||
- `MOVE_CHAIN_LIMIT` naming two chains -- **fixed in `a6a100e`**: renamed
|
||||
to `PARENT_CHAIN_LIMIT` in `render_state.rs` and `shader.wgsl` at once
|
||||
(it had no other users), with the doc naming both chains it governs.
|
||||
- `sense.rs:1434`'s unreproducible negative control -- **fixed in
|
||||
`7e79ec1`**. Rerun with `velocity` reverted to `(newest - oldest) /
|
||||
span`: seven fail in `-p iris` (the flick recording, the accelerating
|
||||
flick, the horizon, the stopped finger, the minimum sample count, both
|
||||
`drag_gesture` flick tests) plus `phone_screen.rs`'s flick. RUST.md's
|
||||
"exactly seven" was right; the doc comment's "exactly this one, the
|
||||
flick recording, and `phone_screen.rs`" was not, and now says the same
|
||||
thing RUST.md does.
|
||||
- `bench_client.rs:393`'s `set_bottom_inset(.., max(..))` -- **deferred to
|
||||
the devlog agent**; `iris/android-app/**` was open under it this pass.
|
||||
|
||||
## Nits
|
||||
|
||||
- `iris/src/sense.rs:798` computes `self.velocity.velocity()` twice on a
|
||||
release when `info` logging is on (once for the outcome, once for the
|
||||
log line) — a full Lsq2 fit each.
|
||||
- `iris/transcript-ui/src/selection.rs:303` calls `ui.ui_mut().animate(id)`
|
||||
even when `fling()` bailed (`|v| <= 1.0`, or no anchor). Harmless — the
|
||||
first `tick` unregisters — but it registers an animation that is known
|
||||
not to exist.
|
||||
|
||||
---
|
||||
|
||||
**Status of the nits, both fixed in `a6a100e`.** `DragGesture`'s release
|
||||
computes `velocity()` once into a local both the outcome and the
|
||||
`iris drag release:` line read. `selection.rs`'s `animate(id)` is behind
|
||||
`is_scrolling()`, which is the same answer `List::fling` itself reached --
|
||||
and `phone_screen.rs`'s recorded flick still flings, which is the half
|
||||
that says the guard did not turn a working release off.
|
||||
|
||||
## Commits reviewed
|
||||
|
||||
```
|
||||
7e4e26a iris: resolve fontique's Android monospace generic family ourselves
|
||||
84a13e8 iris: a fling starts at Compose's velocity, which is a curve fit and not an average
|
||||
452c442 docs/RUST.md: queue -- logging landed; iris app enrolment ...
|
||||
238057a docs: the phone-logging decision, how to use it, and two build-apk traps
|
||||
896c93a iris: drop bundled Noto Sans, match Compose's platform-font fonts
|
||||
690161e docs: the transcript's edges were three faults, and what the rig found
|
||||
e922b73 iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
|
||||
d507ae4 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
|
||||
9ed01e2 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity ...
|
||||
5be9f1b iris-android-app: keep the app's own log, put it in Copy report, upload it
|
||||
977bdb9 client-core: the app's own log ring, and POST /client-log to get it off a phone
|
||||
9cd1263 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
|
||||
42af780 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
|
||||
4274b8b Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
|
||||
73f956f iris: the fling curve was the identity function, and the keyboard was a targetSdk
|
||||
038f6a3 docs: the test rig's layers 1 and 2, with their commands and their limits
|
||||
1121d7c docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape ...
|
||||
232de0e iris: a phone-shaped desktop window, driven by the same touch recordings
|
||||
e430880 docs: phone report 2026-09-07, rows at the transcript's top edge culled early ...
|
||||
a999bd1 docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue
|
||||
6840edf iris-android-app: the bench's fixture half comes from transcript-fixture
|
||||
3332201 iris: a headless in-process harness, and the bench fixture as a shared crate
|
||||
7f4ea7e docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range
|
||||
591128e AGENTS.md: the phone app and the planned desktop app share widgets and styling
|
||||
```
|
||||
+883
-8
@@ -43,6 +43,566 @@ 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.
|
||||
|
||||
### The transcript's edges (2026-09-07, e922b73 + d507ae4)
|
||||
|
||||
Iris's two screenshots of the top edge -- rows drawn over the header in
|
||||
one, a blank band in the other -- were **three** faults, and the rule
|
||||
that fixes all three is the one the IRIS_TODO entry asked for: *a row is
|
||||
drawn if any part of it overlaps the list's own box, and nothing outside
|
||||
that box reaches the screen* (`List::intersects_viewport`). Neither
|
||||
suspected cause was right, which is worth reading before trusting the
|
||||
next suspicion in this file: there was no visible-range test comparing a
|
||||
row's top against the viewport's, and `03c6be8`'s header duplicate is
|
||||
untouched and still open.
|
||||
|
||||
What was actually wrong: **nothing clipped the list at all** (and it
|
||||
could not be `.masked()`, because `Painter::set_mask` aborted whenever
|
||||
an ancestor had a mask and the list's own rows use one -- so masks
|
||||
nest now, `Mask::parent`, walked in the fragment stage); **the layout
|
||||
walk drew every row between the anchor and the viewport**, which after
|
||||
panning is however far you have panned, measured at 64 rows placed for
|
||||
a 2012px viewport; and **the list could rest past its own first row**,
|
||||
which is the blank band, and is also the first item of Iris's later
|
||||
report the same day. Details and the six layer-1 test names are in
|
||||
docs/IRIS_TODO.md's 2026-09-07 entry.
|
||||
|
||||
Three things this says about the rig, since the rig is new:
|
||||
|
||||
- **Layer 1 found all of it, in seconds.** The tests open the real
|
||||
screen over the real fixture under a bench-app-shaped header
|
||||
(`iris/transcript-fixture/tests/top_edge.rs`, `cargo test -p
|
||||
transcript-fixture`, ~5s), and each was confirmed to fail on its own
|
||||
subject and no other by breaking that subject on purpose. The
|
||||
emulator was not used, and the phone will only be asked to confirm.
|
||||
- **Layer 2 is where the clip is visible.** `iris/run-headless.sh phone
|
||||
--phone --replay transcript-fixture/touch/flick-120hz.touch --shot
|
||||
/tmp/p.png -- -p transcript-fixture`, run with `.masked()` removed,
|
||||
draws the bottom row's text over the composer bar; with it, the same
|
||||
flick clips cleanly at the bar. The window has no header, so the top
|
||||
edge is the window edge there -- the header case is layer 1's.
|
||||
- **An assertion that reads the wrong thing hides the bug it is for.**
|
||||
`fling_toward_the_start_stops_at_the_first_row` asserted the first
|
||||
row's top was `>= -0.5` while that row sat 1398px *below* a 600px
|
||||
viewport with the screen blank: `extents` then held rows that were
|
||||
not on screen, so the read was satisfied by the failure. `extents`
|
||||
now holds only what is on screen -- which is what `key_at` always
|
||||
claimed of it -- asserted at the end of every draw, and the test
|
||||
checks both directions.
|
||||
|
||||
### Phone logging, 2026-09-07 (rebuilt on Dev Updater's own tab)
|
||||
|
||||
**The problem**: Iris tests these builds on a phone with no `adb`, and
|
||||
Android forbids one app reading another's `logcat`, so a `log::info!` in
|
||||
the iris app could not reach her at all. What she asked for was Dev
|
||||
Updater, which she already reads.
|
||||
|
||||
**The route, in one line**: the app keeps its own bounded log ring and
|
||||
**exposes it on the device** through a `ContentProvider`; Dev Updater's
|
||||
phone app -- on the same phone -- reads that while the component's
|
||||
**Runtime** tab is open and forwards what is new to its own build machine,
|
||||
into that APK component's runtime log. No tunnel, no token, no second
|
||||
enrolment.
|
||||
|
||||
**It is Dev Updater's contract, not iris's feature.** Written down in
|
||||
dev-updater's `README.md` under "An app's own log", so any app that server
|
||||
delivers gets the tab by implementing it. The shape:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| authority | `<applicationId>.devlog` |
|
||||
| read permission | `dev.updater.permission.READ_DEVLOG` (`android:readPermission`) |
|
||||
| `lines?since=<seq>` | held lines with `seq >= since`, ascending: `seq, t_ms, level, target, message` |
|
||||
| `status` | one row: `held, dropped, newest_seq` |
|
||||
|
||||
`newest_seq` is `-1` for an empty ring and is what makes a **restart**
|
||||
visible: the ring is in memory, so a new process starts again at zero and
|
||||
a reader holding a cursor would otherwise skip everything since, silently.
|
||||
`insert`/`update`/`delete` throw. `notifyChange` is **not** implemented --
|
||||
the ring is filled by a `log::Log` backend on whatever thread logged, and
|
||||
routing that to a provider means a callback through `client-core` for
|
||||
every platform, so Dev Updater polls (about a second, only while the tab
|
||||
is open) and the contract says so.
|
||||
|
||||
What exists now:
|
||||
|
||||
- `client_core::log_ring` -- `LogRing` (2000 lines / 256 KiB, whichever
|
||||
bites first, with `dropped` reported rather than inferred), `since(seq)`,
|
||||
`newest_seq()`, `RingLogger` (a `log::Log` backend that records *and*
|
||||
forwards to the platform's own logger), and `install_process_logger`.
|
||||
Reading does not consume, so the report and the provider are two readers
|
||||
of one ring.
|
||||
- `iris/android-app/src/devlog.rs` and
|
||||
`app/src/main/java/dev/iris/android/demo/DevLogProvider.java` -- the
|
||||
platform glue only (the sharing rule): a `String[]` across JNI, a
|
||||
`MatrixCursor` on the Java side, and `nativeReady` telling Rust the
|
||||
authority the provider actually registered under.
|
||||
- `iris/android-app/src/app_log.rs` -- `android_logger` as the logger to
|
||||
forward to, and the Diagnostics pane's two lines: how many lines are
|
||||
held, and **`devlog provider: content://<authority>`** (or "declared,
|
||||
not created yet", since Android creates a provider lazily). Named from
|
||||
what the provider registered rather than composed from the package here,
|
||||
so a screenshot of that pane is evidence the contract is live.
|
||||
- On the Dev Updater side (its own repo, commit `013d711`): the tab for
|
||||
every component rather than only a server's, `DevLog.kt`'s reader and
|
||||
cursor, `POST /apps/{key}/components/{name}/runtime-log`, and
|
||||
`$XDG_DATA_HOME/dev-updater/devlogs/<key>-<component>.log` with one
|
||||
rotation at 4 MiB.
|
||||
|
||||
**Deleted with it, so there is one mechanism**: `client-core`'s
|
||||
`log_upload`, `POST /client-log` on `ai-server`, the `AI_APP_LOG_*` baking
|
||||
in `iris/android-app/build.rs` (which left that file with nothing to do,
|
||||
so it is gone), and the uploader fields on both Android clients.
|
||||
|
||||
**How to use it.** Build and install the bench APK; open Dev Updater ->
|
||||
the ai-app project -> the **app** component's log button -> the
|
||||
**Runtime** tab. Nothing to configure: the tab finds the provider from the
|
||||
package the component installs. A component whose package exposes none
|
||||
says so in as many words, which is a different sentence from an empty log.
|
||||
|
||||
**Verified 2026-09-07 end to end** -- see "Verified" below.
|
||||
|
||||
**Two rig traps this cost an hour to find, both in `build-apk.sh`, both
|
||||
still there.** Written down rather than fixed because fixing them belongs
|
||||
with whoever next touches that script:
|
||||
|
||||
1. **Gradle's merged-native-libs cache survives `rm -rf jniLibs`.** The
|
||||
script removes `app/src/main/jniLibs` before each build (its own
|
||||
comment says why), but Gradle's `mergeReleaseNativeLibs` is *up to
|
||||
date* against its cached inputs, so a build that switches ABI packages
|
||||
the previous ABI. A `--abi x86_64` release APK contained
|
||||
`lib/arm64-v8a/libmain.so`, installed fine, and aborted at startup with
|
||||
`Could not get adapter!: NotFound { active_backends: VULKAN }` under
|
||||
`libndk_translation` -- which reads exactly like the phone's own Vulkan
|
||||
problem and is nothing of the kind. `rm -rf app/build/intermediates`
|
||||
before the build is the workaround; check with
|
||||
`python3 -c "import zipfile; print([i.filename for i in
|
||||
zipfile.ZipFile('...apk').infolist() if i.filename.endswith('.so')])"`.
|
||||
2. **The debug bench APK is 648 MB and will not install**
|
||||
(`INSTALL_PARSE_FAILED_NOT_APK`): the debug `libmain.so` is 325 MB.
|
||||
Use `release` on the emulator for this app, notwithstanding the general
|
||||
rule that the emulator stays on debug -- there is nothing to measure
|
||||
here, and the debug build cannot be installed at all.
|
||||
|
||||
Also: the bench APK's package is `dev.iris.android.demo.bench`, not
|
||||
`dev.iris.android.demo`. An older non-bench build left installed answers
|
||||
to the second name, runs, looks right, and reports whatever *it* was built
|
||||
with -- which is how "log upload: this build has no server configured"
|
||||
came from a build that had one. The devlog authority carries the
|
||||
`applicationId` for exactly that reason: the two packages each get their
|
||||
own and neither can read the other's log.
|
||||
|
||||
### `iris::input`/`iris::frame` diagnostics, 2026-09-07
|
||||
|
||||
Iris asked for a second button (or the same "Copy report" made to say
|
||||
more): "add another button to copy input event info so that I can do some
|
||||
stuff manually and then send the event log to you so you know what events
|
||||
the code is actually receiving. You may also want to instrument a lot of
|
||||
the code with timings so I can give you time reports too through the same
|
||||
button." This is that, built on the log ring rather than a second
|
||||
mechanism.
|
||||
|
||||
**What it writes.** `iris::sense::log_input_event` -- one line per
|
||||
platform pointer sample, from Android's `on_touch_event` (once per real
|
||||
`MotionEvent`, historical samples inline), the winit backend (once per
|
||||
pointer `WindowEvent`, no batching), and `harness::Harness::touch` (once
|
||||
per `TouchScript` line, also unbatched, which is what makes a harness
|
||||
replay round-trip exactly):
|
||||
|
||||
iris input: action=down x=540.0 y=1000.0 t=0ms history=0
|
||||
iris input: action=move x=540.0 y=1196.0 t=16ms history=3 4:540.0,1040.0 8:540.0,1086.0 12:540.0,1138.0
|
||||
|
||||
`iris::diagnostics::log_frame` -- one line per frame, called from each
|
||||
backend's own frame function after the draw (or, on the harness, where a
|
||||
draw would be):
|
||||
|
||||
iris frame: n=42 now=701ms since_input=12ms layout=8.3µs draw=1.1ms redraw=Updates primitives=384 animating=true
|
||||
|
||||
`n` is `UiRenderState::frame_number` (counts every call to `update`,
|
||||
including one that redrew nothing); `now` is milliseconds since that
|
||||
render state's own construction (`UiRenderState::epoch`, set the same way
|
||||
`Harness::base` is, so it lines up with a harness's own `t_ms`);
|
||||
`since_input` is how stale the input driving this frame was, from the last
|
||||
sample `SensorUi::run_sensors` saw; `layout`/`draw` are `Instant` pairs
|
||||
around `UiRenderState::update` and the platform's own submit+present;
|
||||
`redraw` is `RedrawKind::{None,All,Updates}`; `primitives` is
|
||||
`UiRenderState::active_primitive_count` (everything currently on screen,
|
||||
not a per-frame delta -- `take_counters`' draw/rewrite/shape counts are
|
||||
that, and `iris::frame` does not duplicate them).
|
||||
|
||||
**Reading a report**: with tracing off (the default) neither target
|
||||
appears at all. With it on, the two interleave in arrival order, so a
|
||||
flick's shape reads as a run of `iris input:` lines followed by the frames
|
||||
they drove, and a release still shows the existing `iris drag release:`/
|
||||
`iris drag release samples:` lines from `sense.rs` -- those were not
|
||||
duplicated, only (the `samples:` one) brought under the same gate.
|
||||
|
||||
**Replaying one**: `iris/benches/report_to_touch.py < report.txt >
|
||||
replay.touch` reads every `iris input: action=...` line (ignoring
|
||||
everything else in the report, prefix-agnostic -- it works on a bare
|
||||
message or a full ring line with its `HH:MM:SS.mmm LEVEL target:` header)
|
||||
and expands each event's inline historical samples into their own `move`
|
||||
lines first, oldest first, exactly as Android delivers and replays them.
|
||||
The output is an ordinary `.touch` file:
|
||||
`Harness::replay(&TouchScript::parse(&text)?)` plays it back at layer 1,
|
||||
or point `run-headless.sh phone --phone --replay` at it for layer 2.
|
||||
Verified round-trip, both directions: a harness replay of
|
||||
`flick-120hz.touch` with tracing on produces exactly six `iris::input`
|
||||
lines, and piping them through the script and re-parsing reproduces the
|
||||
same six `t_ms action x y` samples (`iris/transcript-fixture/tests/
|
||||
input_log_roundtrip.rs`).
|
||||
|
||||
**The toggle**: `iris::diagnostics::set_trace(bool)`, off by default.
|
||||
**Not `log::log_enabled!`/`log::set_max_level`**: the app already installs
|
||||
its logger at `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s
|
||||
`JNI_OnLoad`), and `client_core::log_ring::RingLogger::enabled` is
|
||||
unconditionally `true` by design ("the ring wants everything"), so a
|
||||
`log::Level::Debug` line reaches the ring regardless of what this
|
||||
instrument would prefer -- the gate has to be a crate-level flag, checked
|
||||
before `log::debug!` is even reached, and that is what `trace_enabled()`
|
||||
is. **The switch is the bench header's fourth control**, beside Run
|
||||
benchmark / Copy report / Diagnostics: it reads `Trace off` or `Trace on`,
|
||||
because a toggle whose own appearance never changes is a button that looks
|
||||
like it did nothing. Its accessibility label stays the fixed `Trace input
|
||||
and frames` -- that is what `run-bench.sh` and `ui-trace --do "tap '...'"`
|
||||
find it by, and a control that renames itself when pressed is one no
|
||||
script can find twice. Pressing it rebuilds the header (the same
|
||||
`bench_controls` path `on_insets_changed` already uses) and shows the
|
||||
diagnostics pane, so the state is on screen at the moment of the press.
|
||||
|
||||
**Why default off, and why the ring's size is the actual constraint**: the
|
||||
ring is 2000 lines / 256 KiB
|
||||
(`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`); a 120Hz
|
||||
session logging a line per touch sample and a line per frame fills that in
|
||||
seconds, so a caller turns tracing on only for the length of whatever is
|
||||
being investigated, not for a whole session. This is also why the report
|
||||
should say at its top whether tracing was on -- a caller reading
|
||||
`iris::diagnostics::trace_enabled()` when building the report can print
|
||||
that. Both reports do: `bench_client::trace_line` is the one wording, and
|
||||
it takes the flag **read at the start of the run as well as at the end**,
|
||||
so a switch flipped half way through is reported as exactly that rather
|
||||
than as a confident "on" about a log covering half the run. Three states,
|
||||
because that third one happens -- the switch is on screen while a
|
||||
benchmark runs.
|
||||
|
||||
**D1 from `docs/REVIEW-2026-09-07.md`**: the review found that this gate
|
||||
existed (as `iris/src/diagnostics.rs`, uncommitted at the time) but four
|
||||
older per-frame `debug!` lines were not wired to it --
|
||||
`android/view.rs`'s two `render():` lines, `widget/list.rs`'s `iris fling
|
||||
tick:`, and `widget/text/mod.rs`'s `iris text render:` -- each
|
||||
unconditional at `Debug`, and between them enough to fill the ring in
|
||||
under ten seconds at 120Hz before `Copy report` ever saw anything else.
|
||||
All four (and `sense.rs`'s `iris drag release samples:`, which is
|
||||
lower-volume but the same shape) are now behind
|
||||
`iris::diagnostics::trace_enabled()`, moved onto the `iris::frame`/
|
||||
`iris::input` targets where each belongs. `iris::sense`'s `iris drag
|
||||
release:` (info level, one per gesture, low volume, and the line that
|
||||
already answered "why didn't that flick fling" from Iris's phone) is
|
||||
unchanged and ungated on purpose -- it is exactly the kind of always-
|
||||
useful summary line the ring is *for*.
|
||||
|
||||
**Verification**: `iris/transcript-fixture/tests/
|
||||
input_log_roundtrip.rs`'s one test replays `flick-120hz.touch` through a
|
||||
real capturing `log::Log` twice -- tracing off, then on -- and asserts (a)
|
||||
off leaves zero `Debug`-level lines from the whole replay (`cargo test -p
|
||||
transcript-fixture` catches a regression here immediately, not just this
|
||||
one), (b) on produces exactly one `iris::input` line per replayed sample
|
||||
and at least one `iris::frame` line with a non-zero `layout=`, and (c) the
|
||||
round trip through `report_to_touch.py` reproduces the exact script. A
|
||||
throwaway (not committed) 3000-frame timing loop through the harness with
|
||||
tracing off vs on, idle after the opening layout, measured **2.97µs/frame
|
||||
off against 3.40µs/frame on with no logger even installed** -- the
|
||||
`Instant::now()` pairs and the `trace_enabled()` atomic loads that stay
|
||||
live either way. That is a layer-1 proxy, not the phone's own bench (no
|
||||
GPU work happens there at all), but it bounds the added cost at a few
|
||||
hundred nanoseconds against a 60Hz budget of 16,600 -- three orders of
|
||||
magnitude below where it could be seen.
|
||||
|
||||
### APK size (2026-09-07)
|
||||
|
||||
Iris's question: the iris bench APK is about double the Compose bench APK
|
||||
(20.6 MB vs 10.1 MB, both release). Measured before this pass: 18,088 KiB of
|
||||
`lib/arm64-v8a/libmain.so`, stored uncompressed (`extractNativeLibs=false`),
|
||||
plus 2 MB of dex; the Compose APK's dex is 25 MB raw, compressed to 9 MB in
|
||||
the APK. `iris/android-app/Cargo.toml`'s `[profile.release]` set only
|
||||
`panic = "abort"` -- no `lto`, no `codegen-units`, no `strip`, default
|
||||
`opt-level`. All numbers below are `arm64-v8a` release, built with
|
||||
`./build-apk.sh release` (this checkout, `nice -n 10`, no `CARGO_TARGET_DIR`
|
||||
override, reusing the incremental `target/`), and are the raw file sizes
|
||||
(`ls -la`), not what `du` would round to.
|
||||
|
||||
| profile.release | APK bytes | `libmain.so` bytes | delta vs previous |
|
||||
|---|---|---|---|
|
||||
| `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- |
|
||||
| + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 |
|
||||
| + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 |
|
||||
| + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 |
|
||||
| + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 |
|
||||
| + `opt-level = "z"` (not adopted, see below) | 12,507,276 | 10,374,808 | -818,800 |
|
||||
| + platform fonts, no bundled Noto (2026-09-07) | 9,577,940 | 7,445,472 | -3,748,136 |
|
||||
|
||||
Adopted: `strip = true`, `lto = "fat"`, `codegen-units = 1`, `opt-level = "s"`.
|
||||
Baseline to final: `libmain.so` **18,546,488 -> 11,193,608 bytes (-39.7%)**,
|
||||
APK **20,678,956 -> 13,326,076 bytes (-35.5%)**.
|
||||
|
||||
**`opt-level = "z"` was measured but not adopted.** It is smaller still --
|
||||
another 818,800 bytes off `libmain.so` (7.9 MiB total vs `s`'s 8.7 MiB) --
|
||||
but `z` trims more aggressively than `s` in ways that can cost frame time
|
||||
(fewer inlines, more size-motivated codegen choices, per rustc's own docs),
|
||||
and this pass did not have an iris-side frame-time benchmark run against
|
||||
it (the app's own `run-bench.sh`/render report was not exercised here,
|
||||
per this task's scope, and this checkout has no emulator currently up).
|
||||
Trading an unmeasured runtime cost for ~700 KB more off the download is not
|
||||
a call to make blind, so `s` is what shipped, and `z` is left as something
|
||||
to try only alongside a `transcript-bench.sh`/`stream-bench.sh`-equivalent
|
||||
run for iris to confirm it does not regress.
|
||||
|
||||
Not stripped (baseline) had a live `.symtab` (`llvm-readelf -S`): section
|
||||
25, `SYMTAB`, 0x17ed40 bytes (~1.49 MiB) covering 65,223 raw symbols. `strip
|
||||
= true` removes it at build time -- notably, `stripReleaseDebugSymbols`
|
||||
(AGP's own strip task) had already logged "Unable to strip the following
|
||||
libraries, packaging them as they are: libmain.so" on the baseline, so
|
||||
Cargo's own strip is also the fix for that.
|
||||
|
||||
Baseline section sizes (`llvm-readelf -S`, before any profile change):
|
||||
|
||||
| section | bytes |
|
||||
|---|---|
|
||||
| `.text` | 6,463,032 |
|
||||
| `.rodata` | 6,548,192 |
|
||||
| `.eh_frame` | 721,872 |
|
||||
| `.data.rel.ro` | 441,328 |
|
||||
| `.gcc_except_table` | 13,652 |
|
||||
| `.symtab` | 1,563,456 |
|
||||
|
||||
No `bloaty` on this machine (`which bloaty` empty); used
|
||||
`llvm-nm -S --size-sort -C` on the baseline (unstripped) `.so`, summed by
|
||||
the symbol's leading crate/module name. 7.02 MB of the 18.5 MB `.so` carries
|
||||
a name at all (the rest is `.rodata` blobs -- embedded data, padding,
|
||||
relocations -- that never get a symbol). Top 8 by that accounting:
|
||||
|
||||
| crate | bytes (named symbols only) |
|
||||
|---|---|
|
||||
| `naga` | 1,100,099 |
|
||||
| `core` (std) | 698,862 |
|
||||
| `wgpu_core` | 599,720 |
|
||||
| `harfrust` | 372,694 |
|
||||
| `alloc` | 347,324 |
|
||||
| `read_fonts` | 326,824 |
|
||||
| `wgpu_hal` | 299,648 |
|
||||
| `skrifa` | 291,332 |
|
||||
|
||||
Also notable further down: `hashbrown` 244,712, `jni` 199,660, `std`
|
||||
199,126, `serde` 168,256, `zeno` 157,320, `pulldown_cmark` 116,568,
|
||||
`iris_core` 95,112, `parley` 78,816, `iris` 76,616, `swash` 72,940,
|
||||
`serde_json` 72,312, `fontique` 45,464.
|
||||
|
||||
**The other ~11.5 MB of `.rodata`/unnamed data is mostly the embedded
|
||||
fonts**: `iris/core/src/primitive/text.rs` `include_bytes!`s six Noto Sans
|
||||
TTFs (`iris/core/assets/fonts/`) -- Regular/Bold/Italic/BoldItalic for Noto
|
||||
Sans plus Regular/Bold for Noto Sans Mono -- totalling **3.6 MB** of raw
|
||||
font data (`du -ch iris/core/assets/fonts/*.ttf`). That is real render
|
||||
data, not something to strip: unlike the Compose app's Nerd Fonts icon
|
||||
subset (`app/build-icon-font.sh`, which subsets because the app only ever
|
||||
draws ~100 fixed glyphs), iris's Noto Sans embedding backs arbitrary text
|
||||
in a chat transcript, so a subset would have to be a Unicode-coverage
|
||||
subset (Latin/Latin-Extended/common punctuation, dropping CJK/Cyrillic/etc)
|
||||
rather than a fixed-codepoint one -- a real behaviour change (text in a
|
||||
language outside the subset would fall back to tofu or a missing glyph) and
|
||||
out of scope for a size-only pass. Left as a follow-up, flagged for Iris:
|
||||
subsetting would plausibly save 1-2 MB but changes what scripts render
|
||||
correctly, which is a product decision. **Superseded 2026-09-07**: Iris
|
||||
decided to remove the embedding outright rather than subset it -- see
|
||||
"Platform fonts (2026-09-07)" below.
|
||||
|
||||
### Platform fonts (2026-09-07)
|
||||
|
||||
Iris's verdict on the open question above: **"remove the font for now;
|
||||
just match what compose does."** The Compose app takes its body text from
|
||||
`FontFamily.Default` (platform Roboto on Android) and its code/tool-output
|
||||
text from `FontFamily.Monospace`, and ships no text font of its own --
|
||||
only its committed Nerd Fonts icon subset (`app/build-icon-font.sh`) for
|
||||
~100 fixed glyphs, a different case (a small, known, closed set of
|
||||
codepoints, unlike arbitrary transcript text). iris had no equivalent icon
|
||||
font to keep; it draws no icons through a font today, so there was nothing
|
||||
parallel to preserve.
|
||||
|
||||
**What changed**: `TextData::register_bundled_fonts` and the six
|
||||
`include_bytes!` Noto Sans/Noto Sans Mono constants are gone from
|
||||
`iris/core/src/primitive/text.rs`, along with the `.ttf`s themselves and
|
||||
their `OFL.txt` (`iris/core/assets/fonts/`, now removed -- nothing else in
|
||||
the tree referenced the licence file). `TextData::default` now does
|
||||
nothing but `FontContext::new()`, which was already discovering the
|
||||
platform's fonts underneath the bundled ones -- `fontique` 0.11.1's
|
||||
`CollectionOptions::system_fonts` defaults to `true`, and both platforms
|
||||
this crate ships on have a real backend behind it: `backend/fontconfig.rs`
|
||||
on Linux (this VM's desktop has a full Noto install, confirmed with
|
||||
`fc-match sans-serif`/`fc-match monospace`), `backend/android.rs` on
|
||||
Android (parses `/system/fonts` and `/system/etc/fonts.xml`, mapping
|
||||
`SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]` and
|
||||
`Monospace` to `["monospace"]` -- see the fallback finding below for why
|
||||
that last one does not actually resolve on this fontique version). So
|
||||
removing the bundled registration did not need a replacement call; the
|
||||
platform path was already live, just shadowed.
|
||||
|
||||
**Fallback, and the unknown-glyph state (UI_RULES: design it, don't let
|
||||
it default to blank)**. `parley`'s shaper sets both an explicit family
|
||||
list *and* a script/locale-keyed fallback chain per run
|
||||
(`parley-0.11.1/src/shape/mod.rs`'s `query.set_families`/
|
||||
`query.set_fallbacks`), so a codepoint the resolved family lacks still
|
||||
gets a query against fontique's fallback map before giving up. Checked
|
||||
with a throwaway example (`iris/examples/font_check.rs`, deleted after
|
||||
use -- not part of the crate) shaping `"🎉🔥▸▾▲你好"` headless on the
|
||||
desktop at `run-headless.sh`: the emoji and CJK characters drew as
|
||||
visible **tofu boxes** (the platform's own missing-glyph box, not blank
|
||||
space), and the chevron marks (U+25B8/25BE/25B4, the ones
|
||||
`transcript-ui/src/tool.rs`'s `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` draw)
|
||||
shaped as real triangles. So the failure mode this crate now depends on
|
||||
is "the platform's own tofu," which is the correct unknown-glyph state
|
||||
per UI_RULES, not "nothing drawn." `tool.rs`'s doc comment on those marks
|
||||
is updated to say this is now a bet on the platform's coverage rather
|
||||
than a checked fact about a bundled `cmap`.
|
||||
|
||||
**One real gap, found on this checkout's emulator, not the desktop, closed
|
||||
2026-09-07**: `fonts: 208 families found, default=Some("Roboto Flex")
|
||||
mono=None` in the startup log (`FontDiagnostics`, read via `adb logcat`
|
||||
after installing the `force-gles` debug build -- the emulator's default
|
||||
Vulkan backend has no adapter here, a pre-existing, documented condition
|
||||
unrelated to this change, and aborts with `Could not get adapter!` without
|
||||
that feature). `mono=None` means fontique's Android backend never resolves
|
||||
the `Monospace` generic family at all on this system image, and it is two
|
||||
bugs stacked rather than one: reading `fontique-0.11.1/src/backend/android.rs`,
|
||||
`DEFAULT_GENERIC_FAMILIES`'s `["monospace"]` is looked up against
|
||||
`name_map` *before* the `fonts.xml` parse that adds the name runs, and even
|
||||
after parsing, this AVD's `/system/etc/fonts.xml` (and AOSP's/GrapheneOS's,
|
||||
same file format) names it with a `<family name="monospace"><font
|
||||
...>DroidSansMono.ttf</font></family>` element rather than an `<alias>` --
|
||||
whose `<font>` children that same parser's `"family"` match arm never reads
|
||||
(a `TODO` left in place), so the name gets registered with no font data
|
||||
behind it. `family_by_name("monospace")` therefore also comes up empty, on
|
||||
every Android device this fontique version runs on, not just this AVD.
|
||||
Checked against `linebender/parley`'s `main` branch on GitHub the same day:
|
||||
neither bug is fixed there either, so there is no newer release to bump to.
|
||||
The visible effect was not blank text -- `Family::Monospace`'s
|
||||
explicit-family list came up empty, but the script-based fallback chain
|
||||
(independent of the generic-family list) still resolved a real font, the
|
||||
same one `SansSerif` gets -- so code blocks and the tool-card chevrons
|
||||
rendered, just without a genuinely monospaced face, while Compose's
|
||||
`FontFamily.Monospace` (resolved through Android's own `Typeface.MONOSPACE`
|
||||
constant, a path fontique does not use) was unaffected.
|
||||
|
||||
**Fixed** in `iris/core/src/primitive/text.rs`'s `patch_android_monospace`
|
||||
(`#[cfg(target_os = "android")]`, called from `TextData::default` right
|
||||
after `FontContext::new()`): rather than pinning an OEM-specific name like
|
||||
`"Droid Sans Mono"` (the fragility this gap was originally left open over),
|
||||
it reads `/system/etc/fonts.xml` itself -- a plain substring search, not a
|
||||
new XML-parser dependency, for the one well-known AOSP file fontique
|
||||
already parses with a real one -- for the filename the `"monospace"`
|
||||
family declares, then searches fontique's own *actually* scanned families
|
||||
(the ones with real font data, from `/system/fonts`) for whichever one
|
||||
owns a font file with that name, and registers that family as the
|
||||
`Monospace` generic itself. This is the same authority Compose's
|
||||
`Typeface.MONOSPACE` resolves through, and it degrades safely to a no-op
|
||||
if `fonts.xml` is missing (a headless test) or nothing matches (a device
|
||||
naming it some other way) -- the pre-existing sans fallback, not a panic.
|
||||
Verified on this checkout's emulator: `mono=Some("Droid Sans Mono")` in the
|
||||
startup log, `resolved ... mono=Some("Droid Sans Mono")`, and a screenshot
|
||||
of the bench-fixture transcript showing the code block and tool-card value
|
||||
text in a visibly monospaced face next to sans body/heading text. The
|
||||
desktop's `fontconfig` backend was never affected (confirmed unchanged:
|
||||
`./run-headless.sh phone --phone --shot` still shows monospaced code next
|
||||
to sans body text) -- the patch is Android-only and a no-op everywhere
|
||||
else.
|
||||
|
||||
**Verified**: `cargo test -p transcript-fixture` (6 tests, all headless
|
||||
layers) and `cargo clippy -p iris-core --all-targets` both clean;
|
||||
`./run-headless.sh phone --phone --shot` (the bench-fixture transcript,
|
||||
bold/italic/monospace code fences all shaping correctly) and
|
||||
`./run-headless.sh tabs` (the desktop example with several distinct text
|
||||
styles, per this task's "negative case" check) both look right by eye;
|
||||
the emulator's own bench run (`run-bench.sh`, `force-gles` debug build)
|
||||
completed a full fling/stream/type/keyboard cycle with text visibly
|
||||
drawn throughout and no crash. `.so` size: see the APK size table's final
|
||||
row, **11,193,608 -> 7,445,472 bytes**, a 3,748,136-byte drop matching
|
||||
the 3.6 MB estimate almost exactly.
|
||||
|
||||
**naga/wgpu backend features: investigated, not trimmed, because the
|
||||
trim would not change the binary.** `iris/core/Cargo.toml` and
|
||||
`iris/Cargo.toml` depend on `wgpu = "28.0.0"` with default features, which
|
||||
via `wgpu`'s own defaults (`dx12`, `metal`, `gles`, `vulkan`, `wgsl`,
|
||||
`webgpu`) forward `naga/hlsl-out`, `naga/msl-out`, `naga/glsl-out`,
|
||||
`naga/spv-out`, `naga/wgsl-in`, `naga/wgsl-out` -- Cargo feature
|
||||
unification is not per-target, so all of those are nominally "on" for the
|
||||
Android build too, not just the ones Android actually uses (`glsl-out` for
|
||||
GLES, `spv-out` for Vulkan). But `wgpu-hal`'s own `build.rs`
|
||||
(`cfg_aliases!`) gates the *modules* themselves on the real target:
|
||||
`dx12: target_os = "windows" AND feature = "dx12"`, `metal: target_vendor =
|
||||
"apple" AND feature = "metal"` (`wgpu-hal-28.0.0/build.rs`,
|
||||
`wgpu-hal-28.0.0/src/lib.rs`'s `#[cfg(dx12)] pub mod dx12;` etc). So on
|
||||
`aarch64-linux-android` the dx12/metal modules never compile, nothing calls
|
||||
into `naga::back::hlsl` or `naga::back::msl`, and the linker's normal
|
||||
dead-code elimination already drops them: `grep -c
|
||||
"naga::back::hlsl\|naga::back::msl\|naga::front::spv\|naga::front::glsl"
|
||||
/tmp/nm_size.txt` on the **baseline** (no LTO yet) `.so` returns **0** --
|
||||
none of that code reached the linked binary in the first place. `regex`
|
||||
(pulled in transitively by `env_filter`, which `android_logger` uses for
|
||||
`RUST_LOG`-style filtering) is in the same position: present in
|
||||
`Cargo.lock` but only a handful of small generic-drop symbols in `nm`, not
|
||||
a real contributor. Neither is worth a Cargo-level feature trim (which
|
||||
would also need a per-target dependency table to avoid stripping dx12/metal
|
||||
off the desktop build, adding real complexity for a change that measures
|
||||
as zero). No emulator use was needed for this finding since no feature
|
||||
flag changed; the earlier per-step size measurements (strip/LTO/cgu/opt-level)
|
||||
were likewise not re-verified on the emulator, since this task's brief
|
||||
scoped emulator use to the naga-trim step specifically, and that step's
|
||||
answer was "don't."
|
||||
|
||||
**`tabs-ui`/`tabs-screen`: also investigated, also already dead.**
|
||||
`build-apk.sh`'s default features are `"transcript-screen bench"`, passed
|
||||
without `--no-default-features`, so the crate's own `default =
|
||||
["tabs-screen"]` (`iris/android-app/Cargo.toml`) is *also* on for every
|
||||
build this script produces, including the bench APK. `src/lib.rs`'s doc
|
||||
comment already says the three screens are mutually exclusive at runtime
|
||||
(`ActiveClient` gives `bench` priority over `transcript-screen`, which
|
||||
takes priority over the default `tabs-screen`), and checking the actual
|
||||
`#[cfg(...)]` gates confirms it is mutually exclusive at *compile* time
|
||||
too: the `Client` struct and its one call to `tabs_ui::build` are behind
|
||||
`#[cfg(not(feature = "transcript-screen"))]`, which is false whenever
|
||||
`transcript-screen` is on, so that code does not even get generated, let
|
||||
alone linked. `llvm-nm -C` on both the baseline and the final `.so` confirm
|
||||
it: `grep -ci "tabs_ui\|sungals"` is **0** in both. So there is nothing to
|
||||
trim here either -- `tabs-ui` and its `sungals.png` (8.9 KB) never reach
|
||||
the linked binary in a `transcript-screen`/`bench` build, regardless of the
|
||||
feature being nominally "on" in `Cargo.toml`.
|
||||
|
||||
**Comparison Iris asked for, honestly**: most of the remaining ~13.3 MB vs
|
||||
Compose's 10.1 MB is not a build-settings gap, it is what each app links.
|
||||
Compose's APK carries ~9 MB of *compressed* dex and links Android's own
|
||||
platform renderer, text shaper (HarfBuzz/Minikin) and font files from the
|
||||
system image at zero cost to the APK -- none of that is bytes Compose ships.
|
||||
Iris ships its own copy of all of that: `wgpu`+`naga`+`wgpu_hal` (a
|
||||
software/hardware-portable GPU backend and shader cross-compiler, roughly
|
||||
2 MB of named symbols alone), `harfrust`+`read_fonts`+`skrifa`+`swash`+
|
||||
`parley`+`fontique` (a full third-party font-loading/shaping/rasterizing
|
||||
pipeline, another ~1.2 MB of named symbols), and 3.6 MB of embedded font
|
||||
data because it cannot borrow the platform's fonts the way Compose does.
|
||||
Build settings (this pass) closed real ground -- 39.7% off `libmain.so` --
|
||||
but did not remove any of those linked systems, because removing them would
|
||||
mean iris stops being a self-contained native renderer, which is the whole
|
||||
point of the port (`AGENTS.md`'s "no Dioxus and nothing that draws through
|
||||
a WebView", `no-dioxus-or-webview-ui-true-native-only` memory). Install size
|
||||
(what `dumpsys package`/`du` on the installed `lib/arm64-v8a/` directory
|
||||
would show) was not separately measured this pass: with
|
||||
`extractNativeLibs=false` the `.so` is mapped directly out of the APK
|
||||
rather than copied onto disk a second time, so install size tracks the APK
|
||||
size closely for the native library and is not a second, larger number the
|
||||
way it would be under the old `extractNativeLibs=true` default -- checking
|
||||
this precisely needs the emulator, which this task scoped to the naga-trim
|
||||
verification only.
|
||||
|
||||
Committed: `iris/android-app/Cargo.toml`'s `[profile.release]` now reads
|
||||
`panic = "abort"`, `strip = true`, `lto = "fat"`, `codegen-units = 1`,
|
||||
`opt-level = "s"`, with a comment naming the measured savings.
|
||||
|
||||
### Queue, 2026-09-07 (orchestrator)
|
||||
|
||||
In order; two builders at a time. Each is ticked here by the agent that
|
||||
@@ -50,19 +610,211 @@ closes it.
|
||||
|
||||
- [x] Test rig, layers 1 and 2 ("Three test layers" below), landed 2026-09-07.
|
||||
- [ ] Fling parity with Compose, and the phone's keyboard push-up, with
|
||||
insets shown in the diagnostics overlay. Running, in a worktree.
|
||||
- [ ] Rows at the transcript's top edge: culled too early in one state,
|
||||
insets shown in the diagnostics overlay. The worktree note here was
|
||||
stale by 2026-09-07 night: no worktree exists and the keyboard half
|
||||
is ticked in IRIS_TODO's night entry. What remains is the impulse
|
||||
estimator item below.
|
||||
- **Orchestrator note, 2026-09-07 late**: the tree was found holding a
|
||||
non-compiling diff from two killed agents (catch-a-fling in
|
||||
`sense.rs`/`selection.rs`; shaped masks in the render files). One
|
||||
opus agent owns splitting and landing both (catch-a-fling first);
|
||||
one sonnet agent owns report hygiene and the bench header in
|
||||
`bench_client.rs` and client-core's log ring. If both boxes below
|
||||
are still open and nothing is running, that work was cut off again.
|
||||
- [x] Rows at the transcript's top edge: culled too early in one state,
|
||||
drawn through the header in the other (docs/IRIS_TODO.md, 2026-09-07).
|
||||
First after the rig lands, using its layer-1 harness.
|
||||
- [ ] Phone logging through Dev Updater (Iris has no logcat; see
|
||||
docs/TODO.md and the memory note): research how Dev Updater shows an
|
||||
app's runtime log, design the smallest route (the app keeps its own
|
||||
recent log; a debug button copies it; Dev Updater reads it), write
|
||||
the decision in docs/DECISIONS.md, build it.
|
||||
Done 2026-09-07, e922b73 + d507ae4; the root causes and the test names
|
||||
are in that IRIS_TODO entry, and the short version is below.
|
||||
- [x] Phone logging through Dev Updater -- **done 2026-09-07**, see
|
||||
"Phone logging" above and docs/DECISIONS.md's entry of that date.
|
||||
- [x] APK size: release profile tuned (`42af780`), -35% APK, -40% .so;
|
||||
see "APK size (2026-09-07)". **Iris's verdict, 2026-09-07: "remove the
|
||||
font for now; just match what compose does."** Done same day -- the six
|
||||
bundled Noto Sans TTFs are gone, text now loads from
|
||||
`fontique`'s platform collection (`FontContext::new()`'s default
|
||||
`CollectionOptions::system_fonts`), and `.so` dropped by 3,748,136 bytes
|
||||
(11,193,608 -> 7,445,472), matching the 3.6 MB estimate almost exactly.
|
||||
See "APK size" table's final row and "Platform fonts (2026-09-07)"
|
||||
below for the fallback behaviour and one real gap it surfaced: this
|
||||
fontique version's Android backend never resolves the `Monospace`
|
||||
generic family at all (`mono=None` in the startup diagnostic, measured
|
||||
on this checkout's emulator) -- code/tool-card text still rendered (the
|
||||
script fallback chain still landed on a real face, never blank), just
|
||||
not in a genuinely monospaced one. Compose did not have this gap; it
|
||||
resolves `FontFamily.Monospace` through Android's own Typeface
|
||||
constant rather than through fontique. **Closed same day** -- see
|
||||
"Platform fonts (2026-09-07)"'s "Fixed" paragraph:
|
||||
`TextData::patch_android_monospace` resolves the platform's own
|
||||
`fonts.xml` monospace declaration against fontique's actually-scanned
|
||||
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
|
||||
checkout's emulator.
|
||||
- [ ] Scroll clamped at both ends, and Compose's impulse velocity
|
||||
estimator with min/max fling velocity (docs/IRIS_TODO.md, 2026-09-07
|
||||
later). After the culling fix lands (same file).
|
||||
- [x] **APK runtime logs in Dev Updater (Iris, 2026-09-07: "please add
|
||||
android / apk runtime log support to dev updater").** **Done
|
||||
2026-09-07** -- dev-updater `013d711`, and this repo's provider half;
|
||||
"Phone logging" above is the account, docs/DECISIONS.md the decision.
|
||||
What was designed and what was built agree except in one place: the
|
||||
provider does **not** call `notifyChange` (the reason is in both), and
|
||||
the tab is drawn for every component rather than only where a provider
|
||||
resolves, since its absence would be the one thing that could not say
|
||||
which of the several reasons there was nothing to read. The design as
|
||||
written: Supersedes the
|
||||
ai-server `POST /client-log` route, which becomes the second mechanism
|
||||
and is deleted once this works (`log_upload.rs`, `app_log.rs`'s
|
||||
upload half, the route). Design: Android forbids reading another
|
||||
app's logcat, so the app carries its own log (`client_core::log_ring`,
|
||||
kept) and **exposes it on-device through a ContentProvider** that Dev
|
||||
Updater's phone app reads -- no tunnel, no token, no second
|
||||
enrolment, because the two apps are on the same phone. Authority
|
||||
`<applicationId>.devlog`, one table `lines(seq, t_ms, level, target,
|
||||
message)` plus a `dropped` count, queried with `since=<seq>` so a poll
|
||||
is incremental. Dev Updater's phone app: for an APK component whose
|
||||
installed package resolves that authority (`PackageManager`), the
|
||||
component gets a **Runtime** tab like a service's; it polls the
|
||||
provider while the tab is open and forwards new lines to its host
|
||||
server's existing per-component runtime-log store, so history
|
||||
survives the phone and the same tab code renders it. Read access
|
||||
guarded by a permission Dev Updater defines
|
||||
(`dev.updater.permission.READ_DEVLOG`, protection `normal`; signature
|
||||
level is not available because the two apps are signed with different
|
||||
locally generated keys -- state that trade-off in DECISIONS.md). The
|
||||
provider is Java in `android-app` reading the ring over JNI (platform
|
||||
glue, allowed by the sharing rule); the Compose app can implement the
|
||||
same contract later so both apps get the tab. Rejected: Dev Updater
|
||||
handing its server token to the app it installed (leaks the token
|
||||
into every managed app); the app posting to ai-server (needs its own
|
||||
enrolment first and puts the phone's logs in the wrong component).
|
||||
- [x] Iris app enrolment (**done 2026-09-07**): the iris Android app is
|
||||
told which `ai-server` to talk to by an `aiapp://enroll` link, exactly
|
||||
as the Compose app and `desktop-app` are, instead of having it compiled
|
||||
in. `MainActivity` registers the VIEW intent and hands the URI and the
|
||||
app's private files directory to Rust (`src/enrollment.rs` is the intent
|
||||
plumbing and nothing else); the parsing, the file and its 0600 mode are
|
||||
`client_core::config`'s `EnrolledServer`/`EnrollmentStore`, shared with
|
||||
the desktop app. `build.rs`'s `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`
|
||||
are gone, and with them a token in a built artifact.
|
||||
**The CA travels with the link** (`&ca=`, base64url of the DER) --
|
||||
docs/DECISIONS.md, 2026-09-07, has the decision, the two rejected
|
||||
alternatives and what the longer link costs a QR code. That is what
|
||||
makes an APK cross-compiled here work against the server on the host.
|
||||
Diagnostics says which of three things is true -- `enrolled: host:port`,
|
||||
`not enrolled -- open the enrol link from Dev Updater`, or
|
||||
`enrolment unreadable: ...` -- because "could not find out" wants a
|
||||
different action from "nothing there yet".
|
||||
Dev Updater needed no change: its Enroll button already opens the minted
|
||||
link with `ACTION_VIEW`, and Android offers the chooser between this app
|
||||
and the Compose one.
|
||||
**The log upload was deliberately left out of it**: Dev Updater grew an
|
||||
on-device runtime-log reader instead (Iris, 2026-09-07), so
|
||||
`log_upload`, `POST /client-log` and the `AI_APP_LOG_*` baking went out
|
||||
whole rather than being rewired first -- done the same day, "Phone
|
||||
logging" above. `build.rs` had nothing left to do and is gone with them.
|
||||
- [ ] `iris/android-app/build-apk.sh`: clear Gradle's merged-native-libs
|
||||
cache when the ABI changes (the x86_64 trap), and make the debug bench
|
||||
APK installable (648 MB) -- RUST.md's logging section names both.
|
||||
- [ ] Input-event and timing instrumentation into the log ring, copied
|
||||
by the report button. After the logging route lands (same ring).
|
||||
- [ ] Catch-a-fling: down during a fling stops it at the down and drags
|
||||
with no slop (docs/IRIS_TODO.md, night). Opus, next slot; uses the
|
||||
layer-1 harness.
|
||||
- [x] **Report hygiene (done 2026-09-07).** Ring takes Debug only from
|
||||
`iris`/`client_core` targets, Copy report always copies and trims the
|
||||
log. `client_core::log_ring::ring_accepts` is the one filter (Info+
|
||||
from anywhere; Debug/Trace only from this app's own targets, and only
|
||||
while `iris::diagnostics::trace_enabled()` says tracing is on) --
|
||||
`RingLogger` takes that as a plain `fn() -> bool` rather than depending
|
||||
on `iris` directly, since `client-core` sits below it; `app_log.rs`
|
||||
wires `iris::diagnostics::trace_enabled` through at install. Fixed the
|
||||
1339-held/4050-dropped flood from `naga::front`/`wgpu_core`/`jni`
|
||||
logging at Debug unconditionally. `bench_client.rs`'s `copy_report` no
|
||||
longer declines when nothing has run: with no benchmark yet it copies
|
||||
the diagnostics pane's own text instead, with a first line saying so,
|
||||
and always appends `LogRing::tail_text(COPY_REPORT_TAIL_LINES = 150)`
|
||||
(a first line naming how many older lines were left out) rather than
|
||||
the whole ring. `client-core`'s tests cover the filter and the trim;
|
||||
the copy path was checked on the emulator (immediate `Copy report` tap
|
||||
with no prior button press now logs "copied to clipboard").
|
||||
- [x] **Bench app header (done 2026-09-07).** Four controls no longer fit
|
||||
at 1080px at `HEADER_TEXT = 18`, and a previous agent had shrunk it to
|
||||
13 to make room -- UI_RULES: never shrink text to fit. Restored to 18
|
||||
and split `bench_controls` into two rows (`Dir::DOWN` of two
|
||||
`Dir::RIGHT` pairs: run+copy, then diagnostics+trace), doubling the
|
||||
header's own height (`HEADER_ROW_HEIGHT_DP`) rather than the outer
|
||||
layout's reserved space, since `top_bar` sizes to its own content.
|
||||
Checked on the emulator: `ui-trace show ... --field box` confirms two
|
||||
clean rows with no overlap, and a screenshot shows the restored size
|
||||
reading clearly.
|
||||
- [x] **Bench app crash-loops on this checkout's emulator (done
|
||||
2026-09-07).** Not the surface lifecycle at all, and not "once
|
||||
backgrounded" -- a build with the **default features** (no
|
||||
`force-gles`) never got a first frame. `AndroidRenderer::new`
|
||||
(`iris/src/android/render.rs`) asked wgpu for `Backends::PRIMARY`,
|
||||
which does not contain `GL`, and this emulator advertises a Vulkan ICD
|
||||
with no adapter behind it: `RequestAdapterError::NotFound {
|
||||
active_backends: VULKAN, no_adapter_backends: VULKAN,
|
||||
supported_backends: VULKAN | GL }`, `.expect`ed, so SIGABRT, so the
|
||||
launcher restarts it -- the loop. A `force-gles` build was never
|
||||
affected, which is why the crash looked like it belonged to whatever
|
||||
else was going on. **Root cause: iris refused a device whose only
|
||||
usable adapter is a GLES one.**
|
||||
|
||||
**Fix, two halves.** (1) `AndroidRenderer::new` now probes for a
|
||||
`PRIMARY` adapter with an instance that never touches the window and
|
||||
rebuilds the instance on `Backends::GL` when there is none. The probe
|
||||
is surface-free deliberately: **an Android window can be connected to
|
||||
one graphics API only**, so a single instance carrying both backends
|
||||
fails differently and worse -- Vulkan's
|
||||
`vkCreateAndroidSurfaceKHR` claims the window in `create_surface` and
|
||||
the GLES surface built from the same window then reports `In
|
||||
Surface::configure / Invalid surface`, aborting one frame later in
|
||||
`Surface::get_current_texture_view` ("Surface is not configured for
|
||||
presentation"). That was measured here on the way to the fix, not
|
||||
reasoned about. Vulkan still wins wherever it has an adapter, so
|
||||
nothing changes on the phone. (2) The surface, adapter and device
|
||||
requests all report through the `Result<Self, String>` this function
|
||||
already returns, instead of two of the three panicking -- one rule for
|
||||
the set, and `surface_changed` already puts that string on screen and
|
||||
in the log ring.
|
||||
|
||||
**Evidence**, this checkout's emulator (API 36, x86_64, debug):
|
||||
before, default features aborted on first launch with `Abort message:
|
||||
'Could not get adapter!: NotFound {...}'`; after, `iris renderer: no
|
||||
Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU) adapter on this
|
||||
device, falling back to GLES` then `new renderer built (Gl)` and
|
||||
frames. Then the cases the fix had no reason to touch, clean on both
|
||||
the default build and a `force-gles` one: two background/return
|
||||
cycles, rotate to landscape and back (`already_live=true`, the reuse
|
||||
branch), a background/return after the rotation, and cold starts.
|
||||
**Vulkan could not be exercised here** -- the probe's own answer is
|
||||
that this emulator has no Vulkan adapter, which is the whole defect;
|
||||
Vulkan remains only testable on Iris's phone.
|
||||
|
||||
**Also landed with it, and worth more than the fix**: a panic hook in
|
||||
`iris/android-app/src/app_log.rs`. Checked first, rather than assumed:
|
||||
under `panic = "abort"` a panic's message reaches the tombstone's
|
||||
`Abort message` and **nothing else** -- not `log`, so not the ring, so
|
||||
not Dev Updater's Runtime tab, which is the only surface Iris has on a
|
||||
phone with no `adb`. The hook writes the message and its location at
|
||||
`error` level, and -- because the ring is memory only and the process
|
||||
is about to die -- also to `last-panic.txt` in the app's private
|
||||
directory, which `set_crash_dir` (called from `nativeSetFilesDir`)
|
||||
replays into the ring at `error` level on the next start and deletes.
|
||||
So a crash loop now explains itself in the Runtime tab of the run
|
||||
that is still up. Verified on the emulator by building the *unfixed*
|
||||
renderer with the hook: `iris panic at .../render.rs:140:14: Could not
|
||||
get adapter!: NotFound {...}` in the ring on the run that died, and
|
||||
`iris app log: the previous run died -- ...` on the next one.
|
||||
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
|
||||
2026-09-07)". A mask references a primitive already drawn
|
||||
(rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
|
||||
points at the widget's own primitives; hit-testing applies the shape.
|
||||
**Chaining landed early**, 2026-09-07 (d507ae4): a mask carries the
|
||||
mask it was set inside and the fragment stage walks that chain, so
|
||||
nesting works and `Painter::set_mask` no longer aborts on it. Still
|
||||
rectangles only -- the shape half, and the hit-testing half, are what
|
||||
is left of this item.
|
||||
- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted`
|
||||
(docs/TODO.md). Main branch, not rustify.
|
||||
|
||||
@@ -309,6 +1061,129 @@ the targetSdk reading was wrong and the window is still being resized.
|
||||
different fault from either. For the fling, a flick should now visibly
|
||||
slow before it stops rather than running out at speed.
|
||||
|
||||
### The fling started too slow: Compose fits a curve, iris averaged (2026-09-07)
|
||||
|
||||
Iris on the `4274b8b` build: **"flinging now actually works but is
|
||||
slower than Compose's immediately after releasing the flick (the slow
|
||||
down seems correct)."** The curve was right -- that was the previous
|
||||
fix -- so the wrong number was the *initial velocity*.
|
||||
|
||||
**What iris did.** `VelocityTracker` held the last 100ms of per-frame
|
||||
pan *deltas* and answered their sum over the span between the oldest and
|
||||
newest: an average. An average cannot tell an accelerating flick from a
|
||||
steady drag, and a flick is by definition accelerating, so every fling
|
||||
started at roughly the speed of the middle of the gesture rather than
|
||||
the speed at the release. Every test it had asserted the average's own
|
||||
definition back at it, which is the same shape of self-grading the
|
||||
spline shipped a straight line through.
|
||||
|
||||
**What Compose does, which is not what it is remembered as.** Read out
|
||||
of the `-sources.jar` of `androidx.compose.ui:ui-android:1.12.0` and
|
||||
`androidx.compose.foundation:foundation-android:1.12.0` on
|
||||
dl.google.com -- the versions `app/gradle/libs.versions.toml` builds the
|
||||
Compose app against, which is the app being compared with:
|
||||
|
||||
- `scrollable`/`draggable` release through `DragGestureNode.
|
||||
sendDragStopped`, which calls the **2D `VelocityTracker`**. On Android
|
||||
that delegates to `Lsq2VelocityTracker` -- two
|
||||
`VelocityTracker1D(strategy = Lsq2)` over **absolute positions**,
|
||||
fitting a degree-2 polynomial by least squares (`polyFitLeastSquares`,
|
||||
Gram-Schmidt QR) and taking its derivative at the newest sample.
|
||||
- **`Strategy.Impulse` is not on the touch path.** Its only route in is
|
||||
`DifferentialVelocityTracker`, whose only caller is
|
||||
`NonTouchScrollingLogic`: mouse wheel and trackpad.
|
||||
`AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled`, which would
|
||||
swap in the platform's own (impulse) tracker, defaults to `false`.
|
||||
This was the surprise of the port, and the reason to read rather than
|
||||
remember -- the plan for this task named Impulse.
|
||||
- Constants: `HistorySize = 20`, `HorizonMilliseconds = 100`,
|
||||
`AssumePointerMoveStoppedMilliseconds = 40`, `minSampleSize = 3` for
|
||||
Lsq2. The walk back from the newest sample stops at the first sample
|
||||
older than the horizon **or** separated from its neighbour by more
|
||||
than the stopped gap.
|
||||
- **Which samples.** `sendDragStart` adds the DOWN change; every
|
||||
subsequent MOVE, historical samples included, is added by
|
||||
`sendDragEvent`. The **UP position is never added** --
|
||||
`Lsq2VelocityTracker.addPointerInputChange` wraps its `addPosition`
|
||||
calls in `if (!event.changedToUpIgnoreConsumed())`, and the UP branch
|
||||
only resets the tracker when 40ms have passed since the last MOVE
|
||||
(b/238654963). So a finger that comes to rest before lifting reads as
|
||||
a stop, not as a decelerating tail.
|
||||
|
||||
**The clamps, both checked rather than assumed.** Maximum:
|
||||
`ViewConfiguration.getScaledMaximumFlingVelocity()`, 8000 dp/s, passed
|
||||
into `calculateVelocity(maximumVelocity)` at the release -- 20400px/s at
|
||||
the phone's density 2.55, which this flick does not reach. Minimum:
|
||||
**there is none on the fling path.** `ViewConfiguration.
|
||||
minimumFlingVelocity` (AOSP's 50 dp/s) exists in Compose's
|
||||
`ViewConfiguration` interface, but its only use in either artifact is
|
||||
`NestedScrollInteropConnection`, for View interop.
|
||||
`DefaultFlingBehavior.performFling` guards with
|
||||
`abs(initialVelocity) > 1f` and says why in its own comment ("we need it
|
||||
since spline curve gives us NaNs"). So iris applies 1px/s, not 50dp/s: a
|
||||
50dp/s floor would swallow slow deliberate releases that Compose flings.
|
||||
Both live in `List::fling`, which is the only place that knows the
|
||||
density the dp figure has to be multiplied by.
|
||||
|
||||
**What changed.** `VelocityTracker` holds **positions**, not deltas
|
||||
(Lsq2 refuses differential data in Compose too), capped at 20 samples;
|
||||
`add_sample(delta, at)` is now `add_position(position, at)`, and
|
||||
`DragGesture` feeds the raw window coordinate along the drag axis at the
|
||||
press and at every `Pan` frame -- the same set Compose feeds, minus the
|
||||
one MOVE that crosses the touch slop, which Compose drops only because
|
||||
`sendDragStart` happens to add just the DOWN. `poly_fit_least_squares`
|
||||
is Compose's `polyFitLeastSquares` on fixed-size arrays.
|
||||
|
||||
**`iris/benches/velocity_reference.py`** is the independent
|
||||
transcription, same role as `fling_spline_reference.py`, and prints
|
||||
every number the tests assert. On the phone's own recording
|
||||
(`transcript-fixture/touch/flick-120hz.touch`, five samples in 16ms):
|
||||
|
||||
| sample set | shipped (average) | Compose (Lsq2) |
|
||||
| --- | --- | --- |
|
||||
| `flick-120hz.touch` | 12250 px/s | **15250 px/s** |
|
||||
| steady drag, 5px/10ms | 500 px/s | 500 px/s |
|
||||
| accelerating flick, deltas doubling | 1080 px/s | **2445 px/s** |
|
||||
| old fast burst then 1px/10ms | 9182 px/s | 100 px/s |
|
||||
| stopped 48ms, then released | 2533 px/s | 0 px/s |
|
||||
| press + one move frame | 12500 px/s | 0 px/s |
|
||||
|
||||
The recording understates the change (1.24x) because it is only 16ms
|
||||
long; the accelerating set (2.26x) is the shape of a real finger flick
|
||||
and is what Iris was feeling. The last three rows are the cases an
|
||||
average gets not just low but *wrong*: it flings from a standstill, and
|
||||
it flings from two points that describe no curve.
|
||||
|
||||
**Corrected 2026-09-07** (docs/REVIEW-2026-09-07.md's D5): the first and
|
||||
last rows of the "shipped" column, and the 1.30x, read 11750 px/s -- one
|
||||
number in two rows for two different sample sets, which is the tell. The
|
||||
script prints 12250 for the recording (196 px over its own 16 ms) and
|
||||
12500 for the press-plus-one-move set (100 px over 8 ms). Where 11750 came
|
||||
from is only half recoverable: it is 196 px over 16.68 ms, i.e. the
|
||||
recording's travel divided by a **60 Hz** frame rather than by the span
|
||||
the file itself records, which explains the flick row and does not explain
|
||||
the other one -- that one was copied. The rule at the top of this section
|
||||
stands: these numbers come from `velocity_reference.py`, and a
|
||||
disagreement is fixed by running it, not by running the Rust.
|
||||
|
||||
**Tests.** Eight in `sense::velocity_tracker_tests`, two rewritten in
|
||||
`sense::drag_gesture_tests` (three samples is the fewest that can fling;
|
||||
one move frame answers 0, as Compose does), and `phone_screen.rs` now
|
||||
asserts -15250px/s rather than "more than 1000". **Negative control
|
||||
run**: with `velocity` reverted to `total / span`, exactly seven fail --
|
||||
the flick recording, the accelerating flick, the horizon, the stopped
|
||||
finger, the minimum sample count, both `drag_gesture` flick tests -- and
|
||||
the steady drag, the tap, the selection release, all sixteen arbiter
|
||||
tests and the whole of `phone_screen.rs` bar the flick pass unchanged.
|
||||
That is the half the change had no reason to touch.
|
||||
|
||||
**`iris drag release:` keeps its info line and gains a debug one**,
|
||||
`iris drag release samples:`, printing every held sample as
|
||||
`t_ms:position` relative to the first. Iris has no logcat, so that is
|
||||
the only way a flick that felt wrong on her screen becomes something
|
||||
replayable: paste it into a `touch/*.touch` file for layer 1, or
|
||||
straight into `velocity_reference.py`.
|
||||
|
||||
### The 22:16 phone report, worked 2026-09-06/07
|
||||
|
||||
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
|
||||
|
||||
Generated
+3
@@ -720,7 +720,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3662,6 +3664,7 @@ dependencies = [
|
||||
"client-core",
|
||||
"event-model",
|
||||
"iris",
|
||||
"log",
|
||||
"serde_json",
|
||||
"transcript-ui",
|
||||
"winit",
|
||||
|
||||
Generated
+2
@@ -744,7 +744,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -73,6 +73,18 @@ bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
# Measured 2026-09-07 (docs/RUST.md's "APK size" subsection): together these
|
||||
# take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the APK
|
||||
# from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a release. `strip`
|
||||
# also works around AGP's own stripReleaseDebugSymbols failing silently on
|
||||
# this .so ("packaging them as they are"). `opt-level = "s"` over `"z"`:
|
||||
# `z` measured another ~800 KB smaller but was not checked against iris's
|
||||
# own frame-time bench, so it is not worth the unmeasured risk -- see the
|
||||
# doc for the number and the follow-up this leaves.
|
||||
strip = true
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
opt-level = "s"
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
@@ -24,8 +24,45 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- The enrollment link Dev Updater's Enroll button opens
|
||||
(what `ai-server` mints), the same one the Compose app
|
||||
in `app/` registers: which app answers it is the phone
|
||||
owner's choice at the moment of the tap, and both being
|
||||
offered is the intended behaviour rather than a clash.
|
||||
BROWSABLE so a link tapped in another app reaches here,
|
||||
and `android:host` so this app is not offered for every
|
||||
aiapp:// URI a future route invents. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="aiapp" android:host="enroll" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||
</activity>
|
||||
|
||||
<!-- This app's own recent log, for Dev Updater to read on the
|
||||
phone. Iris runs these builds with no adb, and Android
|
||||
forbids one app reading another's logcat, so this is the
|
||||
only way a log::info! here reaches her. The shape is Dev
|
||||
Updater's contract (its README.md, "An app's own log"), not
|
||||
something invented for this app.
|
||||
|
||||
The authority carries ${applicationId}, so the bench package
|
||||
and the ordinary one each get their own and neither can read
|
||||
the other's log. Exported, because the whole point is
|
||||
another app reading it, and guarded by a permission Dev
|
||||
Updater declares at protectionLevel="normal" (a signature
|
||||
permission is not available: the two apps are signed with
|
||||
different locally generated keys). Read-only: insert,
|
||||
update and delete throw. -->
|
||||
<provider
|
||||
android:name=".DevLogProvider"
|
||||
android:authorities="${applicationId}.devlog"
|
||||
android:exported="true"
|
||||
android:readPermission="dev.updater.permission.READ_DEVLOG" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,186 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.UriMatcher;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
|
||||
/**
|
||||
* This app's own recent log, exposed on the device.
|
||||
*
|
||||
* Iris runs these builds on a phone with no {@code adb}, and Android
|
||||
* forbids one app reading another's {@code logcat} -- so nothing outside
|
||||
* this process can recover what it wrote. The process already keeps a
|
||||
* bounded copy of its log (Rust: {@code client_core::log_ring}); this
|
||||
* hands it to Dev Updater, which is on the same phone, so it needs no
|
||||
* tunnel, no token and no second enrolment.
|
||||
*
|
||||
* <p>The shape is <em>Dev Updater's contract</em>, not something invented
|
||||
* here -- see that project's {@code README.md}, "An app's own log". Any
|
||||
* app it delivers can implement the same and get the same Runtime tab.
|
||||
* Two paths:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code lines?since=<seq>} -- every held line with a sequence at or
|
||||
* after {@code since}, oldest first.
|
||||
* <li>{@code status} -- one row: how many lines are held, how many the
|
||||
* ring's own bound has dropped, and the newest sequence ({@code -1}
|
||||
* for a log nothing has been written to, which is also how a reader
|
||||
* notices this process restarted).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Read-only: there is nothing here for anyone else to change, so the
|
||||
* three writing methods throw rather than silently doing nothing.
|
||||
*
|
||||
* <p>The authority is {@code <applicationId>.devlog}, filled in from
|
||||
* Gradle so the bench build and the ordinary one each get their own and
|
||||
* neither can read the other's. Read access is guarded by
|
||||
* {@code dev.updater.permission.READ_DEVLOG}, declared in the manifest.
|
||||
*
|
||||
* <p>No {@code notifyChange}: the ring is filled by a {@code log::Log}
|
||||
* backend on whatever thread logged, and giving that a way to reach a
|
||||
* provider would mean plumbing a callback through {@code client-core} for
|
||||
* every platform. Dev Updater polls while its tab is open, which its
|
||||
* contract says it does precisely so implementing this stays cheap.
|
||||
*/
|
||||
public final class DevLogProvider extends ContentProvider {
|
||||
static {
|
||||
// The provider is created before any activity, so it cannot rely
|
||||
// on MainActivity's own load. Loading twice is a no-op.
|
||||
System.loadLibrary("main");
|
||||
}
|
||||
|
||||
/** Matches {@link #nativeLinesSince}'s flat answer. Both sides say it once. */
|
||||
private static final int FIELDS_PER_LINE = 5;
|
||||
|
||||
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
|
||||
|
||||
private static final int LINES = 1;
|
||||
private static final int STATUS = 2;
|
||||
|
||||
private UriMatcher matcher;
|
||||
|
||||
/** Every held line, {@link #FIELDS_PER_LINE} strings each, oldest first. */
|
||||
private static native String[] nativeLinesSince(long since);
|
||||
|
||||
/** Three strings: held, dropped, newest sequence. */
|
||||
private static native String[] nativeStatus();
|
||||
|
||||
/**
|
||||
* Tells the Rust side which authority this build registered under, so
|
||||
* the diagnostics pane can name somewhere a reader can actually query
|
||||
* -- and so "declared but never created" is a state it can say. Only
|
||||
* the provider knows it was instantiated; Android creates one lazily.
|
||||
*/
|
||||
private static native void nativeReady(String authority);
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
// The authority is not a constant here: it is derived from this
|
||||
// build's applicationId, so the bench package and the ordinary one
|
||||
// do not share one. Read back from the manifest rather than
|
||||
// recomposed, so there is one answer to what it is.
|
||||
String authority = getContext().getPackageName() + ".devlog";
|
||||
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||
matcher.addURI(authority, "lines", LINES);
|
||||
matcher.addURI(authority, "status", STATUS);
|
||||
nativeReady(authority);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(
|
||||
Uri uri,
|
||||
String[] projection,
|
||||
String selection,
|
||||
String[] selectionArgs,
|
||||
String sortOrder) {
|
||||
switch (matcher.match(uri)) {
|
||||
case LINES:
|
||||
return lines(sinceOf(uri));
|
||||
case STATUS:
|
||||
return status();
|
||||
default:
|
||||
// Null rather than an exception: an unknown path is a
|
||||
// reader asking for something this app does not have, and
|
||||
// the contract's own answer for that is no cursor.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code ?since=} as a number, or 0 for a reader starting from the
|
||||
* beginning. A value that is not a number is treated as 0 rather than
|
||||
* refused -- what a caller wants from a malformed cursor is the log,
|
||||
* not a stack trace about the query string.
|
||||
*/
|
||||
private static long sinceOf(Uri uri) {
|
||||
String since = uri.getQueryParameter("since");
|
||||
if (since == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(since);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static Cursor lines(long since) {
|
||||
String[] fields = nativeLinesSince(since);
|
||||
if (fields == null) {
|
||||
return null;
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
|
||||
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
|
||||
cursor.addRow(
|
||||
new Object[] {
|
||||
Long.parseLong(fields[at]),
|
||||
Long.parseLong(fields[at + 1]),
|
||||
fields[at + 2],
|
||||
fields[at + 3],
|
||||
fields[at + 4],
|
||||
});
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private static Cursor status() {
|
||||
String[] fields = nativeStatus();
|
||||
if (fields == null || fields.length != STATUS_COLUMNS.length) {
|
||||
return null;
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
|
||||
cursor.addRow(
|
||||
new Object[] {
|
||||
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
|
||||
});
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
// A MIME type is for something meant to be handed to another app
|
||||
// as data; these rows are read by one reader that knows the
|
||||
// columns. Saying nothing is the honest answer, not a gap.
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("this app's log is read-only");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("this app's log is read-only");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("this app's log is read-only");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.WindowInsets;
|
||||
@@ -20,9 +22,28 @@ public final class MainActivity extends Activity {
|
||||
System.loadLibrary("main");
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's private directory, where the Rust side keeps its enrollment
|
||||
* (`src/enrollment.rs`). Handed over before the view is built, because
|
||||
* the client the view creates reads the enrollment as it starts.
|
||||
*/
|
||||
private static native void nativeSetFilesDir(String path);
|
||||
|
||||
/**
|
||||
* One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's
|
||||
* Enroll button opens it. Parsed and stored on the Rust side, which is
|
||||
* where the enrollment lives for the desktop app too -- nothing about
|
||||
* the link's format is known here.
|
||||
*/
|
||||
private static native void nativeEnroll(String uri);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
// Before the view: creating it starts the Rust client, which asks
|
||||
// straight away which server it is enrolled with.
|
||||
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||
handleEnrollmentIntent(getIntent());
|
||||
IrisView view = new IrisView(this);
|
||||
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
@@ -99,6 +120,35 @@ public final class MainActivity extends Activity {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A link that arrives while the activity is already up. `singleTop` is
|
||||
* not set, so this is the resumed case only -- the fresh-launch case
|
||||
* goes through `onCreate`'s `getIntent`. `setIntent` so a later
|
||||
* `getIntent` reports the one actually being acted on rather than the
|
||||
* one this activity started with.
|
||||
*/
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleEnrollmentIntent(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a VIEW intent's URI to the Rust side, which decides whether it
|
||||
* is an enrollment link -- the scheme is checked here only so a launch
|
||||
* intent (which carries no data) costs nothing.
|
||||
*/
|
||||
private static void handleEnrollmentIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
nativeEnroll(data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||
* place that reads these fields, so the static dispatch and the
|
||||
* animation callback above cannot come to report different things. */
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// Only does anything under the `transcript-screen` feature (RUST.md's I5
|
||||
// Android integration) -- the plain tabs build (I2/I4) needs none of this
|
||||
// and stays untouched, same reasoning as the feature gate in Cargo.toml.
|
||||
//
|
||||
// Bakes the sandbox server's host, port, token and pinned CA in at build
|
||||
// time, the same way `app/androidApp/build.gradle.kts`'s
|
||||
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that
|
||||
// file's comment for why reading the machine's own certificate at build
|
||||
// time is the right trust boundary. This build additionally bakes the
|
||||
// host/port/token, which the Compose app does not: that app enrolls at
|
||||
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI
|
||||
// (Keystore-sealed token storage, a QR/link scanner) is real, separate
|
||||
// scope this integration does not need to build to answer RUST.md's
|
||||
// question -- there is nothing here yet resembling `ServerConfig.kt`. So
|
||||
// this is a **deliberate simplification for this rig only**: an APK built
|
||||
// this way is good for exactly the emulator/server pair that built it, and
|
||||
// must never be treated as a template for a real enrollment flow. Recorded
|
||||
// in RUST.md's I5 box rather than left to be rediscovered.
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
||||
return;
|
||||
}
|
||||
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
|
||||
// server at all -- `bench_client.rs` never references the `pinned`
|
||||
// module this generates, so requiring a live server's host/port/token/
|
||||
// CA to build it (as plain `transcript-screen` does, below) would be a
|
||||
// pointless requirement for a build that talks to nothing.
|
||||
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
|
||||
return;
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
|
||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
|
||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
||||
println!("cargo:rerun-if-env-changed=AI_APP_CA");
|
||||
println!("cargo:rerun-if-env-changed=XDG_CONFIG_HOME");
|
||||
|
||||
let host = require_env(
|
||||
"AI_APP_TRANSCRIPT_HOST",
|
||||
"the sandbox server's host as the emulator reaches it, e.g. 10.0.2.2",
|
||||
);
|
||||
let port = require_env(
|
||||
"AI_APP_TRANSCRIPT_PORT",
|
||||
"the sandbox server's port -- app/ui-sandbox.sh's start banner prints it",
|
||||
);
|
||||
let token = require_env(
|
||||
"AI_APP_TRANSCRIPT_TOKEN",
|
||||
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
|
||||
);
|
||||
|
||||
let ca_path = std::env::var_os("AI_APP_CA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var_os("HOME").expect("HOME must be set");
|
||||
PathBuf::from(home).join(".config")
|
||||
});
|
||||
base.join("ai-app").join("certs").join("ca.pem")
|
||||
});
|
||||
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"no CA certificate at {} ({e}).\n\
|
||||
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
|
||||
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
|
||||
against a different one.",
|
||||
ca_path.display()
|
||||
)
|
||||
});
|
||||
let ca_pem = ca_pem.trim();
|
||||
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
|
||||
panic!("{} is not a PEM certificate.", ca_path.display());
|
||||
}
|
||||
|
||||
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
let generated = format!(
|
||||
"// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\
|
||||
pub const HOST: &str = {host_lit:?};\n\
|
||||
pub const PORT: u16 = {port};\n\
|
||||
pub const TOKEN: &str = {token_lit:?};\n\
|
||||
pub const CA_PEM: &str = {ca_lit:?};\n",
|
||||
host = host,
|
||||
port = port
|
||||
.parse::<u16>()
|
||||
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
|
||||
ca = ca_path.display(),
|
||||
host_lit = host,
|
||||
token_lit = token,
|
||||
ca_lit = ca_pem,
|
||||
);
|
||||
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
|
||||
}
|
||||
|
||||
fn require_env(name: &str, what: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| {
|
||||
panic!("{name} must be set to build the transcript-screen feature -- {what}")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The platform half of this app's logging: what
|
||||
//! `client_core::log_ring` needs that only Android can supply, which is
|
||||
//! `android_logger` as the logger to forward to and nothing else.
|
||||
//!
|
||||
//! Everything general -- the ring, its bounds, the `log::Log` backend --
|
||||
//! is in `client-core`, shared with the desktop app (AGENTS.md's sharing
|
||||
//! rule).
|
||||
//!
|
||||
//! **Why an app carries its own log at all**: Iris tests these builds on a
|
||||
//! GrapheneOS phone with no `adb`, and Android forbids one app reading
|
||||
//! another's `logcat`. Nothing outside this process can recover what it
|
||||
//! wrote, so the process keeps a copy -- and hands it to Dev Updater on
|
||||
//! the same phone through `devlog`'s `ContentProvider`. See
|
||||
//! `docs/DECISIONS.md`, 2026-09-07.
|
||||
|
||||
use client_core::log_ring::{self, LogRing};
|
||||
|
||||
/// Installs the ring in front of `android_logger`, so `logcat` still sees
|
||||
/// exactly what it saw before and the ring sees it too.
|
||||
///
|
||||
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
|
||||
/// itself; the message says which caller, since two initialisation paths
|
||||
/// is a programmer error rather than something to recover from.
|
||||
pub fn install(max_level: log::LevelFilter) {
|
||||
let inner = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(max_level)
|
||||
.with_tag("iris-android-app"),
|
||||
);
|
||||
if log_ring::install_process_logger(
|
||||
Box::new(inner),
|
||||
max_level,
|
||||
iris::diagnostics::trace_enabled,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
// Not a panic: a logger already installed means logging works,
|
||||
// just without the ring, and taking the app down over a
|
||||
// diagnostic would be worse than the diagnostic being missing.
|
||||
// The line goes through whatever logger did win.
|
||||
log::warn!("iris app log: a logger was already installed, so there is no ring");
|
||||
}
|
||||
install_panic_hook();
|
||||
}
|
||||
|
||||
/// The process's ring -- what `Copy report` appends, what the diagnostics
|
||||
/// pane counts, and what `devlog`'s provider hands to Dev Updater.
|
||||
pub fn ring() -> &'static LogRing {
|
||||
log_ring::process_ring()
|
||||
}
|
||||
|
||||
/// Only the bench build has a diagnostics pane to put this in; the
|
||||
/// transcript build's screen is the app's own and has no room for a
|
||||
/// readout. Gated rather than left dead so the build stays warning-clean.
|
||||
#[cfg(feature = "bench")]
|
||||
/// Two lines for the diagnostics pane: how much of this app's log is held,
|
||||
/// and where it can be read from.
|
||||
///
|
||||
/// The second names the provider's authority rather than saying "logging
|
||||
/// is on", so a screenshot of this pane is enough to tell whether the
|
||||
/// contract is live and which package's log it is -- the bench build and
|
||||
/// the ordinary one have different ones.
|
||||
pub fn diagnostics_line() -> String {
|
||||
let where_to_read = match crate::devlog::authority() {
|
||||
Some(authority) => format!("devlog provider: content://{authority}"),
|
||||
// Not "off": Android creates a provider lazily, so this is what
|
||||
// "nobody has asked for it yet" looks like, and it is a different
|
||||
// thing from a build that does not have one.
|
||||
None => "devlog provider: declared, not created yet".to_string(),
|
||||
};
|
||||
format!("{}\n{where_to_read}", ring().summary())
|
||||
}
|
||||
|
||||
/// Where the panic hook leaves its one line, under the app's private
|
||||
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
||||
/// start.
|
||||
const CRASH_FILE: &str = "last-panic.txt";
|
||||
|
||||
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Installs a `log`-level panic hook, so a panic's message and location
|
||||
/// reach the ring and `logcat` rather than only the tombstone.
|
||||
///
|
||||
/// **Why this is needed at all**: these builds are `panic = "abort"`
|
||||
/// (`Cargo.toml`), and the default hook writes to `stderr` plus
|
||||
/// `android_set_abort_message` -- the crash report. Iris runs these on a
|
||||
/// phone with no `adb`, so the crash report is exactly the surface she
|
||||
/// cannot read, and an `assert!` that fired said nothing anywhere she
|
||||
/// could see it. Routing it through `log::error!` puts it in front of
|
||||
/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev
|
||||
/// Updater.
|
||||
///
|
||||
/// The ring is memory only, so after an abort the process that holds it
|
||||
/// is gone -- hence the file half. [`set_crash_dir`] replays it.
|
||||
fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let where_at = match info.location() {
|
||||
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
|
||||
None => "an unknown location".to_string(),
|
||||
};
|
||||
// `info`'s own `Display` repeats the location and a newline;
|
||||
// the payload alone keeps this to the one line the ring wants.
|
||||
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
|
||||
let line = format!("iris panic at {where_at}: {message}");
|
||||
log::error!("{line}");
|
||||
if let Some(path) = CRASH_PATH.get() {
|
||||
// Best effort by design: a panic is already the failure, and
|
||||
// failing to record it must not become a second one.
|
||||
let _ = std::fs::write(path, &line);
|
||||
}
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Tells the panic hook where to leave its line, and replays the line a
|
||||
/// previous run left there into the ring before deleting it.
|
||||
///
|
||||
/// Called from `nativeSetFilesDir`, which is the first moment the app's
|
||||
/// private directory is known. The replay is at `error` level and says
|
||||
/// it is from the previous run, so a crash loop shows the reason it is
|
||||
/// looping in the Runtime tab of the run that is still up.
|
||||
pub fn set_crash_dir(dir: &std::path::Path) {
|
||||
let path = dir.join(CRASH_FILE);
|
||||
if let Ok(previous) = std::fs::read_to_string(&path) {
|
||||
log::error!("iris app log: the previous run died -- {}", previous.trim());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
let _ = CRASH_PATH.set(path);
|
||||
}
|
||||
@@ -415,6 +415,29 @@ const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
|
||||
/// What a report says about the `iris::input`/`iris::frame` trace, from
|
||||
/// the flag read at the start of what is being reported and again at the
|
||||
/// end.
|
||||
///
|
||||
/// Three answers rather than two. Those lines are default-off and the
|
||||
/// switch that turns them on is on screen while a benchmark runs, so
|
||||
/// "somebody moved it half way through" is a state that actually happens
|
||||
/// -- and reported as either "on" or "off" it is a confident sentence
|
||||
/// about a log that only covers part of the run. The "on" wording also
|
||||
/// says what it costs, because a traced run fills the ring in seconds and
|
||||
/// a reader looking at a log with nothing else in it should know why.
|
||||
fn trace_line(at_start: bool, at_end: bool) -> String {
|
||||
match (at_start, at_end) {
|
||||
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
|
||||
the app log, and a traced run fills the ring in seconds)"
|
||||
.to_string(),
|
||||
(false, false) => "input/frame trace: off".to_string(),
|
||||
_ => "input/frame trace: switched during this run, so those lines cover only part \
|
||||
of it"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -442,6 +465,25 @@ const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||
/// so the row's reserved height in the outer `Span::DOWN`
|
||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||
/// The size every label in the header row is drawn at.
|
||||
///
|
||||
/// One constant for all four rather than a number per button, because the
|
||||
/// whole row has to be sized together. Adding the trace switch made four
|
||||
/// controls too wide for one row at the size three had used (18), and an
|
||||
/// earlier pass shrank this constant to 13 to make them fit -- exactly
|
||||
/// what UI_RULES forbids ("never shrink text to make it fit": a label a
|
||||
/// different size from its neighbours elsewhere in the app for a reason
|
||||
/// the reader cannot see). The fix is [`bench_controls`]'s two rows
|
||||
/// instead, which leaves room to put this back. Whoever adds a fifth
|
||||
/// control reconsiders the row split, not this number.
|
||||
const HEADER_TEXT: f32 = 18.0;
|
||||
|
||||
/// The height of one row of header controls, in dp. `bench_controls` now
|
||||
/// stacks two of these, so this is the one number to change if a control's
|
||||
/// own padding ever changes instead of `dp(56)` and `dp(112)` needing to
|
||||
/// be kept in sync by hand.
|
||||
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||
.on(
|
||||
@@ -453,7 +495,9 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
.label("Run benchmark");
|
||||
let run = (
|
||||
run_rect,
|
||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||
wtext("Run benchmark")
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
@@ -462,14 +506,16 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||
ctx.state.copy_report();
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.copy_report(rsc);
|
||||
},
|
||||
)
|
||||
.label("Copy report");
|
||||
let copy = (
|
||||
copy_rect,
|
||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||
wtext("Copy report")
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
@@ -485,17 +531,61 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
.label("Diagnostics");
|
||||
let diagnostics = (
|
||||
diag_rect,
|
||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
||||
wtext("Diagnostics")
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
||||
// A switch rather than a button, so its own appearance says which
|
||||
// state it is in: the two `iris::input`/`iris::frame` targets are
|
||||
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
|
||||
// session fills the 2000-line ring in seconds, so "is it on right
|
||||
// now" is the question somebody has while looking at a log that is
|
||||
// either full of trace or has none.
|
||||
//
|
||||
// The visible text carries the state and the accessibility label does
|
||||
// not, deliberately: the label is also what `run-bench.sh` taps by
|
||||
// name, and a control that renames itself when pressed is one no
|
||||
// script can find twice.
|
||||
let tracing = iris::diagnostics::trace_enabled();
|
||||
let trace_rect = rect(if tracing {
|
||||
Color::rgb(90, 70, 30)
|
||||
} else {
|
||||
Color::rgb(50, 50, 60)
|
||||
})
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.toggle_trace(rsc);
|
||||
},
|
||||
)
|
||||
.label("Trace input and frames");
|
||||
let trace = (
|
||||
trace_rect,
|
||||
wtext(if tracing { "Trace on" } else { "Trace off" })
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
// Two rows rather than one: four controls at the restored `HEADER_TEXT`
|
||||
// no longer fit a 1080px-wide row (that was the shrink this replaces --
|
||||
// see the constant's own doc). Grouped by what they act on: the first
|
||||
// row starts a benchmark and copies its result; the second is the
|
||||
// diagnostics pane and the switch that decides what it will contain
|
||||
// next time.
|
||||
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
||||
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
||||
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
||||
|
||||
(rect(HEADER_SURFACE), buttons)
|
||||
.stack()
|
||||
.height(dp(56))
|
||||
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||
.pad(Padding::top(top_pad))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
@@ -526,6 +616,25 @@ impl BenchClient {
|
||||
self.last_report = Some(report);
|
||||
}
|
||||
|
||||
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
||||
/// switch that says so, and shows the pane that now reports it.
|
||||
///
|
||||
/// Showing the pane is the point rather than a convenience: this is a
|
||||
/// control whose whole effect is on what a *later* report says, so
|
||||
/// putting the state on screen at the moment of the press is the only
|
||||
/// thing that distinguishes it from a button that did nothing.
|
||||
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
||||
let on = !iris::diagnostics::trace_enabled();
|
||||
iris::diagnostics::set_trace(on);
|
||||
log::info!(
|
||||
"iris diagnostics: input/frame trace {}",
|
||||
if on { "on" } else { "off" }
|
||||
);
|
||||
let controls = bench_controls(rsc, self.last_top_pad);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
self.show_diagnostics(rsc);
|
||||
}
|
||||
|
||||
/// The diagnostics report as text, with no side effect on what is on
|
||||
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||
/// the keyboard-open capture (which only logs it), so the two can
|
||||
@@ -544,7 +653,20 @@ impl BenchClient {
|
||||
// logcat on her phone, and "the keyboard does not push the
|
||||
// composer up" cannot be told from "the listener never fired"
|
||||
// without it (`AndroidUiState::insets_report`).
|
||||
format!("{renderer}\n{}", self.android_state().insets_report())
|
||||
format!(
|
||||
"{renderer}\n{}\n{}\n{}\n{}",
|
||||
trace_line(
|
||||
iris::diagnostics::trace_enabled(),
|
||||
iris::diagnostics::trace_enabled()
|
||||
),
|
||||
self.android_state().insets_report(),
|
||||
// Which server this build talks to, and what to do when the
|
||||
// answer is "none" -- the bench itself opens a checked-in
|
||||
// fixture and needs no server, so this pane is the only place
|
||||
// an enrolment can be seen to have taken.
|
||||
crate::enrollment::status_line(),
|
||||
crate::app_log::diagnostics_line()
|
||||
)
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
@@ -565,16 +687,40 @@ impl BenchClient {
|
||||
log::info!("iris keyboard diagnostics:\n{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");
|
||||
return;
|
||||
};
|
||||
/// Always copies something, and never depends on `Diagnostics` or
|
||||
/// `Run benchmark` having been pressed first (docs/IRIS_TODO.md,
|
||||
/// 2026-09-07 night: "the copy report button seemed impossible to hit
|
||||
/// until I hit the diagnostics one" -- it was silently declining
|
||||
/// instead of reporting where it had failed, the UI_RULES failure "a
|
||||
/// failure is reported where it happened"). With no benchmark run yet,
|
||||
/// it copies the diagnostics pane's own text instead, with a first
|
||||
/// line saying so -- `diagnostics_text` needs no prior button press
|
||||
/// either, so this is never actually empty-handed.
|
||||
fn copy_report(&mut self, rsc: &mut Rsc) {
|
||||
let Some(platform) = &self.platform else {
|
||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||
return;
|
||||
};
|
||||
if platform.copy_to_clipboard("iris bench report", report) {
|
||||
let report = match self.last_report.clone() {
|
||||
Some(report) => report,
|
||||
None => format!(
|
||||
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
|
||||
self.diagnostics_text(rsc)
|
||||
),
|
||||
};
|
||||
// The ring's tail goes on the clipboard, not the full ring, and
|
||||
// not into the on-screen pane either: the full ring can be over a
|
||||
// thousand lines with tracing on, and pasting that into a phone's
|
||||
// message box was Iris's own "causes a lot of lag" report. The
|
||||
// full ring is still reachable through Dev Updater's Runtime tab
|
||||
// (`devlog`'s provider reads the same ring) -- this only bounds
|
||||
// what gets inlined here.
|
||||
let report = format!(
|
||||
"{report}\n\n=== app log ({}) ===\n{}",
|
||||
crate::app_log::ring().summary(),
|
||||
crate::app_log::ring().tail_text(client_core::log_ring::COPY_REPORT_TAIL_LINES)
|
||||
);
|
||||
if platform.copy_to_clipboard("iris bench report", &report) {
|
||||
log::info!("iris bench report: copied to clipboard");
|
||||
} else {
|
||||
log::info!("iris bench report: clipboard copy failed");
|
||||
@@ -603,6 +749,11 @@ impl BenchClient {
|
||||
.and_then(|p| p.refresh_rate_hz())
|
||||
.unwrap_or(60.0);
|
||||
let cpu_start = process_cpu_ms();
|
||||
// Read at the start as well as the end, because the switch is on
|
||||
// screen while a run is going: a report that only asked afterwards
|
||||
// would say "on" about a run whose first half has no trace in it
|
||||
// -- the inferred answer presented as the measured one.
|
||||
let trace_at_start = iris::diagnostics::trace_enabled();
|
||||
let run_started_at = Instant::now();
|
||||
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
@@ -700,9 +851,11 @@ impl BenchClient {
|
||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||
TYPE_TEXT.chars().count()
|
||||
);
|
||||
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||
let report = format!(
|
||||
"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}"
|
||||
"iris bench report\n{traced}\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);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
||||
//! for a `ContentProvider` that Dev Updater queries.
|
||||
//!
|
||||
//! **Why**: Iris runs these builds on a phone with no `adb`, and Android
|
||||
//! forbids one app reading another's `logcat`, so nothing outside this
|
||||
//! process can recover what it wrote. The app already keeps a bounded copy
|
||||
//! (`client_core::log_ring`); this is how the copy leaves the process. Dev
|
||||
//! Updater is on the same phone, so handing it over needs no tunnel, no
|
||||
//! token and no second enrolment -- and it is Dev Updater's own contract
|
||||
//! rather than something invented here, so any app it delivers can do the
|
||||
//! same (its `README.md`, "An app's own log").
|
||||
//!
|
||||
//! **Everything general stays in `client-core`** (AGENTS.md's sharing
|
||||
//! rule). What is here is only what Android forces: the JNI boundary and
|
||||
//! the Java class on the other side of it.
|
||||
//!
|
||||
//! Both entry points answer a **flat `String[]`** rather than a row of
|
||||
//! typed columns. That is the whole of the JNI, and it is one array type
|
||||
//! instead of three interleaved ones for a payload the provider is about
|
||||
//! to hand back over binder as a `MatrixCursor` anyway; `DevLogProvider`
|
||||
//! parses the two numeric fields. Kept flat rather than nested for the
|
||||
//! same reason -- an array of arrays is four more JNI calls per line.
|
||||
|
||||
use android_view::jni::JNIEnv;
|
||||
use android_view::jni::objects::{JClass, JObject, JString};
|
||||
use android_view::jni::sys::{jlong, jobjectArray};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How many `String`s each log line occupies in the flat answer:
|
||||
/// `seq`, `t_ms`, `level`, `target`, `message`, in that order. The Java
|
||||
/// side has the same constant, and the two are the one place the shape is
|
||||
/// written down on each side.
|
||||
const FIELDS_PER_LINE: usize = 5;
|
||||
|
||||
/// The authority the provider registered itself under, once it has been
|
||||
/// created. `None` until then, which is a state worth being able to say:
|
||||
/// a provider Android never instantiated and one that is answering look
|
||||
/// the same from inside this process otherwise.
|
||||
static AUTHORITY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Where this app's log can be read from, for the diagnostics pane.
|
||||
///
|
||||
/// The provider's own answer rather than one composed from the package
|
||||
/// name here: what makes the line worth showing is that it names an
|
||||
/// authority somebody can actually query, and only the provider knows it
|
||||
/// registered.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn authority() -> Option<&'static str> {
|
||||
AUTHORITY.get().map(String::as_str)
|
||||
}
|
||||
|
||||
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
|
||||
/// it registered under, from its own `onCreate`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
authority: JString,
|
||||
) {
|
||||
if authority.is_null() {
|
||||
return;
|
||||
}
|
||||
let Ok(authority) = env.get_string(&authority) else {
|
||||
return;
|
||||
};
|
||||
let authority: String = authority.into();
|
||||
log::info!("iris devlog: serving this app's log at content://{authority}");
|
||||
let _ = AUTHORITY.set(authority);
|
||||
}
|
||||
|
||||
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
|
||||
/// three strings.
|
||||
///
|
||||
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
||||
/// what tells a reader holding a cursor that this process **restarted**:
|
||||
/// the ring is in memory, so a new process starts again at zero and a
|
||||
/// stale cursor would otherwise skip everything silently.
|
||||
///
|
||||
/// Exported by name rather than registered, matching this crate's other
|
||||
/// activity-side natives: the mangled name is the whole of what a class
|
||||
/// this app owns needs.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jobjectArray {
|
||||
string_array(&mut env, &status_fields())
|
||||
}
|
||||
|
||||
/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at
|
||||
/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each.
|
||||
///
|
||||
/// Inclusive of `since` because [`client_core::log_ring::LogRing::since`]
|
||||
/// is, and one definition of the cursor is what keeps the app's own
|
||||
/// uploaded report and this provider describing the same lines.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
since: jlong,
|
||||
) -> jobjectArray {
|
||||
// A negative cursor is a caller asking for everything, not an error to
|
||||
// take the app down over: the provider is a diagnostic.
|
||||
string_array(&mut env, &line_fields(since.max(0) as u64))
|
||||
}
|
||||
|
||||
/// The three status numbers, as the provider's row.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
fn status_fields() -> Vec<String> {
|
||||
let ring = client_core::log_ring::process_ring();
|
||||
vec![
|
||||
ring.len().to_string(),
|
||||
ring.dropped().to_string(),
|
||||
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The tabs demo links no `client-core` and keeps no ring, so it holds
|
||||
/// nothing and has never dropped anything -- which is the truth, not a
|
||||
/// stand-in. The natives are still exported there, because a `native`
|
||||
/// method Java declares and the library does not is an
|
||||
/// `UnsatisfiedLinkError` the moment the class loads.
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
fn status_fields() -> Vec<String> {
|
||||
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
|
||||
}
|
||||
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
fn line_fields(since: u64) -> Vec<String> {
|
||||
let (lines, _next) = client_core::log_ring::process_ring().since(since);
|
||||
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
|
||||
for line in lines {
|
||||
fields.push(line.seq.to_string());
|
||||
fields.push(line.at_ms.to_string());
|
||||
fields.push(line.level.to_string());
|
||||
fields.push(line.target);
|
||||
fields.push(line.message);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
fn line_fields(_since: u64) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A Java `String[]` of those, or a null array if the JVM refused one.
|
||||
///
|
||||
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
|
||||
/// reads it as "the provider could not answer" and returns no cursor,
|
||||
/// which Dev Updater already draws as a distinct state. Taking the app
|
||||
/// down to report that its diagnostic is unavailable would be worse than
|
||||
/// the diagnostic being unavailable.
|
||||
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
|
||||
let null = std::ptr::null_mut();
|
||||
let Ok(class) = env.find_class("java/lang/String") else {
|
||||
return null;
|
||||
};
|
||||
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
|
||||
return null;
|
||||
};
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
let Ok(value) = env.new_string(field) else {
|
||||
return null;
|
||||
};
|
||||
if env
|
||||
.set_object_array_element(&array, index as i32, value)
|
||||
.is_err()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
array.into_raw()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Which `ai-server` this app talks to, and how it was told.
|
||||
//!
|
||||
//! The parsing, the file and its owner-only mode are
|
||||
//! `client_core::config` (`EnrolledServer`/`EnrollmentStore`), shared with
|
||||
//! the desktop app. What is genuinely this platform's, and all that is
|
||||
//! here, is the intent plumbing: Android hands an `aiapp://enroll?...`
|
||||
//! link to `MainActivity`, which passes it and the app's private files
|
||||
//! directory across JNI (see `lib.rs`'s two exported functions).
|
||||
//!
|
||||
//! **Why the app is told at runtime rather than at build time.** The APK
|
||||
//! is cross-compiled in a VM and run against the server on the host, whose
|
||||
//! CA and token are not this machine's -- so nothing about the destination
|
||||
//! can be baked in, and no token or CA may sit in a repo or a delivered
|
||||
//! artifact either way. The CA arrives with the link (`ca` parameter,
|
||||
//! `wg_app_link::enroll::ca_param`), which is what makes an APK built
|
||||
//! anywhere able to pin the server it is pointed at.
|
||||
//!
|
||||
//! The files directory is process-wide state, which this project otherwise
|
||||
//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the
|
||||
//! first thing that wants the enrollment -- has no parameter it could come
|
||||
//! in through. Same shape, and the same reason, as
|
||||
//! `client_core::log_ring`'s process ring.
|
||||
|
||||
#[cfg(not(feature = "bench"))]
|
||||
use client_core::api::UreqTransport;
|
||||
use client_core::config::{EnrolledServer, EnrollmentStore};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds
|
||||
/// the view. Set once per process; a second call with a different path is
|
||||
/// a programmer error rather than something to recover from, and a second
|
||||
/// call with the same one is what a re-created activity does.
|
||||
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn set_files_dir(dir: PathBuf) {
|
||||
if let Err(existing) = FILES_DIR.set(dir.clone()) {
|
||||
assert_eq!(
|
||||
existing, dir,
|
||||
"the app's files directory was set twice with different paths"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` before `MainActivity` has handed the directory over -- which is
|
||||
/// **not** the same as "not enrolled", and is why [`status`] has a state
|
||||
/// for it (UI_RULES: design the unknown state first).
|
||||
fn store() -> Option<EnrollmentStore> {
|
||||
FILES_DIR.get().map(EnrollmentStore::new)
|
||||
}
|
||||
|
||||
/// What this app has been told, or why it has not been.
|
||||
pub enum Status {
|
||||
Enrolled(EnrolledServer),
|
||||
/// Nothing has been enrolled yet: the ordinary first-run state.
|
||||
NotEnrolled,
|
||||
/// The question could not be answered -- the activity never handed a
|
||||
/// files directory over, or the file is there and unreadable. Kept
|
||||
/// apart from `NotEnrolled` because the two want different actions
|
||||
/// from whoever is looking.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn status() -> Status {
|
||||
let Some(store) = store() else {
|
||||
return Status::Unknown("the activity never handed over a files directory".to_string());
|
||||
};
|
||||
match store.load() {
|
||||
Ok(Some(server)) => Status::Enrolled(server),
|
||||
Ok(None) => Status::NotEnrolled,
|
||||
Err(error) => Status::Unknown(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line for the diagnostics pane. The three states read differently on
|
||||
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||
/// must not be mistaken for it.
|
||||
///
|
||||
/// Only the bench build has a pane to put this in -- same gate, and the
|
||||
/// same reason, as `app_log::diagnostics_line`. The transcript build says
|
||||
/// the same things where they matter to it, in the message
|
||||
/// [`transport`]'s error becomes on screen.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn status_line() -> String {
|
||||
match status() {
|
||||
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
|
||||
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
|
||||
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever
|
||||
/// was enrolled before -- opening a link is how somebody says "this server
|
||||
/// now", including after the old one's token was rotated.
|
||||
///
|
||||
/// The returned `Err` is the message for a person: this is called from a
|
||||
/// tap on a link, and a link that did nothing with nothing said is the
|
||||
/// failure the UI rules are most insistent about.
|
||||
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||
let server = EnrolledServer::parse_link(uri)?;
|
||||
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||
store
|
||||
.save(&server)
|
||||
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// A transport for the enrolled server, pinning the CA the link carried.
|
||||
///
|
||||
/// Gated to the same builds as `transcript_client`, its only caller: the
|
||||
/// bench build opens a checked-in fixture and reaches no server, so
|
||||
/// compiling this into it would be a warning about dead code that is
|
||||
/// dead on purpose.
|
||||
///
|
||||
/// Every failure here is a sentence a screen can show, because there is
|
||||
/// nowhere else for it to go: this app has no `logcat` on the phone it is
|
||||
/// built for.
|
||||
#[cfg(not(feature = "bench"))]
|
||||
pub fn transport() -> Result<UreqTransport, String> {
|
||||
let server = match status() {
|
||||
Status::Enrolled(server) => server,
|
||||
Status::NotEnrolled => {
|
||||
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
|
||||
}
|
||||
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
|
||||
};
|
||||
let ca_pem = server.ca_pem.as_ref().ok_or(
|
||||
"The enrollment link carried no CA, so there is nothing to pin. \
|
||||
Enrol again with a link minted by this server.",
|
||||
)?;
|
||||
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
|
||||
.map_err(|error| error.message)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ use android_view::{
|
||||
Context, View,
|
||||
jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{JClass, JString},
|
||||
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||
},
|
||||
register_view_class,
|
||||
@@ -51,10 +52,25 @@ use iris::prelude::*;
|
||||
use log::LevelFilter;
|
||||
use std::ffi::c_void;
|
||||
|
||||
/// The app's own log ring and its upload -- only where `client-core` is
|
||||
/// linked, which is every build that has a server to send to. The plain
|
||||
/// tabs demo keeps `android_logger` alone, as it always had.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
mod app_log;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_client;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_jni;
|
||||
/// This app's log ring, handed to Dev Updater on the phone through a
|
||||
/// `ContentProvider`. Declared in every build for the reason the module
|
||||
/// gives: the Java class is in the manifest either way, and a `native`
|
||||
/// method the library does not export fails the class load.
|
||||
mod devlog;
|
||||
/// Which server this app talks to, told to it at runtime by an
|
||||
/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain
|
||||
/// tabs demo makes no network call and has nothing to enrol against.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
mod enrollment;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
mod transcript_client;
|
||||
|
||||
@@ -119,6 +135,13 @@ extern "system" fn new_view_peer<'local>(
|
||||
/// mirrors android-view's own demo, which carries the same comment.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
||||
// The ring in front of `android_logger` where there is one (see
|
||||
// `app_log`), and `android_logger` alone otherwise. Both install the
|
||||
// same tag and level, so `logcat` cannot tell the two builds apart --
|
||||
// the ring only adds a second reader.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
app_log::install(LevelFilter::Debug);
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(LevelFilter::Debug)
|
||||
@@ -130,3 +153,83 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) ->
|
||||
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||
JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
|
||||
/// over before the view exists because that is where the enrollment is
|
||||
/// read from and written to (`enrollment`'s module doc).
|
||||
///
|
||||
/// Exported by name rather than registered through `RegisterNatives`: the
|
||||
/// view's methods are registered because `android-view` owns that class
|
||||
/// and hands out one function pointer, whereas these two are this app's
|
||||
/// own activity and the mangled name is the whole of what is needed.
|
||||
///
|
||||
/// Declared in every build, including the tabs demo that has no
|
||||
/// `client-core` to store anything -- a `native` method Java declares and
|
||||
/// the library does not export is an `UnsatisfiedLinkError` when the class
|
||||
/// loads, which would take down a build that merely shares the activity.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
dir: JString,
|
||||
) {
|
||||
let Some(dir) = jstring(&mut env, dir) else {
|
||||
return;
|
||||
};
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
{
|
||||
app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
|
||||
}
|
||||
log::debug!("iris app: files directory is {dir}");
|
||||
}
|
||||
|
||||
/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the
|
||||
/// VIEW intent that started or resumed the activity.
|
||||
///
|
||||
/// Logged either way rather than answered: the activity has nothing to do
|
||||
/// with the result, and where the enrollment shows up is the diagnostics
|
||||
/// pane (`enrollment::status_line`), which reads the stored answer rather
|
||||
/// than being told it.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
uri: JString,
|
||||
) {
|
||||
let Some(uri) = jstring(&mut env, uri) else {
|
||||
return;
|
||||
};
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
match enrollment::apply_link(&uri) {
|
||||
// Never the token: `wg-app-link`'s enroll module forbids logging
|
||||
// it, and this line would otherwise be the one place it leaked.
|
||||
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
|
||||
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
|
||||
}
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||
}
|
||||
|
||||
/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one --
|
||||
/// neither is worth taking the app down for, and both are logged where
|
||||
/// they happen.
|
||||
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||
if value.is_null() {
|
||||
log::warn!("iris app: the activity passed a null string across JNI");
|
||||
return None;
|
||||
}
|
||||
match env.get_string(&value) {
|
||||
Ok(value) => Some(value.into()),
|
||||
Err(error) => {
|
||||
log::warn!("iris app: couldn't read a string from the activity -- {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,15 @@
|
||||
//!
|
||||
//! **Deliberate simplification, recorded rather than left to be
|
||||
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
|
||||
//! session list and no enrollment UI here. The server, port, token and
|
||||
//! pinned CA are baked in at build time (`build.rs`'s
|
||||
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
|
||||
//! session `ApiClient::fetch_sessions` returns is opened automatically --
|
||||
//! there is nothing to tap to get there, which is what `transcript-bench.sh`
|
||||
//! and `ui-trace` need to land straight on the screen under test. A real
|
||||
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
|
||||
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
|
||||
//! not this pass's job.
|
||||
//! session list here -- the first session `ApiClient::fetch_sessions`
|
||||
//! returns is opened automatically, since there is nothing to tap to get
|
||||
//! there, which is what `transcript-bench.sh` and `ui-trace` need to land
|
||||
//! straight on the screen under test.
|
||||
//!
|
||||
//! Which server it opens it against is no longer baked in: it is the
|
||||
//! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`,
|
||||
//! and `desktop-app`'s identical `--link`), because an APK
|
||||
//! cross-compiled here cannot pin the CA of a server on the host.
|
||||
//!
|
||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||
@@ -46,10 +46,6 @@ use iris::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
mod pinned {
|
||||
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
|
||||
}
|
||||
|
||||
pub struct TranscriptClient {
|
||||
ui_state: AndroidUiState,
|
||||
/// The screen's own content -- everything under the fixed
|
||||
@@ -85,18 +81,16 @@ impl HasAndroidUiState for TranscriptClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
|
||||
/// twice per session load, same as `desktop-app`'s `build_transport`
|
||||
/// closure -- `ApiClient` and the live-stream follow each need their own,
|
||||
/// since `UreqTransport` holds its own `ureq::Agent`.
|
||||
/// Builds one `UreqTransport` from the stored enrollment. Called twice per
|
||||
/// session load, same as `desktop-app`'s `build_transport` closure --
|
||||
/// `ApiClient` and the live-stream follow each need their own, since
|
||||
/// `UreqTransport` holds its own `ureq::Agent`.
|
||||
///
|
||||
/// Read afresh each time rather than held: opening a new enrolment link
|
||||
/// while the app is running is how somebody points it at another server,
|
||||
/// and a cached transport would keep talking to the old one.
|
||||
fn build_transport() -> Result<UreqTransport, String> {
|
||||
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
|
||||
UreqTransport::new(
|
||||
base_url,
|
||||
pinned::TOKEN.to_string(),
|
||||
pinned::CA_PEM.as_bytes(),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
crate::enrollment::transport()
|
||||
}
|
||||
|
||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
|
||||
@@ -130,7 +130,11 @@ if __name__ == "__main__":
|
||||
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||
# 2.75 is this checkout's emulator.
|
||||
for density in (2.55, 2.75):
|
||||
for velocity in (5000.0, 11064.0):
|
||||
# 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own
|
||||
# release velocity (velocity_reference.py), so `phone_screen.rs`
|
||||
# can bound the fling it produces from *here* rather than from the
|
||||
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
|
||||
for velocity in (5000.0, 11064.0, 15250.0):
|
||||
dur = fling_duration_s(velocity, density)
|
||||
print(
|
||||
f"density={density} v={velocity}: "
|
||||
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
|
||||
from a report the layer-1 harness produced with tracing on
|
||||
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
|
||||
`iris::harness::Harness::replay` can play back at layer 1.
|
||||
|
||||
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
|
||||
layer that can answer a question wins, and a gesture that misbehaves on
|
||||
Iris's phone is otherwise only describable in words. `iris::sense::
|
||||
log_input_event`'s one line per platform event (Android's on_touch_event
|
||||
once per `MotionEvent`, with historical samples inline; winit's once per
|
||||
pointer `WindowEvent`; the harness's `touch`, once per script line) already
|
||||
carries everything a `.touch` file's `t_ms action x y` needs -- this just
|
||||
reads it back out and reconstructs the samples in order, expanding each
|
||||
event's inline historical samples into their own `move` lines first (they
|
||||
are always intermediate positions of a move, and Android documents them as
|
||||
oldest first, which is also the order they appear in the line).
|
||||
|
||||
Usage:
|
||||
report_to_touch.py < report.txt > replay.touch
|
||||
report_to_touch.py report.txt > replay.touch
|
||||
|
||||
Only lines containing "iris input: action=..." are read; everything else in
|
||||
the report (insets, frame timings, drag-release summaries) is ignored, so
|
||||
this can be pointed at Copy report's whole clipboard text directly.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
# The message half of `sense::log_input_event`'s format string, prefix-
|
||||
# agnostic: a real report line also carries the ring's own
|
||||
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
|
||||
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
|
||||
# of that -- neither of which this needs to understand, since `search`
|
||||
# (not `match`) finds the marker wherever it starts.
|
||||
LINE_RE = re.compile(
|
||||
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
|
||||
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
|
||||
)
|
||||
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
|
||||
# -- see `log_input_event`'s own doc for why order matters.
|
||||
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
|
||||
|
||||
|
||||
def _fmt(value: float) -> str:
|
||||
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
|
||||
it -- an integer without a trailing `.0` where the source was one
|
||||
(every coordinate here is a physical pixel), `{:g}` otherwise so a
|
||||
fractional value from a real device is not silently truncated."""
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:g}"
|
||||
|
||||
|
||||
def convert(lines):
|
||||
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
|
||||
action, x, y)` tuple per touch sample -- a historical sample is always
|
||||
an intermediate `move`, and the event's own sample keeps its real
|
||||
action (`down`/`move`/`up`/`cancel`)."""
|
||||
rows = []
|
||||
for line in lines:
|
||||
m = LINE_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
hist_count = int(m.group("hist"))
|
||||
hist_matches = list(HIST_RE.finditer(m.group("rest")))
|
||||
if len(hist_matches) != hist_count:
|
||||
print(
|
||||
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
|
||||
f"holds {len(hist_matches)} samples -- skipped",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
for hm in hist_matches:
|
||||
rows.append(
|
||||
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
|
||||
)
|
||||
rows.append(
|
||||
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2:
|
||||
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
|
||||
return 2
|
||||
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
|
||||
for t_ms, action, x, y in convert(text):
|
||||
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
|
||||
|
||||
Same reason `fling_spline_reference.py` exists: the numbers checked into
|
||||
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
|
||||
estimator -- total motion over the sample span, an average -- passed every test
|
||||
it had, because every one of those tests asserted the average's own definition
|
||||
back at it. An average cannot tell an accelerating flick from a steady drag, and
|
||||
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
|
||||
actually works but is slower than Compose's immediately after releasing the
|
||||
flick".
|
||||
|
||||
Transcribed by hand from, and only from, the `-sources.jar` of
|
||||
**androidx.compose.ui:ui-android:1.12.0** and
|
||||
**androidx.compose.foundation:foundation-android:1.12.0**
|
||||
(dl.google.com/dl/android/maven2), read 2026-09-07:
|
||||
|
||||
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
|
||||
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
|
||||
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
|
||||
`HistorySize = 20`, `HorizonMilliseconds = 100`,
|
||||
`AssumePointerMoveStoppedMilliseconds = 40`.
|
||||
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
|
||||
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
|
||||
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
|
||||
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
|
||||
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
|
||||
default, which is `false`.
|
||||
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
|
||||
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
|
||||
feeds the tracker and where the maximum-velocity clamp is applied.
|
||||
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
|
||||
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
|
||||
* `androidx/compose/foundation/gestures/Scrollable.kt` --
|
||||
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
|
||||
|
||||
**Which strategy a touch fling actually uses, since this was the surprise.**
|
||||
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
|
||||
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
|
||||
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
|
||||
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
|
||||
least-squares fit over **absolute positions**, whose velocity is the fitted
|
||||
polynomial's derivative at the newest sample. Impulse is reached only through
|
||||
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
|
||||
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
|
||||
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
|
||||
printed points, because ruling it out by reading is cheaper than ruling it out
|
||||
again next time somebody remembers "Compose uses impulse".
|
||||
|
||||
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
|
||||
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
|
||||
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
|
||||
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
|
||||
and all the UP branch does is reset the tracker when more than 40ms have passed
|
||||
since the last MOVE (b/238654963). So a finger that stops before lifting reads
|
||||
as a stop, not as a decelerating tail. Positions are the raw event positions,
|
||||
so the touch slop is inside the motion the tracker sees even though the list
|
||||
never scrolled by it.
|
||||
|
||||
Two of Compose's samples iris does *not* reproduce, both noted rather than
|
||||
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
|
||||
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
|
||||
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
|
||||
one, since it is a real measured position and dropping it would be copying a
|
||||
quirk of where Compose happens to split its state machine.
|
||||
|
||||
**The clamps.** Maximum: `sendDragStopped` passes
|
||||
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
|
||||
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
|
||||
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
|
||||
exists in Compose's `ViewConfiguration` interface but its only use in either
|
||||
artifact is `NestedScrollInteropConnection`, for View interop.
|
||||
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
|
||||
and says why in its own comment: "we need it since spline curve gives us
|
||||
NaNs". 1 px/s, not 50 dp/s.
|
||||
|
||||
Run it with no arguments; it prints the sample sets and the velocities the
|
||||
Rust tests assert on.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
HISTORY_SIZE = 20
|
||||
HORIZON_MILLISECONDS = 100.0
|
||||
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
|
||||
MIN_SAMPLE_SIZE_LSQ2 = 3
|
||||
|
||||
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
|
||||
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
|
||||
# DefaultFlingBehavior.performFling's own threshold, in the units of the
|
||||
# positions fed to the tracker -- pixels per second here.
|
||||
FLING_MINIMUM_PX_S = 1.0
|
||||
|
||||
|
||||
def poly_fit_least_squares(x, y, sample_count, degree):
|
||||
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
|
||||
if degree < 1:
|
||||
raise ValueError("The degree must be at positive integer")
|
||||
if sample_count == 0:
|
||||
raise ValueError("At least one point must be provided")
|
||||
|
||||
truncated_degree = sample_count - 1 if degree >= sample_count else degree
|
||||
m = sample_count
|
||||
n = truncated_degree + 1
|
||||
|
||||
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
|
||||
a = [[0.0] * m for _ in range(n)]
|
||||
for h in range(m):
|
||||
a[0][h] = 1.0
|
||||
for i in range(1, n):
|
||||
a[i][h] = a[i - 1][h] * x[h]
|
||||
|
||||
q = [[0.0] * m for _ in range(n)]
|
||||
r = [[0.0] * n for _ in range(n)]
|
||||
for j in range(n):
|
||||
w = q[j]
|
||||
w[:] = a[j][:m]
|
||||
for i in range(j):
|
||||
z = q[i]
|
||||
dot = sum(w[h] * z[h] for h in range(m))
|
||||
for h in range(m):
|
||||
w[h] -= dot * z[h]
|
||||
norm = math.sqrt(sum(v * v for v in w))
|
||||
inverse_norm = 1.0 / max(norm, 1e-6)
|
||||
for h in range(m):
|
||||
w[h] *= inverse_norm
|
||||
for i in range(n):
|
||||
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
|
||||
|
||||
coefficients = [0.0] * n
|
||||
for i in range(n - 1, -1, -1):
|
||||
c = sum(q[i][h] * y[h] for h in range(m))
|
||||
for j in range(n - 1, i, -1):
|
||||
c -= r[i][j] * coefficients[j]
|
||||
coefficients[i] = c / r[i][i]
|
||||
return coefficients
|
||||
|
||||
|
||||
def kinetic_energy_to_velocity(kinetic_energy):
|
||||
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
|
||||
return sign * math.sqrt(2 * abs(kinetic_energy))
|
||||
|
||||
|
||||
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
|
||||
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
|
||||
work = 0.0
|
||||
start = sample_count - 1
|
||||
next_time = time[start]
|
||||
for i in range(start, 0, -1):
|
||||
current_time = next_time
|
||||
next_time = time[i - 1]
|
||||
if current_time == next_time:
|
||||
continue
|
||||
if is_data_differential:
|
||||
delta = -data_points[i - 1]
|
||||
else:
|
||||
delta = data_points[i] - data_points[i - 1]
|
||||
v_curr = delta / (current_time - next_time)
|
||||
v_prev = kinetic_energy_to_velocity(work)
|
||||
work += (v_curr - v_prev) * abs(v_curr)
|
||||
if i == start:
|
||||
work = work * 0.5
|
||||
return kinetic_energy_to_velocity(work)
|
||||
|
||||
|
||||
def calculate_velocity(samples):
|
||||
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
|
||||
|
||||
`samples` is `(time_millis, position)` oldest first, at most the last
|
||||
`HISTORY_SIZE` of which the circular buffer would still be holding.
|
||||
Returns units per second.
|
||||
"""
|
||||
held = samples[-HISTORY_SIZE:]
|
||||
if not held:
|
||||
return 0.0
|
||||
|
||||
data_points = []
|
||||
time = []
|
||||
newest_time, _ = held[-1]
|
||||
previous_time = newest_time
|
||||
for sample_time, sample_position in reversed(held):
|
||||
age = float(newest_time - sample_time)
|
||||
delta = abs(float(sample_time - previous_time))
|
||||
# Lsq2 walks back sample to sample; only the non-differential
|
||||
# Impulse branch compares every sample against the newest one.
|
||||
previous_time = sample_time
|
||||
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
|
||||
break
|
||||
data_points.append(sample_position)
|
||||
time.append(-age)
|
||||
if len(data_points) == HISTORY_SIZE:
|
||||
break
|
||||
|
||||
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
|
||||
return 0.0
|
||||
try:
|
||||
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
|
||||
# which is the newest sample's timestamp. units/ms -> units/s.
|
||||
return coefficients[1] * 1000.0
|
||||
|
||||
|
||||
def clamped(velocity, maximum):
|
||||
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
|
||||
if velocity == 0.0 or math.isnan(velocity):
|
||||
return 0.0
|
||||
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
|
||||
|
||||
|
||||
def average(samples):
|
||||
"""The estimator being replaced: total motion over the span."""
|
||||
if len(samples) < 2:
|
||||
return 0.0
|
||||
span = (samples[-1][0] - samples[0][0]) / 1000.0
|
||||
if span <= 0.0:
|
||||
return 0.0
|
||||
return (samples[-1][1] - samples[0][1]) / span
|
||||
|
||||
|
||||
# --- The three recorded sample sets the Rust tests assert on. ----------------
|
||||
|
||||
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
|
||||
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
|
||||
# sample (see the module doc), which is why the finger sitting still for its
|
||||
# last 4ms does not drag the estimate down. y only; the flick is vertical.
|
||||
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
|
||||
|
||||
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
|
||||
# average must agree here -- this is the case that cannot tell the two
|
||||
# estimators apart, which is why it is not the only one.
|
||||
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
|
||||
|
||||
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
|
||||
# This is the case the average gets wrong, and the negative control for
|
||||
# the port -- reverting to the average must fail this test and only this
|
||||
# kind of test.
|
||||
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
|
||||
|
||||
# 4. The two edges of the sample walk, checked here so the Rust asserts
|
||||
# Compose's answer rather than iris's own reading of the rule.
|
||||
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
|
||||
# drag: the burst must not leak into the estimate.
|
||||
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
|
||||
# (b) The finger stops for 48ms and then lifts. The gap exceeds
|
||||
# AssumePointerMoveStopped, so the walk breaks after one sample and
|
||||
# there is no fling -- what stops a "park it and let go" from
|
||||
# flinging at whatever speed the finger arrived with.
|
||||
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
||||
|
||||
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
|
||||
# press and two move frames, which is the fewest a fit can use.
|
||||
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
|
||||
# ... and one move frame, which Compose cannot fit either.
|
||||
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
|
||||
|
||||
# The phone: 1080x2424 at content_scale 2.55.
|
||||
PHONE_DENSITY = 2.55
|
||||
|
||||
|
||||
def report(name, samples):
|
||||
v = calculate_velocity(samples)
|
||||
print(f"{name}:")
|
||||
print(f" samples (t_ms, position): {samples}")
|
||||
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
|
||||
print(f" average (the old estimator): {average(samples):.4f} px/s")
|
||||
print(f" impulse (non-touch, for ref): ", end="")
|
||||
held = list(reversed(samples[-HISTORY_SIZE:]))
|
||||
newest = held[0][0]
|
||||
print(
|
||||
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
|
||||
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
|
||||
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
|
||||
report("flick-120hz.touch", FLICK_120HZ)
|
||||
report("steady drag (5px/10ms)", STEADY_DRAG)
|
||||
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
|
||||
|
||||
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
|
||||
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
|
||||
report("press and two move frames", TWO_MOVE_FRAMES)
|
||||
report("press and one move frame", ONE_MOVE_FRAME)
|
||||
|
||||
print("Clamps:")
|
||||
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
|
||||
print(
|
||||
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
|
||||
)
|
||||
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
|
||||
print()
|
||||
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
|
||||
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use std::ops::{Index, IndexMut};
|
||||
|
||||
use crate::{
|
||||
UiRegion, WidgetId,
|
||||
render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
|
||||
util::to_mut,
|
||||
};
|
||||
use crate::{render::LayerOrder, util::to_mut};
|
||||
|
||||
pub type LayerId = usize;
|
||||
|
||||
@@ -40,7 +36,10 @@ struct Child {
|
||||
tail: usize,
|
||||
}
|
||||
|
||||
pub type PrimitiveLayers = Layers<Primitives>;
|
||||
/// The draw order of every layer. The primitives themselves live in one
|
||||
/// arena beside this (`UiRenderState::primitives`); a layer names the
|
||||
/// slots it draws, which is what its vertex buffer is.
|
||||
pub type PrimitiveLayers = Layers<LayerOrder>;
|
||||
|
||||
impl<T: Default> Layers<T> {
|
||||
pub fn new() -> Layers<T> {
|
||||
@@ -120,32 +119,6 @@ impl<T: Default> Layers<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl PrimitiveLayers {
|
||||
pub fn write<P: Primitive>(
|
||||
&mut self,
|
||||
layer: LayerId,
|
||||
info: PrimitiveInst<P>,
|
||||
) -> PrimitiveHandle {
|
||||
self[layer].write(layer, info)
|
||||
}
|
||||
|
||||
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
||||
self[h.layer].free(h)
|
||||
}
|
||||
|
||||
pub fn write_image(
|
||||
&mut self,
|
||||
layer: LayerId,
|
||||
id: WidgetId,
|
||||
texture_idx: u32,
|
||||
region: UiRegion,
|
||||
mask_idx: MaskIdx,
|
||||
move_idx: MoveIdx,
|
||||
) -> PrimitiveHandle {
|
||||
self[layer].write_image(layer, id, texture_idx, region, mask_idx, move_idx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Default> Default for Layers<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
||||
+111
-92
@@ -2,34 +2,14 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
|
||||
use parley::{
|
||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||
fontique::{Blob, FamilyId},
|
||||
};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use swash::{
|
||||
FontRef,
|
||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||
zeno::{Format, Vector},
|
||||
};
|
||||
|
||||
/// Bundled fonts, registered over the system collection rather than relied
|
||||
/// on alone -- see `TextData::register_bundled_fonts`'s doc comment for
|
||||
/// why. Static weight/style cuts, not a variable font: parley/fontique
|
||||
/// resolve a variable font's weight axis by picking normalized coordinates
|
||||
/// on whatever single face registers for the family, and a phone whose
|
||||
/// system "Roboto" is actually the variable "Roboto Flex" is exactly the
|
||||
/// device class this sidesteps, rather than depends on working correctly.
|
||||
/// Noto Sans, OFL-licensed (`assets/fonts/OFL.txt`), chosen for coverage
|
||||
/// breadth (a transcript's content is not known in advance) over a
|
||||
/// smaller-footprint alternative -- see the doc comment for the size this
|
||||
/// added.
|
||||
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_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
|
||||
|
||||
/// What starting up found about text rendering, for the on-screen
|
||||
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
|
||||
/// once at startup ... the number of font families found, the default
|
||||
@@ -80,91 +60,130 @@ pub struct TextData {
|
||||
}
|
||||
|
||||
impl Default for TextData {
|
||||
/// Text comes entirely from the platform's own font collection --
|
||||
/// `FontContext::new()` builds a `fontique::Collection` with
|
||||
/// `CollectionOptions::system_fonts` on by default, which is real
|
||||
/// discovery on both targets this crate ships on: Android's backend
|
||||
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
|
||||
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
|
||||
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
|
||||
/// build's backend is fontconfig. No font is bundled or registered
|
||||
/// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what
|
||||
/// the Compose app does: it takes body/monospace text from
|
||||
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
|
||||
/// and its platform monospace face, and ships no text font of its own,
|
||||
/// only its committed Nerd Fonts icon subset for fixed glyphs).
|
||||
fn default() -> Self {
|
||||
let mut data = Self {
|
||||
font_cx: FontContext::new(),
|
||||
let mut font_cx = FontContext::new();
|
||||
patch_android_monospace(&mut font_cx);
|
||||
Self {
|
||||
font_cx,
|
||||
layout_cx: LayoutContext::new(),
|
||||
scale_cx: ScaleContext::new(),
|
||||
atlas: GlyphAtlas::default(),
|
||||
density: 1.0,
|
||||
};
|
||||
data.register_bundled_fonts();
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
|
||||
/// Mono (regular/bold) as static faces, and puts them **first** in the
|
||||
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
|
||||
/// not instead of, whatever the platform already found, so a script
|
||||
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
|
||||
/// font the same as before this existed.
|
||||
///
|
||||
/// Exists because text rendering must not depend on the platform's own
|
||||
/// font enumeration succeeding or resolving weight/style the way this
|
||||
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
|
||||
/// rendering as blank gaps of the correct advance width (the glyph
|
||||
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
|
||||
/// the emulator's system fonts happened to resolve every style. A
|
||||
/// bundled, static-per-style family removes fontique's Android font
|
||||
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
|
||||
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
|
||||
/// has to survive to reach the screen at all.
|
||||
///
|
||||
/// Cost: six static `.ttf`s, ~3.6 MB uncompressed
|
||||
/// (`iris/core/assets/fonts/`), landing in the APK compressed --
|
||||
/// `build-apk.sh`'s own output is what says the delivered number, not
|
||||
/// this comment.
|
||||
fn register_bundled_fonts(&mut self) {
|
||||
fn register(cx: &mut FontContext, bytes: &'static [u8]) -> Option<FamilyId> {
|
||||
let blob = Blob::new(Arc::new(bytes));
|
||||
cx.collection
|
||||
.register_fonts(blob, None)
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.next()
|
||||
}
|
||||
let sans_id = register(&mut self.font_cx, NOTO_SANS_REGULAR);
|
||||
register(&mut self.font_cx, NOTO_SANS_BOLD);
|
||||
register(&mut self.font_cx, NOTO_SANS_ITALIC);
|
||||
register(&mut self.font_cx, NOTO_SANS_BOLD_ITALIC);
|
||||
let mono_id = register(&mut self.font_cx, NOTO_SANS_MONO_REGULAR);
|
||||
register(&mut self.font_cx, NOTO_SANS_MONO_BOLD);
|
||||
/// Works around `fontique` 0.11.1's Android backend never resolving
|
||||
/// `GenericFamily::Monospace` (confirmed against
|
||||
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
|
||||
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
|
||||
/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry,
|
||||
/// "Platform fonts," for the full account). Two bugs stack, not one:
|
||||
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
|
||||
/// `fonts.xml` is parsed into that same name map, and even after parsing,
|
||||
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
|
||||
/// (not an `<alias>`) whose `<font>` children the backend's own parser
|
||||
/// does not read (a `TODO` in that match arm) -- so the name gets a
|
||||
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
|
||||
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
|
||||
/// /system/etc/fonts.xml` shows
|
||||
/// `<family name="monospace"><font weight="400"
|
||||
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
|
||||
/// alias.
|
||||
///
|
||||
/// So this reads `fonts.xml` itself (already on-device, already the
|
||||
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
|
||||
/// filename that declaration names, then finds which of fontique's
|
||||
/// *actually* scanned families (from `/system/fonts`, which do carry real
|
||||
/// font data, just under whatever name the font's own metadata gives it --
|
||||
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
|
||||
/// file with that name, and registers that family as the `Monospace`
|
||||
/// generic the way the backend itself would have if its parser had reified
|
||||
/// the declaration. A no-op if the family is somehow already resolved
|
||||
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
|
||||
/// test, or a device that names it some other way).
|
||||
#[cfg(target_os = "android")]
|
||||
fn patch_android_monospace(font_cx: &mut FontContext) {
|
||||
use parley::fontique::SourceKind;
|
||||
|
||||
if let Some(sans_id) = sans_id {
|
||||
let existing: Vec<_> = self
|
||||
.font_cx
|
||||
let already_resolved = font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::Monospace)
|
||||
.next()
|
||||
.is_some();
|
||||
if already_resolved {
|
||||
return;
|
||||
}
|
||||
let Some(target_file) = android_monospace_font_filename() else {
|
||||
return;
|
||||
};
|
||||
let names: Vec<String> = font_cx
|
||||
.collection
|
||||
.family_names()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
for name in names {
|
||||
let Some(id) = font_cx.collection.family_id(&name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(info) = font_cx.collection.family(id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(font) = info.default_font() else {
|
||||
continue;
|
||||
};
|
||||
let SourceKind::Path(path) = font.source().kind() else {
|
||||
continue;
|
||||
};
|
||||
if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) {
|
||||
font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::SansSerif)
|
||||
.collect();
|
||||
self.font_cx.collection.set_generic_families(
|
||||
GenericFamily::SansSerif,
|
||||
std::iter::once(sans_id).chain(existing),
|
||||
);
|
||||
let existing: Vec<_> = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::SystemUi)
|
||||
.collect();
|
||||
self.font_cx.collection.set_generic_families(
|
||||
GenericFamily::SystemUi,
|
||||
std::iter::once(sans_id).chain(existing),
|
||||
);
|
||||
}
|
||||
if let Some(mono_id) = mono_id {
|
||||
let existing: Vec<_> = self
|
||||
.font_cx
|
||||
.collection
|
||||
.generic_families(GenericFamily::Monospace)
|
||||
.collect();
|
||||
self.font_cx.collection.set_generic_families(
|
||||
GenericFamily::Monospace,
|
||||
std::iter::once(mono_id).chain(existing),
|
||||
);
|
||||
.append_generic_families(GenericFamily::Monospace, std::iter::once(id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
|
||||
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
|
||||
/// real XML parser -- a new dependency for one well-known, stable AOSP file
|
||||
/// whose structure fontique itself already parses with a full parser one
|
||||
/// module over. Not a general XML reader; assumes the file has exactly one
|
||||
/// `<family name="monospace">` element with at least one `<font>` child,
|
||||
/// which is the format on every AOSP `fonts.xml` this was checked against.
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_monospace_font_filename() -> Option<String> {
|
||||
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
|
||||
let xml =
|
||||
std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?;
|
||||
let family_start = xml.find("<family name=\"monospace\">")?;
|
||||
let block = &xml[family_start..];
|
||||
let block = &block[..block.find("</family>")?];
|
||||
let font_tag = block.find("<font")?;
|
||||
let after_tag = &block[font_tag..];
|
||||
let content_start = after_tag.find('>')? + 1;
|
||||
let content = &after_tag[content_start..];
|
||||
let filename = content[..content.find('<')?].trim();
|
||||
(!filename.is_empty()).then(|| filename.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn patch_android_monospace(_font_cx: &mut FontContext) {}
|
||||
|
||||
impl TextData {
|
||||
/// Builds the startup report -- see `FontDiagnostics`. Queries the
|
||||
/// collection directly (`fontique::Query`) rather than shaping a real
|
||||
/// string, since all that's needed is which family each axis lands on.
|
||||
|
||||
@@ -8,6 +8,15 @@ pub struct WindowUniform {
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// One primitive's placement and what to draw there, in the one arena
|
||||
/// every layer shares (`Primitives`). Read from a storage buffer by
|
||||
/// **both** shader stages: the vertex stage for the corners of the
|
||||
/// primitive it is drawing, the fragment stage for the corners of a
|
||||
/// *mask's* primitive, which is generally a different one and often in
|
||||
/// another layer. A layer's vertex buffer carries only the slot
|
||||
/// ([`instance_slot_layout`]), so there is exactly one copy of a
|
||||
/// placement and a mask cannot disagree with what was drawn. See
|
||||
/// LAYOUT.md's "Masks with a shape".
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PrimitiveInstance {
|
||||
@@ -18,24 +27,17 @@ pub struct PrimitiveInstance {
|
||||
pub move_idx: MoveIdx,
|
||||
}
|
||||
|
||||
impl PrimitiveInstance {
|
||||
const ATTRIBS: [VertexAttribute; 8] = vertex_attr_array![
|
||||
0 => Float32x2,
|
||||
1 => Float32x2,
|
||||
2 => Float32x2,
|
||||
3 => Float32x2,
|
||||
4 => Uint32,
|
||||
5 => Uint32,
|
||||
6 => Uint32,
|
||||
7 => Uint32,
|
||||
];
|
||||
|
||||
pub fn desc() -> VertexBufferLayout<'static> {
|
||||
VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Self>() as BufferAddress,
|
||||
step_mode: VertexStepMode::Instance,
|
||||
attributes: &Self::ATTRIBS,
|
||||
}
|
||||
/// The vertex layout of a layer's draw order: one `u32` slot into the
|
||||
/// global instance arena per instance, stepped per instance. Everything a
|
||||
/// primitive is made of used to be here as eight vertex attributes; it
|
||||
/// moved into the storage buffer above so the fragment stage can read it
|
||||
/// too.
|
||||
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
|
||||
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
|
||||
VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<u32>() as BufferAddress,
|
||||
step_mode: VertexStepMode::Instance,
|
||||
attributes: &ATTRIBS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +58,19 @@ pub struct Mask {
|
||||
/// primitive's own corners, so a mask and the content clipped by it
|
||||
/// can move independently. See LAYOUT.md section 2b.
|
||||
pub move_idx: MoveIdx,
|
||||
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
|
||||
/// clipping nests: the fragment stage walks the chain and a pixel has
|
||||
/// to be inside every mask on it. Chained rather than intersected on
|
||||
/// the CPU because each mask moves with its own widget -- a code fence
|
||||
/// inside a transcript row carries the row's scroll, the list's own
|
||||
/// box does not, and one region resolved when the fence was last drawn
|
||||
/// gets the second of those wrong as soon as the row moves.
|
||||
///
|
||||
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
|
||||
/// released when the child's own slot goes
|
||||
/// (`UiRenderState::remove`), so the chain cannot outlive what it
|
||||
/// points at.
|
||||
pub parent: MaskIdx,
|
||||
}
|
||||
|
||||
/// One widget's cumulative on-screen translation, and the slot of the
|
||||
|
||||
+100
-77
@@ -1,6 +1,10 @@
|
||||
use crate::{
|
||||
UiData, UiRenderState,
|
||||
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
|
||||
render::{
|
||||
data::{PrimitiveInstance, instance_slot_layout},
|
||||
texture::GpuTextures,
|
||||
util::ArrBuf,
|
||||
},
|
||||
util::{HashMap, Vec2},
|
||||
};
|
||||
use data::WindowUniform;
|
||||
@@ -120,6 +124,11 @@ impl WgpuErrorLog {
|
||||
pub struct UiRenderNode {
|
||||
uniform_group: BindGroup,
|
||||
primitive_layout: BindGroupLayout,
|
||||
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
|
||||
/// not per layer -- a mask referencing a rect drawn in another layer
|
||||
/// has to be able to read it (see `Primitives`).
|
||||
primitives: PrimitiveBuffers,
|
||||
primitive_group: BindGroup,
|
||||
rsc_layout: BindGroupLayout,
|
||||
rsc_group: BindGroup,
|
||||
|
||||
@@ -129,6 +138,9 @@ pub struct UiRenderNode {
|
||||
active: Vec<usize>,
|
||||
window_buffer: Buffer,
|
||||
textures: GpuTextures,
|
||||
/// Every primitive's placement, read by the vertex stage for the
|
||||
/// primitive being drawn and by the fragment stage for a mask's.
|
||||
instances: ArrBuf<PrimitiveInstance>,
|
||||
masks: ArrBuf<Mask>,
|
||||
move_offsets: ArrBuf<MoveOffset>,
|
||||
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
||||
@@ -146,16 +158,16 @@ pub struct UiRenderNode {
|
||||
masks_group: BindGroup,
|
||||
}
|
||||
|
||||
/// One layer's vertex buffers: the slots it draws, in order. The
|
||||
/// primitives themselves are in `UiRenderNode::instances`.
|
||||
struct RenderLayer {
|
||||
instance: ArrBuf<PrimitiveInstance>,
|
||||
primitives: PrimitiveBuffers,
|
||||
primitive_group: BindGroup,
|
||||
/// A standalone image's instances, kept apart from `instance` because
|
||||
/// each one draws with its own bind group -- see `UiRenderNode::draw`.
|
||||
image_instance: ArrBuf<PrimitiveInstance>,
|
||||
/// The texture slot each entry of `image_instance` draws with, in the
|
||||
/// same order, refreshed alongside it. Not stored in the vertex buffer
|
||||
/// itself because it names a bind group, not shader data.
|
||||
order: ArrBuf<u32>,
|
||||
/// A standalone image's slots, kept apart from `order` because each
|
||||
/// one draws with its own bind group -- see `UiRenderNode::draw`.
|
||||
images: ArrBuf<u32>,
|
||||
/// The texture slot each entry of `images` draws with, in the same
|
||||
/// order, refreshed alongside it. Not in the vertex buffer itself
|
||||
/// because it names a bind group, not shader data.
|
||||
image_tex_indices: Vec<u32>,
|
||||
}
|
||||
|
||||
@@ -163,6 +175,8 @@ impl UiRenderNode {
|
||||
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_group, &[]);
|
||||
// Group 1 is global now, so it is set here rather than per layer.
|
||||
pass.set_bind_group(1, &self.primitive_group, &[]);
|
||||
// Set once, not per layer or per image: masks/move_offsets are read
|
||||
// by every primitive and every standalone image alike, and living
|
||||
// in their own group (rather than folded into group 2 alongside the
|
||||
@@ -172,14 +186,13 @@ impl UiRenderNode {
|
||||
pass.set_bind_group(3, &self.masks_group, &[]);
|
||||
for i in &self.active {
|
||||
let layer = &self.layers[i];
|
||||
if layer.instance.len() == 0 && layer.image_instance.len() == 0 {
|
||||
if layer.order.len() == 0 && layer.images.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
pass.set_bind_group(1, &layer.primitive_group, &[]);
|
||||
if layer.instance.len() > 0 {
|
||||
if layer.order.len() > 0 {
|
||||
pass.set_bind_group(2, &self.rsc_group, &[]);
|
||||
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
|
||||
pass.draw(0..4, 0..layer.instance.len() as u32);
|
||||
pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
|
||||
pass.draw(0..4, 0..layer.order.len() as u32);
|
||||
}
|
||||
// Images draw after this layer's rects and glyphs, one draw call
|
||||
// each with its own bind group. That draws every image "on top"
|
||||
@@ -188,8 +201,8 @@ impl UiRenderNode {
|
||||
// draw order was already undefined before images had their own
|
||||
// list -- nothing before this relied on interleaving a rect
|
||||
// between two images at a particular position.
|
||||
if layer.image_instance.len() > 0 {
|
||||
pass.set_vertex_buffer(0, layer.image_instance.buffer.slice(..));
|
||||
if layer.images.len() > 0 {
|
||||
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
|
||||
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
|
||||
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
|
||||
pass.draw(0..4, k as u32..k as u32 + 1);
|
||||
@@ -206,67 +219,45 @@ impl UiRenderNode {
|
||||
ui_render: &mut UiRenderState,
|
||||
) -> FrameUpdateStats {
|
||||
self.active.clear();
|
||||
for (i, primitives) in ui_render.layers.iter_mut() {
|
||||
for (i, order) in ui_render.layers.iter_mut() {
|
||||
self.active.push(i);
|
||||
for change in primitives.apply_free() {
|
||||
if let Some(inst) = ui_render.active.get_mut(&change.id) {
|
||||
for h in &mut inst.primitives {
|
||||
// `is_image` disambiguates: `instances` and `images`
|
||||
// are separate lists with independent indices, so
|
||||
// without it a rect's renumbering could be applied to
|
||||
// an image handle that happened to share the same
|
||||
// (layer, inst_idx).
|
||||
if h.layer == i
|
||||
&& h.inst_idx == change.old
|
||||
&& (h.binding == IMAGE_BINDING) == change.is_image
|
||||
{
|
||||
h.inst_idx = change.new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rlayer = self.layers.entry(i).or_insert_with(|| {
|
||||
let primitives = PrimitiveBuffers::new(device);
|
||||
let primitive_group =
|
||||
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
|
||||
RenderLayer {
|
||||
instance: ArrBuf::new(
|
||||
device,
|
||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||
"instance",
|
||||
),
|
||||
primitives,
|
||||
primitive_group,
|
||||
image_instance: ArrBuf::new(
|
||||
device,
|
||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||
"image instance",
|
||||
),
|
||||
image_tex_indices: Vec::new(),
|
||||
}
|
||||
});
|
||||
if primitives.updated {
|
||||
rlayer
|
||||
.instance
|
||||
.update(device, queue, primitives.instances());
|
||||
rlayer.primitives.update(device, queue, primitives.data());
|
||||
rlayer.primitive_group = Self::primitive_group(
|
||||
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
|
||||
order: ArrBuf::new(
|
||||
device,
|
||||
&self.primitive_layout,
|
||||
rlayer.primitives.buffers(),
|
||||
);
|
||||
rlayer
|
||||
.image_instance
|
||||
.update(device, queue, primitives.image_instances());
|
||||
rlayer.image_tex_indices = primitives
|
||||
.image_instances()
|
||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||
"layer order",
|
||||
),
|
||||
images: ArrBuf::new(
|
||||
device,
|
||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||
"layer image order",
|
||||
),
|
||||
image_tex_indices: Vec::new(),
|
||||
});
|
||||
if order.updated {
|
||||
rlayer.order.update(device, queue, order.order());
|
||||
rlayer.images.update(device, queue, order.images());
|
||||
rlayer.image_tex_indices = order
|
||||
.images()
|
||||
.iter()
|
||||
.map(|inst| inst.idx)
|
||||
.map(|&slot| ui_render.primitives.instance(slot).idx)
|
||||
.collect();
|
||||
primitives.updated = false;
|
||||
order.updated = false;
|
||||
}
|
||||
}
|
||||
let instances_resized = if ui_render.primitives.updated {
|
||||
ui_render.primitives.updated = false;
|
||||
let resized = self
|
||||
.instances
|
||||
.update(device, queue, ui_render.primitives.instances());
|
||||
self.primitives
|
||||
.update(device, queue, ui_render.primitives.data());
|
||||
self.primitive_group =
|
||||
Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers());
|
||||
resized
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let masks_resized = if ui.masks.changed {
|
||||
ui.masks.changed = false;
|
||||
self.masks.update(device, queue, &ui.masks[..])
|
||||
@@ -280,9 +271,14 @@ impl UiRenderNode {
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if masks_resized || moves_resized {
|
||||
self.masks_group =
|
||||
Self::masks_group(device, &self.masks_layout, &self.masks, &self.move_offsets);
|
||||
if masks_resized || moves_resized || instances_resized {
|
||||
self.masks_group = Self::masks_group(
|
||||
device,
|
||||
&self.masks_layout,
|
||||
&self.masks,
|
||||
&self.move_offsets,
|
||||
&self.instances,
|
||||
);
|
||||
}
|
||||
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
|
||||
if rebuild_main {
|
||||
@@ -408,6 +404,14 @@ impl UiRenderNode {
|
||||
});
|
||||
|
||||
let tex_manager = GpuTextures::new(device, queue);
|
||||
let primitives = PrimitiveBuffers::new(device);
|
||||
let primitive_group =
|
||||
Self::primitive_group(device, &primitive_layout, primitives.buffers());
|
||||
let instances = ArrBuf::new(
|
||||
device,
|
||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||
"ui instances",
|
||||
);
|
||||
let masks = ArrBuf::new(
|
||||
device,
|
||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||
@@ -422,7 +426,8 @@ impl UiRenderNode {
|
||||
let rsc_layout = Self::rsc_layout(device);
|
||||
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
|
||||
let masks_layout = Self::masks_layout(device);
|
||||
let masks_group = Self::masks_group(device, &masks_layout, &masks, &move_offsets);
|
||||
let masks_group =
|
||||
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
||||
label: Some("UI Shape Pipeline Layout"),
|
||||
@@ -440,7 +445,7 @@ impl UiRenderNode {
|
||||
vertex: VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[PrimitiveInstance::desc()],
|
||||
buffers: &[instance_slot_layout()],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(FragmentState {
|
||||
@@ -486,6 +491,8 @@ impl UiRenderNode {
|
||||
Ok(Self {
|
||||
uniform_group,
|
||||
primitive_layout,
|
||||
primitives,
|
||||
primitive_group,
|
||||
rsc_layout,
|
||||
rsc_group,
|
||||
pipeline,
|
||||
@@ -493,6 +500,7 @@ impl UiRenderNode {
|
||||
layers: HashMap::default(),
|
||||
active: Vec::new(),
|
||||
textures: tex_manager,
|
||||
instances,
|
||||
masks,
|
||||
move_offsets,
|
||||
masks_layout,
|
||||
@@ -627,6 +635,16 @@ impl UiRenderNode {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
|
||||
ty: BindingType::Buffer {
|
||||
ty: BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
label: Some("ui masks"),
|
||||
})
|
||||
@@ -637,6 +655,7 @@ impl UiRenderNode {
|
||||
layout: &BindGroupLayout,
|
||||
masks: &ArrBuf<Mask>,
|
||||
move_offsets: &ArrBuf<MoveOffset>,
|
||||
instances: &ArrBuf<PrimitiveInstance>,
|
||||
) -> BindGroup {
|
||||
device.create_bind_group(&BindGroupDescriptor {
|
||||
layout,
|
||||
@@ -649,6 +668,10 @@ impl UiRenderNode {
|
||||
binding: 1,
|
||||
resource: move_offsets.buffer.as_entire_binding(),
|
||||
},
|
||||
BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: instances.buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
label: Some("ui masks"),
|
||||
})
|
||||
|
||||
+258
-191
@@ -11,46 +11,11 @@ use crate::{
|
||||
use bytemuck::Pod;
|
||||
use wgpu::*;
|
||||
|
||||
pub struct Primitives {
|
||||
instances: Vec<PrimitiveInstance>,
|
||||
assoc: Vec<WidgetId>,
|
||||
data: PrimitiveData,
|
||||
free: Vec<usize>,
|
||||
|
||||
/// Standalone images, kept apart from `instances` because each one draws
|
||||
/// with its own bind group rather than sharing the layer's one instanced
|
||||
/// draw -- see TEXTURES.md's "Recommended shape". `idx` on each
|
||||
/// `PrimitiveInstance` here is the texture's slot in `Textures`/
|
||||
/// `GpuTextures`, not an index into `data`; there is no per-image entry
|
||||
/// in `data` because a bind group already picks the texture; nothing
|
||||
/// left to look up per-instance.
|
||||
images: Vec<PrimitiveInstance>,
|
||||
image_assoc: Vec<WidgetId>,
|
||||
image_free: Vec<usize>,
|
||||
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
impl Default for Primitives {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
instances: Default::default(),
|
||||
assoc: Default::default(),
|
||||
data: Default::default(),
|
||||
free: Vec::new(),
|
||||
images: Default::default(),
|
||||
image_assoc: Default::default(),
|
||||
image_free: Vec::new(),
|
||||
updated: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
|
||||
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
|
||||
/// one from -- a bind group already selects the texture -- so this only ever
|
||||
/// has to match the shader's `TEXTURE` constant and flag "this instance lives
|
||||
/// in `Primitives::images`, not `Primitives::instances`" to the code below.
|
||||
/// has to match the shader's `TEXTURE` constant and flag "this instance is
|
||||
/// drawn with its own bind group" to the code below.
|
||||
pub const IMAGE_BINDING: u32 = 1;
|
||||
|
||||
pub trait Primitive: Pod {
|
||||
@@ -134,18 +99,61 @@ macro_rules! primitives {
|
||||
(@count $t:tt) => { 1 };
|
||||
}
|
||||
|
||||
pub struct PrimitiveInst<P> {
|
||||
pub id: WidgetId,
|
||||
pub primitive: P,
|
||||
pub region: UiRegion,
|
||||
pub mask_idx: MaskIdx,
|
||||
pub move_idx: MoveIdx,
|
||||
/// Every primitive instance in the tree, in one arena that all layers
|
||||
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
|
||||
///
|
||||
/// **Why one arena rather than one per layer**, which is what this was:
|
||||
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
|
||||
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
|
||||
/// routinely in a different layer from the content it clips -- a rounded
|
||||
/// container in one layer, a `Stack`'s child content in the layer below.
|
||||
/// A per-layer buffer cannot answer that lookup at all: only one layer's
|
||||
/// group is bound at a time, so the mask would silently read another
|
||||
/// layer's rect. Both buffers are therefore global and bound once per
|
||||
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
|
||||
///
|
||||
/// Slots are stable for a primitive's whole life: nothing here is
|
||||
/// compacted, so a `Mask` can hold a slot across frames.
|
||||
pub struct Primitives {
|
||||
instances: Vec<PrimitiveInstance>,
|
||||
assoc: Vec<WidgetId>,
|
||||
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
|
||||
/// reusable yet: the layer that drew one still names it in its draw
|
||||
/// order until that call compacts the order, so handing it out again
|
||||
/// first would draw the new primitive twice -- once through the stale
|
||||
/// order entry and once through the new one.
|
||||
freed: Vec<usize>,
|
||||
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
|
||||
/// hands out.
|
||||
reusable: Vec<usize>,
|
||||
data: PrimitiveData,
|
||||
/// Whether the instance arena or the per-primitive data changed since
|
||||
/// the last upload -- one flag for both, since they are uploaded
|
||||
/// together.
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
impl Default for Primitives {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
instances: Default::default(),
|
||||
assoc: Default::default(),
|
||||
freed: Vec::new(),
|
||||
reusable: Vec::new(),
|
||||
data: Default::default(),
|
||||
updated: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Primitives {
|
||||
pub fn write<P: Primitive>(
|
||||
/// Writes a primitive into the arena and hands back its slot and its
|
||||
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
|
||||
/// the slot into a layer's draw order -- an instance that no layer
|
||||
/// names is never rasterized, which is what a mask shape drawn only to
|
||||
/// be *referenced* uses.
|
||||
pub fn alloc<P: Primitive>(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
PrimitiveInst {
|
||||
id,
|
||||
primitive,
|
||||
@@ -153,154 +161,118 @@ impl Primitives {
|
||||
mask_idx,
|
||||
move_idx,
|
||||
}: PrimitiveInst<P>,
|
||||
) -> PrimitiveHandle {
|
||||
self.updated = true;
|
||||
let vec = P::vec(&mut self.data);
|
||||
let i = vec.add(primitive);
|
||||
let inst = PrimitiveInstance {
|
||||
region,
|
||||
idx: i as u32,
|
||||
mask_idx,
|
||||
move_idx,
|
||||
binding: P::BINDING,
|
||||
};
|
||||
let inst_i = if let Some(i) = self.free.pop() {
|
||||
self.instances[i] = inst;
|
||||
self.assoc[i] = id;
|
||||
i
|
||||
} else {
|
||||
let i = self.instances.len();
|
||||
self.instances.push(inst);
|
||||
self.assoc.push(id);
|
||||
i
|
||||
};
|
||||
PrimitiveHandle::new::<P>(layer, inst_i, i)
|
||||
) -> (u32, usize) {
|
||||
let data_idx = P::vec(&mut self.data).add(primitive);
|
||||
let slot = self.push(
|
||||
PrimitiveInstance {
|
||||
region,
|
||||
idx: data_idx as u32,
|
||||
mask_idx,
|
||||
move_idx,
|
||||
binding: P::BINDING,
|
||||
},
|
||||
id,
|
||||
);
|
||||
(slot, data_idx)
|
||||
}
|
||||
|
||||
/// Writes an image instance directly -- there is no `Primitive` impl for
|
||||
/// it to go through `write`, since it has nowhere in `PrimitiveData` to
|
||||
/// put a per-instance entry. `texture_idx` is the slot the bind group at
|
||||
/// draw time is chosen from, carried in the otherwise-unused `idx` field.
|
||||
pub fn write_image(
|
||||
/// A standalone image, which has no `PrimitiveData` entry to allocate
|
||||
/// -- its bind group already picks the texture, so `texture_idx` rides
|
||||
/// in the otherwise-unused `idx` field and names the bind group the
|
||||
/// draw call selects.
|
||||
pub fn alloc_image(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
id: WidgetId,
|
||||
texture_idx: u32,
|
||||
region: UiRegion,
|
||||
mask_idx: MaskIdx,
|
||||
move_idx: MoveIdx,
|
||||
) -> PrimitiveHandle {
|
||||
) -> u32 {
|
||||
self.push(
|
||||
PrimitiveInstance {
|
||||
region,
|
||||
idx: texture_idx,
|
||||
mask_idx,
|
||||
move_idx,
|
||||
binding: IMAGE_BINDING,
|
||||
},
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
|
||||
self.updated = true;
|
||||
let inst = PrimitiveInstance {
|
||||
region,
|
||||
idx: texture_idx,
|
||||
mask_idx,
|
||||
move_idx,
|
||||
binding: IMAGE_BINDING,
|
||||
};
|
||||
let inst_i = if let Some(i) = self.image_free.pop() {
|
||||
self.images[i] = inst;
|
||||
self.image_assoc[i] = id;
|
||||
let slot = if let Some(i) = self.reusable.pop() {
|
||||
self.instances[i] = inst;
|
||||
self.assoc[i] = id;
|
||||
i
|
||||
} else {
|
||||
let i = self.images.len();
|
||||
self.images.push(inst);
|
||||
self.image_assoc.push(id);
|
||||
i
|
||||
self.instances.push(inst);
|
||||
self.assoc.push(id);
|
||||
self.instances.len() - 1
|
||||
};
|
||||
PrimitiveHandle {
|
||||
layer,
|
||||
inst_idx: inst_i,
|
||||
data_idx: 0,
|
||||
binding: IMAGE_BINDING,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image_instances(&self) -> &Vec<PrimitiveInstance> {
|
||||
&self.images
|
||||
}
|
||||
|
||||
/// returns (old index, new index) for both lists this layer keeps --
|
||||
/// `PrimitiveChange::is_image` says which, since the two have separate
|
||||
/// index spaces and `old`/`new` alone would collide between them.
|
||||
///
|
||||
/// Both lists free with `swap_remove`, so a layer's draw order was
|
||||
/// already undefined before images existed: nothing here may assume one
|
||||
/// primitive stays adjacent to another once anything in the layer has
|
||||
/// been freed.
|
||||
pub fn apply_free(&mut self) -> Vec<PrimitiveChange> {
|
||||
let mut changes =
|
||||
Self::apply_free_list(&mut self.free, &mut self.instances, &mut self.assoc, false);
|
||||
changes.extend(Self::apply_free_list(
|
||||
&mut self.image_free,
|
||||
&mut self.images,
|
||||
&mut self.image_assoc,
|
||||
true,
|
||||
));
|
||||
changes
|
||||
}
|
||||
|
||||
fn apply_free_list(
|
||||
free: &mut Vec<usize>,
|
||||
instances: &mut Vec<PrimitiveInstance>,
|
||||
assoc: &mut Vec<WidgetId>,
|
||||
is_image: bool,
|
||||
) -> Vec<PrimitiveChange> {
|
||||
free.sort_by(|a, b| b.cmp(a));
|
||||
free.drain(..)
|
||||
.filter_map(|i| {
|
||||
instances.swap_remove(i);
|
||||
assoc.swap_remove(i);
|
||||
if i == instances.len() {
|
||||
return None;
|
||||
}
|
||||
let id = assoc[i];
|
||||
let old = instances.len();
|
||||
Some(PrimitiveChange {
|
||||
id,
|
||||
is_image,
|
||||
old,
|
||||
new: i,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
slot as u32
|
||||
}
|
||||
|
||||
/// Retires a slot, answering the mask it was drawn under so the caller
|
||||
/// can drop that mask's ref. The slot itself only becomes reusable at
|
||||
/// the next [`Self::apply_free`] -- see `freed`.
|
||||
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
||||
self.updated = true;
|
||||
if h.binding == IMAGE_BINDING {
|
||||
self.image_free.push(h.inst_idx);
|
||||
self.images[h.inst_idx].mask_idx
|
||||
} else {
|
||||
let slot = h.slot as usize;
|
||||
if h.binding != IMAGE_BINDING {
|
||||
self.data.free(h.binding, h.data_idx);
|
||||
self.free.push(h.inst_idx);
|
||||
self.instances[h.inst_idx].mask_idx
|
||||
}
|
||||
self.freed.push(slot);
|
||||
self.instances[slot].mask_idx
|
||||
}
|
||||
|
||||
/// How many instances are still bound for the GPU -- the O(1) half of
|
||||
/// the orphan check, so the O(primitives) walk below only runs on a
|
||||
/// frame that already looks wrong. See
|
||||
/// Hands this frame's freed slots back for reuse. Called once per
|
||||
/// frame from `UiRenderState::update`, **after** every layer has
|
||||
/// compacted its draw order, since that order is the only thing still
|
||||
/// naming them.
|
||||
pub fn release_freed(&mut self) {
|
||||
self.reusable.append(&mut self.freed);
|
||||
}
|
||||
|
||||
/// Which widget drew the primitive in `slot` -- how a draw-order
|
||||
/// change finds the handle it has to renumber.
|
||||
pub fn owner(&self, slot: u32) -> WidgetId {
|
||||
self.assoc[slot as usize]
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.updated = true;
|
||||
self.instances.clear();
|
||||
self.assoc.clear();
|
||||
self.freed.clear();
|
||||
self.reusable.clear();
|
||||
self.data.clear();
|
||||
}
|
||||
|
||||
/// How many instances are still live -- the O(1) half of the orphan
|
||||
/// check, so the O(primitives) walk below only runs on a frame that
|
||||
/// already looks wrong. See
|
||||
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||
pub fn live_count(&self) -> usize {
|
||||
(self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len())
|
||||
self.instances.len() - self.freed.len() - self.reusable.len()
|
||||
}
|
||||
|
||||
/// Every instance that is still bound for the GPU, as `(inst_idx,
|
||||
/// owner, is_image)` -- everything except the slots already handed to
|
||||
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
||||
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
|
||||
/// to check that every drawn primitive still belongs to a live widget.
|
||||
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
|
||||
let free: HashSet<usize> = self.free.iter().copied().collect();
|
||||
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
|
||||
let rects = (0..self.instances.len())
|
||||
.filter(move |i| !free.contains(i))
|
||||
.map(|i| (i, self.assoc[i], false));
|
||||
let images = (0..self.images.len())
|
||||
.filter(move |i| !image_free.contains(i))
|
||||
.map(|i| (i, self.image_assoc[i], true));
|
||||
rects.chain(images)
|
||||
/// Every live instance as `(slot, owner, is_image)` -- everything
|
||||
/// except the freed and the reusable. Only
|
||||
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
|
||||
/// that every live primitive still belongs to a live widget.
|
||||
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
|
||||
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
|
||||
(0..self.instances.len())
|
||||
.filter(move |i| !dead.contains(i))
|
||||
.map(|i| {
|
||||
(
|
||||
i as u32,
|
||||
self.assoc[i],
|
||||
self.instances[i].binding == IMAGE_BINDING,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &PrimitiveData {
|
||||
@@ -311,46 +283,141 @@ impl Primitives {
|
||||
&self.instances
|
||||
}
|
||||
|
||||
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
|
||||
&self.instances[slot as usize]
|
||||
}
|
||||
|
||||
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
||||
self.updated = true;
|
||||
if h.binding == IMAGE_BINDING {
|
||||
&mut self.images[h.inst_idx].region
|
||||
} else {
|
||||
&mut self.instances[h.inst_idx].region
|
||||
}
|
||||
&mut self.instances[h.slot as usize].region
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrimitiveChange {
|
||||
pub id: WidgetId,
|
||||
/// Which of `Primitives::instances`/`Primitives::images` this change
|
||||
/// belongs to -- their `old`/`new` indices are independent, so a
|
||||
/// consumer matching only on `(layer, inst_idx)` could apply an image's
|
||||
/// renumbering to a rect's handle that happens to share the same index.
|
||||
pub is_image: bool,
|
||||
pub old: usize,
|
||||
pub new: usize,
|
||||
/// One layer's draw order: the slots of the global arena it draws, in the
|
||||
/// order they were written. The vertex buffer of a layer is exactly this.
|
||||
///
|
||||
/// Both lists free with `swap_remove`, so a layer's draw order was already
|
||||
/// undefined before this split: nothing here may assume one primitive
|
||||
/// stays adjacent to another once anything in the layer has been freed.
|
||||
#[derive(Default)]
|
||||
pub struct LayerOrder {
|
||||
order: Vec<u32>,
|
||||
/// Standalone images, kept apart because each draws with its own bind
|
||||
/// group rather than sharing the layer's one instanced draw -- see
|
||||
/// `UiRenderNode::draw`.
|
||||
images: Vec<u32>,
|
||||
free: Vec<usize>,
|
||||
image_free: Vec<usize>,
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
impl LayerOrder {
|
||||
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
|
||||
self.updated = true;
|
||||
let list = if is_image {
|
||||
&mut self.images
|
||||
} else {
|
||||
&mut self.order
|
||||
};
|
||||
list.push(slot);
|
||||
list.len() - 1
|
||||
}
|
||||
|
||||
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
|
||||
/// the arena's own, so that a position is only renumbered once per
|
||||
/// frame however many were dropped.
|
||||
pub fn free(&mut self, pos: usize, is_image: bool) {
|
||||
self.updated = true;
|
||||
if is_image {
|
||||
self.image_free.push(pos);
|
||||
} else {
|
||||
self.free.push(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compacts both lists, answering every primitive whose position
|
||||
/// moved so its handle can be corrected.
|
||||
pub fn apply_free(&mut self) -> Vec<OrderChange> {
|
||||
let mut changes = Self::apply_free_list(&mut self.free, &mut self.order, false);
|
||||
changes.extend(Self::apply_free_list(
|
||||
&mut self.image_free,
|
||||
&mut self.images,
|
||||
true,
|
||||
));
|
||||
changes
|
||||
}
|
||||
|
||||
fn apply_free_list(
|
||||
free: &mut Vec<usize>,
|
||||
list: &mut Vec<u32>,
|
||||
is_image: bool,
|
||||
) -> Vec<OrderChange> {
|
||||
// Descending, so removing a contiguous tail costs no renumbering
|
||||
// at all -- which is what freeing one widget's primitives is.
|
||||
free.sort_by(|a, b| b.cmp(a));
|
||||
free.drain(..)
|
||||
.filter_map(|pos| {
|
||||
list.swap_remove(pos);
|
||||
if pos == list.len() {
|
||||
return None;
|
||||
}
|
||||
Some(OrderChange {
|
||||
slot: list[pos],
|
||||
is_image,
|
||||
pos,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn order(&self) -> &Vec<u32> {
|
||||
&self.order
|
||||
}
|
||||
|
||||
pub fn images(&self) -> &Vec<u32> {
|
||||
&self.images
|
||||
}
|
||||
}
|
||||
|
||||
/// A primitive whose position in a layer's draw order moved when
|
||||
/// something before it was freed -- `slot` names which primitive, so its
|
||||
/// owner's handle can be found and pointed at `pos`.
|
||||
pub struct OrderChange {
|
||||
pub slot: u32,
|
||||
/// Which of the layer's two lists moved: their positions are
|
||||
/// independent index spaces, so a handle matching on position alone
|
||||
/// could take an image's renumbering for a rect's.
|
||||
pub is_image: bool,
|
||||
pub pos: usize,
|
||||
}
|
||||
|
||||
/// Where one primitive lives: its stable slot in the global arena, and
|
||||
/// where in a layer's draw order it currently sits. A handle with no
|
||||
/// layer position (`pos == NOT_DRAWN`) is a primitive that exists to be
|
||||
/// *referenced* -- a mask's shape -- and is never rasterized.
|
||||
#[derive(Debug)]
|
||||
pub struct PrimitiveHandle {
|
||||
pub layer: usize,
|
||||
pub inst_idx: usize,
|
||||
pub pos: usize,
|
||||
pub slot: u32,
|
||||
pub data_idx: usize,
|
||||
pub binding: u32,
|
||||
}
|
||||
|
||||
impl PrimitiveHandle {
|
||||
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
|
||||
Self {
|
||||
layer,
|
||||
inst_idx,
|
||||
data_idx,
|
||||
binding: P::BINDING,
|
||||
}
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.binding == IMAGE_BINDING
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrimitiveInst<P> {
|
||||
pub id: WidgetId,
|
||||
pub primitive: P,
|
||||
pub region: UiRegion,
|
||||
pub mask_idx: MaskIdx,
|
||||
pub move_idx: MoveIdx,
|
||||
}
|
||||
|
||||
primitives!(
|
||||
rects: RectPrimitive => 0,
|
||||
glyphs: GlyphPrimitive => 2,
|
||||
|
||||
@@ -34,6 +34,10 @@ struct Mask {
|
||||
x: UiSpan,
|
||||
y: UiSpan,
|
||||
move_idx: u32,
|
||||
/// The mask this one is nested inside, or `4294967295u`. Mirrors
|
||||
/// `Mask::parent` in data.rs; walked below with the same bound the
|
||||
/// move chain uses.
|
||||
parent: u32,
|
||||
}
|
||||
|
||||
/// One widget's cumulative on-screen translation and the slot of the
|
||||
@@ -79,8 +83,15 @@ var samp: sampler;
|
||||
var<storage> masks: array<Mask>;
|
||||
@group(3) @binding(1)
|
||||
var<storage> move_offsets: array<MoveOffset>;
|
||||
// Every primitive's placement, in one arena all layers share. The vertex
|
||||
// stage reads the primitive it is drawing (its slot arrives as the only
|
||||
// vertex attribute); the fragment stage reads a *mask's* primitive, which
|
||||
// is generally a different one in a different layer. See LAYOUT.md's
|
||||
// "Masks with a shape" and `Primitives` in primitive.rs.
|
||||
@group(3) @binding(2)
|
||||
var<storage> instances: array<PrimitiveInstance>;
|
||||
|
||||
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
|
||||
// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in
|
||||
// render_state.rs, which walks the identical chain on the CPU side for
|
||||
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||
@@ -90,7 +101,7 @@ var<storage> move_offsets: array<MoveOffset>;
|
||||
// Past the bound both walks simply stop summing, so the widget draws and
|
||||
// hit-tests short by whatever the outer slots held, with nothing on
|
||||
// screen to say so.
|
||||
const MOVE_CHAIN_LIMIT: u32 = 64u;
|
||||
const PARENT_CHAIN_LIMIT: u32 = 64u;
|
||||
|
||||
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||
@@ -98,7 +109,7 @@ const MOVE_CHAIN_LIMIT: u32 = 64u;
|
||||
fn resolve_move(idx: u32) -> vec2<f32> {
|
||||
var total = vec2<f32>(0.0, 0.0);
|
||||
var i = idx;
|
||||
for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) {
|
||||
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||
let entry = move_offsets[i];
|
||||
total += entry.delta;
|
||||
if entry.parent == 4294967295u {
|
||||
@@ -113,15 +124,20 @@ struct WindowUniform {
|
||||
dim: vec2<f32>,
|
||||
};
|
||||
|
||||
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
|
||||
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
|
||||
struct PrimitiveInstance {
|
||||
x: UiSpan,
|
||||
y: UiSpan,
|
||||
binding: u32,
|
||||
idx: u32,
|
||||
mask_idx: u32,
|
||||
move_idx: u32,
|
||||
}
|
||||
|
||||
/// A layer's draw order: one slot into `instances` per instance drawn.
|
||||
struct InstanceInput {
|
||||
@location(0) x_start: vec2<f32>,
|
||||
@location(1) x_end: vec2<f32>,
|
||||
@location(2) y_start: vec2<f32>,
|
||||
@location(3) y_end: vec2<f32>,
|
||||
@location(4) binding: u32,
|
||||
@location(5) idx: u32,
|
||||
@location(6) mask_idx: u32,
|
||||
@location(7) move_idx: u32,
|
||||
@location(0) slot: u32,
|
||||
}
|
||||
|
||||
struct VertexOutput {
|
||||
@@ -147,13 +163,14 @@ fn vs_main(
|
||||
in: InstanceInput,
|
||||
) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
let inst = instances[in.slot];
|
||||
|
||||
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
|
||||
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
|
||||
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
|
||||
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
|
||||
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
|
||||
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
|
||||
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
|
||||
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
|
||||
|
||||
let move_delta = resolve_move(in.move_idx);
|
||||
let move_delta = resolve_move(inst.move_idx);
|
||||
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta;
|
||||
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta;
|
||||
let size = bot_right - top_left;
|
||||
@@ -165,11 +182,11 @@ fn vs_main(
|
||||
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
|
||||
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
|
||||
out.uv = uv;
|
||||
out.binding = in.binding;
|
||||
out.idx = in.idx;
|
||||
out.binding = inst.binding;
|
||||
out.idx = inst.idx;
|
||||
out.top_left = top_left;
|
||||
out.bot_right = bot_right;
|
||||
out.mask_idx = in.mask_idx;
|
||||
out.mask_idx = inst.mask_idx;
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -196,8 +213,15 @@ fn fs_main(
|
||||
color = vec4(1.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
if in.mask_idx != 4294967295u {
|
||||
let mask = masks[in.mask_idx];
|
||||
// Every mask on the chain, not just the innermost: a widget that set
|
||||
// its own mask inside another is clipped by both, and each carries its
|
||||
// own move slot (`Mask::parent` in data.rs).
|
||||
var mask_idx = in.mask_idx;
|
||||
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||
if mask_idx == 4294967295u {
|
||||
break;
|
||||
}
|
||||
let mask = masks[mask_idx];
|
||||
let mask_delta = resolve_move(mask.move_idx);
|
||||
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
|
||||
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
|
||||
@@ -207,6 +231,7 @@ fn fs_main(
|
||||
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
|
||||
color *= 0.0;
|
||||
}
|
||||
mask_idx = mask.parent;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ pub struct Painter<'a> {
|
||||
|
||||
impl<'a> Painter<'a> {
|
||||
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
||||
let h = self.state.layers.write(
|
||||
let h = self.state.write_primitive(
|
||||
self.layer,
|
||||
PrimitiveInst {
|
||||
id: self.id,
|
||||
@@ -53,8 +53,11 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
|
||||
/// Clip everything this widget draws, itself and its descendants, to
|
||||
/// `region`. One per widget: a second call would need the two to be
|
||||
/// intersected, which nothing here does.
|
||||
/// `region`. One call per widget; a widget drawn inside another
|
||||
/// widget's mask nests instead -- the new mask chains to the inherited
|
||||
/// one (`Mask::parent`) and the fragment stage requires a pixel to be
|
||||
/// inside both, which is what lets a transcript row's code fence clip
|
||||
/// to itself *and* to the list it scrolls inside.
|
||||
///
|
||||
/// The slot is allocated once and **rewritten in place** on every
|
||||
/// later draw rather than pushed again, because a descendant whose own
|
||||
@@ -62,24 +65,74 @@ impl<'a> Painter<'a> {
|
||||
/// so keeps pointing at whichever slot it was drawn under. See
|
||||
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||
pub fn set_mask(&mut self, region: UiRegion) {
|
||||
assert!(self.mask == MaskIdx::NONE);
|
||||
// `assert!`, not `debug_assert!`: one comparison per widget draw,
|
||||
// and the second call silently *replacing* the first is a widget
|
||||
// drawn unclipped -- which reaches the screen and nothing says so.
|
||||
// Every build anybody runs here is release
|
||||
// (docs/REVIEW-2026-09-07.md's R1).
|
||||
assert!(
|
||||
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
|
||||
"set_mask called twice while drawing one widget: the second would replace the first \
|
||||
rather than nest inside it",
|
||||
);
|
||||
let parent = self.mask;
|
||||
let mask = Mask {
|
||||
region,
|
||||
move_idx: self.move_slot,
|
||||
parent,
|
||||
};
|
||||
if self.own_mask == MaskIdx::NONE {
|
||||
let old_parent = if self.own_mask == MaskIdx::NONE {
|
||||
let slot = self.rsc.ui_mut().masks.push(mask);
|
||||
// The one ref this widget holds on its own slot, so the slot
|
||||
// outlives any single frame's primitives; released in
|
||||
// `UiRenderState::remove`'s `undraw` branch.
|
||||
self.rsc.ui_mut().masks.push_ref(slot);
|
||||
self.own_mask = slot;
|
||||
MaskIdx::NONE
|
||||
} else {
|
||||
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
|
||||
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||
old
|
||||
};
|
||||
// The chain link's own ref, taken before the old one is dropped so
|
||||
// that re-chaining to the same slot cannot free it in between.
|
||||
// Released here when the link changes, and in
|
||||
// `UiRenderState::remove` when this widget's slot goes.
|
||||
if old_parent != parent {
|
||||
if parent != MaskIdx::NONE {
|
||||
self.rsc.ui_mut().masks.push_ref(parent);
|
||||
}
|
||||
if old_parent != MaskIdx::NONE {
|
||||
self.rsc.ui_mut().masks.remove(old_parent);
|
||||
}
|
||||
}
|
||||
self.mask = self.own_mask;
|
||||
}
|
||||
|
||||
/// Ask for this widget to be drawn again on the next frame, from
|
||||
/// inside its own `draw` -- for a layout that can only discover a
|
||||
/// correction to itself by laying out once (`List::clamp_to_content`,
|
||||
/// which learns how far past its content the list is from the walk it
|
||||
/// has just done). The mark is the same one `Widgets::get_dyn_mut`
|
||||
/// sets, so `UiRenderState::update` picks it up exactly as it does any
|
||||
/// other dirty widget; it does **not** by itself ask the platform for
|
||||
/// a frame, which is the caller's own `RequestRedraw` handle.
|
||||
///
|
||||
/// The correction it asks for must converge, or this is a widget that
|
||||
/// redraws forever.
|
||||
pub fn draw_again(&mut self) {
|
||||
self.rsc.widgets_mut().needs_redraw.insert(self.id);
|
||||
}
|
||||
|
||||
/// Whether anything is clipping what this widget draws -- its own
|
||||
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
|
||||
/// a widget whose contents may legitimately extend past its own box
|
||||
/// (`iris::widget::List`, which draws a row straddling an edge in
|
||||
/// full) asserts before relying on being cut off there.
|
||||
pub fn is_masked(&self) -> bool {
|
||||
self.mask != MaskIdx::NONE
|
||||
}
|
||||
|
||||
/// Draws a widget within this widget's region, returning the size it
|
||||
/// reported using.
|
||||
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
|
||||
@@ -170,7 +223,7 @@ impl<'a> Painter<'a> {
|
||||
/// the layer's one instanced draw, so it goes through
|
||||
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
||||
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
||||
let h = self.state.layers.write_image(
|
||||
let h = self.state.write_image(
|
||||
self.layer,
|
||||
self.id,
|
||||
texture_idx,
|
||||
@@ -216,8 +269,10 @@ impl<'a> Painter<'a> {
|
||||
// A caller re-emitting quads placed against an atlas that has since
|
||||
// been cleared draws every glyph from coordinates now holding
|
||||
// something else. Caught at the submission rather than on screen,
|
||||
// where it reads as fragments of unrelated letters.
|
||||
debug_assert_eq!(
|
||||
// where it reads as fragments of unrelated letters. `assert_eq!`
|
||||
// for R1's reason: two integers per laid-out string, not per
|
||||
// glyph, and the failure is unreadable text on a release build.
|
||||
assert_eq!(
|
||||
text.generation,
|
||||
self.atlas_generation(),
|
||||
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{
|
||||
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
||||
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
||||
render::{IMAGE_BINDING, MoveOffset},
|
||||
render::{MoveOffset, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
|
||||
util::{HashMap, HashSet, Id, Vec2},
|
||||
};
|
||||
|
||||
/// What [`UiRenderState::update`] did on its last call -- read back by the
|
||||
/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris`
|
||||
/// crate) so a report can tell a full relayout from a frame that only
|
||||
/// redrew a handful of dirty widgets from one that drew nothing at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RedrawKind {
|
||||
/// Neither the root nor any widget changed -- `update` did nothing.
|
||||
None,
|
||||
/// [`UiRenderState::redraw_all`]: a new root, or a resize.
|
||||
All,
|
||||
/// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw`
|
||||
/// named.
|
||||
Updates,
|
||||
}
|
||||
|
||||
pub struct UiRenderState {
|
||||
pub active: HashMap<WidgetId, ActiveData>,
|
||||
/// Every primitive in the tree, in one arena -- see [`Primitives`] for
|
||||
/// why it is not per layer.
|
||||
pub primitives: Primitives,
|
||||
/// What each layer draws, in order: slots into `primitives`.
|
||||
pub layers: PrimitiveLayers,
|
||||
pub(super) output_size: Vec2,
|
||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||
@@ -58,6 +80,31 @@ pub struct UiRenderState {
|
||||
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
||||
/// which `TextView::render` only reaches on a cache miss.
|
||||
pub(super) shape_count: u64,
|
||||
|
||||
/// `Instant::now()` at construction -- the zero every `iris::frame` line
|
||||
/// dates itself from, so a report's `now=` is comparable to a harness's
|
||||
/// own `t_ms` (`Harness::new` builds its `base` the same way, in the
|
||||
/// same constructor call) without either side needing the wall clock.
|
||||
epoch: Instant,
|
||||
/// How many times [`Self::update`] has run -- the `iris::frame` line's
|
||||
/// frame number. Counts every call, including one that found nothing to
|
||||
/// redraw, so a gap in the sequence in a report is a frame this state
|
||||
/// was never asked to run at all (a stalled event loop), not one that
|
||||
/// ran and did nothing.
|
||||
frame_no: u64,
|
||||
/// How long the redraw phase of the last [`Self::update`] took --
|
||||
/// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or
|
||||
/// zero if neither did. Read back by `iris::diagnostics::log_frame`.
|
||||
last_layout: Duration,
|
||||
last_redraw_kind: RedrawKind,
|
||||
/// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris`
|
||||
/// crate) last saw an input sample, dated by the sample's own clock
|
||||
/// (`CursorState::time`) rather than when the dispatch ran -- same
|
||||
/// reasoning as that field's own doc. A `Mutex` rather than a
|
||||
/// `Cell` for the same reason `captured` is: `run_sensors` takes `&self`
|
||||
/// and this is the one render state both backends already share across
|
||||
/// frames.
|
||||
last_input_at: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||
@@ -70,12 +117,18 @@ pub struct UiRenderState {
|
||||
/// prints the chain). A chain past the bound is not reported anywhere at
|
||||
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||
/// tested short by whatever the outer slots held.
|
||||
pub const MOVE_CHAIN_LIMIT: usize = 64;
|
||||
///
|
||||
/// Named for the walk rather than for one of its two subjects: it bounds
|
||||
/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in
|
||||
/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first
|
||||
/// (docs/REVIEW-2026-09-07.md).
|
||||
pub const PARENT_CHAIN_LIMIT: usize = 64;
|
||||
|
||||
impl UiRenderState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: Default::default(),
|
||||
primitives: Default::default(),
|
||||
layers: Default::default(),
|
||||
output_size: Vec2::ZERO,
|
||||
density: 1.0,
|
||||
@@ -87,6 +140,11 @@ impl UiRenderState {
|
||||
region_mut_count: 0,
|
||||
mov_count: 0,
|
||||
shape_count: 0,
|
||||
epoch: Instant::now(),
|
||||
frame_no: 0,
|
||||
last_layout: Duration::ZERO,
|
||||
last_redraw_kind: RedrawKind::None,
|
||||
last_input_at: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +166,70 @@ impl UiRenderState {
|
||||
)
|
||||
}
|
||||
|
||||
/// Writes a primitive into the arena and into `layer`'s draw order.
|
||||
pub(super) fn write_primitive<P: Primitive>(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
inst: PrimitiveInst<P>,
|
||||
) -> PrimitiveHandle {
|
||||
let (slot, data_idx) = self.primitives.alloc(inst);
|
||||
let pos = self.layers[layer].push(slot, false);
|
||||
PrimitiveHandle {
|
||||
layer,
|
||||
pos,
|
||||
slot,
|
||||
data_idx,
|
||||
binding: P::BINDING,
|
||||
}
|
||||
}
|
||||
|
||||
/// A standalone image, which draws with its own bind group rather
|
||||
/// than sharing the layer's one instanced draw.
|
||||
pub(super) fn write_image(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
id: WidgetId,
|
||||
texture_idx: u32,
|
||||
region: UiRegion,
|
||||
mask_idx: MaskIdx,
|
||||
move_idx: MoveIdx,
|
||||
) -> PrimitiveHandle {
|
||||
let slot = self
|
||||
.primitives
|
||||
.alloc_image(id, texture_idx, region, mask_idx, move_idx);
|
||||
let pos = self.layers[layer].push(slot, true);
|
||||
PrimitiveHandle {
|
||||
layer,
|
||||
pos,
|
||||
slot,
|
||||
data_idx: 0,
|
||||
binding: crate::render::IMAGE_BINDING,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compacts every layer's draw order around the primitives freed
|
||||
/// this frame, corrects the handles that moved, and only then hands
|
||||
/// the arena slots back for reuse -- that order is the whole reason
|
||||
/// `Primitives::freed` exists. Once per frame, at the end of
|
||||
/// [`Self::update`], so the harness (which has no renderer) applies
|
||||
/// it exactly as a real backend does.
|
||||
fn apply_free(&mut self) {
|
||||
for (layer, order) in self.layers.iter_mut() {
|
||||
for change in order.apply_free() {
|
||||
let owner = self.primitives.owner(change.slot);
|
||||
if let Some(active) = self.active.get_mut(&owner) {
|
||||
for h in &mut active.primitives {
|
||||
if h.layer == layer && h.slot == change.slot {
|
||||
h.pos = change.pos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.primitives.release_freed();
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
||||
self.output_size = size.into();
|
||||
self.resized = true;
|
||||
@@ -119,7 +241,16 @@ impl UiRenderState {
|
||||
/// different triggers (a surface resize on every rotation or keyboard
|
||||
/// open; a density change only if the app follows the display to a
|
||||
/// different screen, which Android surfaces separately).
|
||||
///
|
||||
/// Marks the tree for a full redraw when the value actually changes:
|
||||
/// every `Len::dp` already resolved and every glyph already shaped
|
||||
/// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs
|
||||
/// to the old one, and nothing else would ask for them again
|
||||
/// (docs/REVIEW-2026-09-07.md's R5).
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
if density != self.density {
|
||||
self.resized = true;
|
||||
}
|
||||
self.density = density;
|
||||
}
|
||||
|
||||
@@ -150,17 +281,86 @@ impl UiRenderState {
|
||||
"a previous frame left {} widget(s) marked as mid-draw",
|
||||
self.draw_started.len(),
|
||||
);
|
||||
if self.needs_redraw_all(root) {
|
||||
// Timed unconditionally -- an `Instant::now()` pair is cheap enough
|
||||
// not to move the `--phone` bench's frame time (checked when this
|
||||
// was added), and gating it behind the trace toggle would leave
|
||||
// `iris::frame` with nothing to report the one frame somebody just
|
||||
// turned tracing on to look at.
|
||||
let layout_start = Instant::now();
|
||||
let kind = if self.needs_redraw_all(root) {
|
||||
self.redraw_all(root, rsc);
|
||||
self.old_root = root.map(|r| r.id());
|
||||
self.resized = false;
|
||||
RedrawKind::All
|
||||
} else if rsc.widgets().has_updates() {
|
||||
self.redraw_updates(rsc);
|
||||
}
|
||||
RedrawKind::Updates
|
||||
} else {
|
||||
RedrawKind::None
|
||||
};
|
||||
self.last_layout = layout_start.elapsed();
|
||||
self.last_redraw_kind = kind;
|
||||
self.frame_no += 1;
|
||||
// After the redraw and before anything reads the frame: every
|
||||
// slot freed above is still named by its layer's draw order until
|
||||
// this runs.
|
||||
self.apply_free();
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||
}
|
||||
|
||||
/// `Instant::now()` at construction -- see the field's own doc.
|
||||
pub fn epoch(&self) -> Instant {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// How many times [`Self::update`] has run, counting from 1.
|
||||
pub fn frame_number(&self) -> u64 {
|
||||
self.frame_no
|
||||
}
|
||||
|
||||
/// How long the last [`Self::update`]'s redraw phase took.
|
||||
pub fn last_layout_duration(&self) -> Duration {
|
||||
self.last_layout
|
||||
}
|
||||
|
||||
/// What the last [`Self::update`] did -- see [`RedrawKind`].
|
||||
pub fn last_redraw_kind(&self) -> RedrawKind {
|
||||
self.last_redraw_kind
|
||||
}
|
||||
|
||||
/// Records that a real input sample was just dispatched, dated by the
|
||||
/// sample's own clock -- called once per sensor pass, so `iris::frame`'s
|
||||
/// `since_input` can answer "how stale was the input
|
||||
/// this frame drew" instead of a caller guessing from the frame
|
||||
/// interval. `&self` because `run_sensors` only ever has that -- see
|
||||
/// `last_input_at`'s field doc.
|
||||
pub fn note_input(&self, at: Instant) {
|
||||
if let Ok(mut guard) = self.last_input_at.lock() {
|
||||
*guard = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// `now - ` the last input sample's own timestamp, or `None` if no
|
||||
/// input has ever reached this render state (a cold start, or a screen
|
||||
/// that only ever animates on its own). Saturates to zero rather than
|
||||
/// panicking if `now` is earlier than the input sample somehow was --
|
||||
/// a diagnostic reading wrong is not worth a crash over.
|
||||
pub fn time_since_input(&self, now: Instant) -> Option<Duration> {
|
||||
let at = *self.last_input_at.lock().ok()?;
|
||||
at.map(|at| now.saturating_duration_since(at))
|
||||
}
|
||||
|
||||
/// Primitive instances every currently-active widget owns, summed --
|
||||
/// what `iris::frame`'s `primitives=` reports. Not a per-frame delta:
|
||||
/// `redraw_updates` only rewrites what changed, so this is "how much is
|
||||
/// on screen", which is what a report reads as "did this frame have
|
||||
/// more to draw than the last one", not "how much work did this frame
|
||||
/// do" (`take_counters` answers that).
|
||||
pub fn active_primitive_count(&self) -> usize {
|
||||
self.active.values().map(|a| a.primitives.len()).sum()
|
||||
}
|
||||
|
||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||
self.clear(rsc);
|
||||
// free all resources & cache
|
||||
@@ -252,7 +452,7 @@ impl UiRenderState {
|
||||
// instead of redrawing. See LAYOUT.md section 3.
|
||||
let from = active.region;
|
||||
for h in &active.primitives {
|
||||
let r = self.layers[h.layer].region_mut(h);
|
||||
let r = self.primitives.region_mut(h);
|
||||
*r = r.outside(&from).within(®ion);
|
||||
self.region_mut_count += 1;
|
||||
}
|
||||
@@ -324,10 +524,9 @@ impl UiRenderState {
|
||||
// own new one -- and `ActiveData::mask`'s only consumer is
|
||||
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
||||
// the set one instead handed a `Masked` its own mask on every
|
||||
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
||||
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
||||
// first time the composer's scroll area was redrawn on the
|
||||
// emulator.
|
||||
// targeted redraw -- an abort the first time the composer's scroll
|
||||
// area was redrawn on the emulator, and now (masks nest) a mask
|
||||
// whose parent is itself, which `set_mask`'s own assert names.
|
||||
let inherited_mask = mask;
|
||||
let mut painter = Painter {
|
||||
state: self,
|
||||
@@ -490,7 +689,8 @@ impl UiRenderState {
|
||||
let mut active = self.active.remove(&id);
|
||||
if let Some(active) = &mut active {
|
||||
for h in &active.primitives {
|
||||
let mask = self.layers.free(h);
|
||||
let mask = self.primitives.free(h);
|
||||
self.layers[h.layer].free(h.pos, h.is_image());
|
||||
if mask != MaskIdx::NONE {
|
||||
rsc.ui_mut().masks.remove(mask);
|
||||
}
|
||||
@@ -514,8 +714,15 @@ impl UiRenderState {
|
||||
// section 2's lifecycle note).
|
||||
if active.own_mask != MaskIdx::NONE {
|
||||
// The self-ownership ref `Painter::set_mask` took when
|
||||
// it allocated this widget's own mask slot.
|
||||
// it allocated this widget's own mask slot, and the
|
||||
// chain link's ref on the mask this one nests inside
|
||||
// -- read from the arena entry, for the same reason
|
||||
// the move slot's parent is.
|
||||
let outer = rsc.ui().masks[active.own_mask.idx()].parent;
|
||||
rsc.ui_mut().masks.remove(active.own_mask);
|
||||
if outer != MaskIdx::NONE {
|
||||
rsc.ui_mut().masks.remove(outer);
|
||||
}
|
||||
}
|
||||
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
||||
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
||||
@@ -543,6 +750,7 @@ impl UiRenderState {
|
||||
rsc.on_undraw(&active);
|
||||
}
|
||||
self.layers.clear();
|
||||
self.primitives.clear();
|
||||
rsc.widgets_mut().needs_redraw.clear();
|
||||
rsc.free();
|
||||
}
|
||||
@@ -585,8 +793,9 @@ impl UiRenderState {
|
||||
/// Primitive instances still bound for the GPU whose owner is no
|
||||
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
||||
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
||||
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
|
||||
/// each.
|
||||
/// frame at whatever position it last had. `(slot, owner)` each --
|
||||
/// the arena knows which primitive, not which layer's draw order still
|
||||
/// names it.
|
||||
///
|
||||
/// Asserted empty at the end of every [`Self::update`], because this
|
||||
/// is exactly the shape of the duplicated transcript row on Iris's
|
||||
@@ -595,20 +804,15 @@ impl UiRenderState {
|
||||
/// much alive -- it is the *earlier* set of primitives that got
|
||||
/// stranded when the widget was drawn a second time without the first
|
||||
/// draw being freed. O(primitives), debug builds only.
|
||||
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
|
||||
pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> {
|
||||
let mut orphans = Vec::new();
|
||||
for (layer, primitives) in self.layers.iter() {
|
||||
for (inst_idx, owner, is_image) in primitives.live_instances() {
|
||||
let owned = self.active.get(&owner).is_some_and(|a| {
|
||||
a.primitives.iter().any(|h| {
|
||||
h.layer == layer
|
||||
&& h.inst_idx == inst_idx
|
||||
&& (h.binding == IMAGE_BINDING) == is_image
|
||||
})
|
||||
});
|
||||
if !owned {
|
||||
orphans.push((layer, inst_idx, owner));
|
||||
}
|
||||
for (slot, owner, _) in self.primitives.live_instances() {
|
||||
let owned = self
|
||||
.active
|
||||
.get(&owner)
|
||||
.is_some_and(|a| a.primitives.iter().any(|h| h.slot == slot));
|
||||
if !owned {
|
||||
orphans.push((slot, owner));
|
||||
}
|
||||
}
|
||||
orphans
|
||||
@@ -622,7 +826,7 @@ impl UiRenderState {
|
||||
/// transcript is tens of thousands and made a debug build on a phone
|
||||
/// too slow to finish a benchmark run.
|
||||
fn primitive_counts_agree(&self) -> bool {
|
||||
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
|
||||
let live: usize = self.primitives.live_count();
|
||||
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||
live == owned
|
||||
}
|
||||
@@ -636,10 +840,10 @@ impl UiRenderState {
|
||||
let mut lines: Vec<String> = orphans
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|(layer, idx, owner)| {
|
||||
.map(|(slot, owner)| {
|
||||
let alive = self.active.contains_key(owner);
|
||||
format!(
|
||||
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
|
||||
" instance {slot}: owner '{}' ({owner:?}), owner still active: {alive}",
|
||||
rsc.widgets().label(*owner),
|
||||
)
|
||||
})
|
||||
@@ -684,12 +888,12 @@ impl UiRenderState {
|
||||
}
|
||||
|
||||
pub fn debug_layers(&self) {
|
||||
for ((idx, depth), primitives) in self.layers.iter_depth() {
|
||||
for ((idx, depth), order) in self.layers.iter_depth() {
|
||||
let indent = " ".repeat(depth * 2);
|
||||
let len = primitives.instances().len();
|
||||
let len = order.order().len();
|
||||
print!("{indent}{idx}: {len} primitives");
|
||||
if len >= 1 {
|
||||
print!(" ({})", primitives.instances()[0].binding);
|
||||
print!(" ({})", self.primitives.instance(order.order()[0]).binding);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
@@ -713,13 +917,13 @@ impl UiRenderState {
|
||||
|
||||
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||
/// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||
/// about where the chain ends.
|
||||
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||
let offsets = &rsc.ui().move_offsets;
|
||||
let mut delta = Vec2::ZERO;
|
||||
let mut at = slot;
|
||||
for i in 0..MOVE_CHAIN_LIMIT {
|
||||
for i in 0..PARENT_CHAIN_LIMIT {
|
||||
let entry = &offsets[at.idx()];
|
||||
delta.x += entry.delta[0];
|
||||
delta.y += entry.delta[1];
|
||||
@@ -732,8 +936,9 @@ impl UiRenderState {
|
||||
// follow are different faults with different fixes, and the
|
||||
// slot numbers are the only thing that tells them apart.
|
||||
debug_assert!(
|
||||
i + 1 < MOVE_CHAIN_LIMIT,
|
||||
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
|
||||
i + 1 < PARENT_CHAIN_LIMIT,
|
||||
"move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \
|
||||
-- a \
|
||||
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
|
||||
nests deeper than shader.wgsl's own walk of the same bound",
|
||||
chain = Self::move_chain_debug(slot, offsets)
|
||||
@@ -743,13 +948,13 @@ impl UiRenderState {
|
||||
}
|
||||
|
||||
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||
/// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||
/// rather than as a chain that merely stops. Only ever called from the
|
||||
/// failed assertion above.
|
||||
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut at = slot;
|
||||
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
|
||||
for _ in 0..PARENT_CHAIN_LIMIT * 2 {
|
||||
let entry = &offsets[at.idx()];
|
||||
parts.push(format!(
|
||||
"{}({}, {})",
|
||||
|
||||
+10
-113
@@ -1,19 +1,13 @@
|
||||
//! Where the desktop app keeps the enrollment it should not have to be
|
||||
//! told about a second time: `client_core::config::EnrolledServer`,
|
||||
//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`,
|
||||
//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer
|
||||
//! token, and the reason `client_core::config`'s own doc comment leaves
|
||||
//! persistence and file mode to the caller.
|
||||
//! Where the desktop app keeps its enrollment: `client_core::config`'s
|
||||
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
|
||||
//!
|
||||
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
||||
//! rules (`format`) are for configs a person hand-edits, and this file
|
||||
//! never is one -- only this program ever writes or reads it, and
|
||||
//! `serde_json` is already in the dependency graph through `client-core`,
|
||||
//! so nothing new is added to reach for it.
|
||||
//! Only the directory is this app's -- the file's name, its JSON, and its
|
||||
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
|
||||
//! are the store's, shared with the Android client so the two cannot come
|
||||
//! to disagree about them.
|
||||
|
||||
use client_core::config::EnrolledServer;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use client_core::config::EnrollmentStore;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
|
||||
/// the XDG basedir spec says to when the variable is unset -- the same
|
||||
@@ -32,103 +26,6 @@ pub fn config_dir() -> PathBuf {
|
||||
base.join("ai-app-desktop")
|
||||
}
|
||||
|
||||
fn enrollment_file(dir: &Path) -> PathBuf {
|
||||
dir.join("enrollment.json")
|
||||
}
|
||||
|
||||
/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in
|
||||
/// the tests below), creating it if needed, and sets the file owner-only --
|
||||
/// it carries a bearer token, the same reason `server/`'s own token store
|
||||
/// is 0600.
|
||||
pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = enrollment_file(dir);
|
||||
let json = serde_json::to_vec_pretty(server)
|
||||
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||
std::fs::write(&path, json)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Ok(None)` when nothing has been enrolled yet, rather than an error --
|
||||
/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES'
|
||||
/// "a deliberate choice is not a problem to report" applies just as well
|
||||
/// to a file that simply hasn't been written yet).
|
||||
pub fn load_enrollment_in(dir: &Path) -> io::Result<Option<EnrolledServer>> {
|
||||
let path = enrollment_file(dir);
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(server))
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> {
|
||||
save_enrollment_in(&config_dir(), server)
|
||||
}
|
||||
|
||||
pub fn load_enrollment() -> io::Result<Option<EnrolledServer>> {
|
||||
load_enrollment_in(&config_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_saved_enrollment_reads_back_the_same() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server = EnrolledServer {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "tok".to_string(),
|
||||
};
|
||||
save_enrollment_in(dir.path(), &server).unwrap();
|
||||
let read_back = load_enrollment_in(dir.path()).unwrap();
|
||||
assert_eq!(read_back, Some(server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_saved_yet_is_none_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(load_enrollment_in(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn the_saved_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server = EnrolledServer {
|
||||
host: "h".to_string(),
|
||||
port: 1,
|
||||
token: "t".to_string(),
|
||||
};
|
||||
save_enrollment_in(dir.path(), &server).unwrap();
|
||||
let mode = std::fs::metadata(enrollment_file(dir.path()))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_is_named_in_the_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(enrollment_file(dir.path()), b"not json").unwrap();
|
||||
let err = load_enrollment_in(dir.path()).unwrap_err();
|
||||
assert!(err.to_string().contains("enrollment.json"));
|
||||
}
|
||||
pub fn store() -> EnrollmentStore {
|
||||
EnrollmentStore::new(config_dir())
|
||||
}
|
||||
@@ -5,18 +5,20 @@
|
||||
//!
|
||||
//! Usage:
|
||||
//!
|
||||
//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T'
|
||||
//! desktop-app --ca /path/to/ca.pem # after the first run above
|
||||
//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B'
|
||||
//! desktop-app # after the first run above
|
||||
//! desktop-app --ca /path/to/ca.pem # a link that carries no CA
|
||||
//!
|
||||
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
|
||||
//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather
|
||||
//! than scanned, since a desktop has no camera to assume. It is parsed and
|
||||
//! saved to `config::save_enrollment` once; later runs read it back and
|
||||
//! `--link` is only needed again to enrol against a different server. The
|
||||
//! CA is never persisted -- it is a public certificate whose path a
|
||||
//! caller is expected to already know (`AGENTS.md`'s "prefer exercising
|
||||
//! the server directly": the same `certs/ca.pem` a `curl --cacert` call
|
||||
//! uses).
|
||||
//! saved once; later runs read it back and `--link` is only needed again
|
||||
//! to enrol against a different server.
|
||||
//!
|
||||
//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which
|
||||
//! `ai-server` now always includes) and is saved with it. `--ca` is the
|
||||
//! override for a link that carries none, and names the same
|
||||
//! `certs/ca.pem` a `curl --cacert` call uses.
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
@@ -24,7 +26,7 @@ mod config;
|
||||
use client_core::config::EnrolledServer;
|
||||
|
||||
struct Args {
|
||||
ca_path: std::path::PathBuf,
|
||||
ca_path: Option<std::path::PathBuf>,
|
||||
link: Option<String>,
|
||||
}
|
||||
|
||||
@@ -43,13 +45,7 @@ fn parse_args() -> Result<Args, String> {
|
||||
other => return Err(format!("unrecognised argument '{other}'")),
|
||||
}
|
||||
}
|
||||
Ok(Args {
|
||||
ca_path: ca_path.ok_or(
|
||||
"--ca PATH is required (the pinned CA's certificate, e.g. \
|
||||
~/.config/ai-app/certs/ca.pem)",
|
||||
)?,
|
||||
link,
|
||||
})
|
||||
Ok(Args { ca_path, link })
|
||||
}
|
||||
|
||||
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
|
||||
@@ -60,14 +56,17 @@ fn parse_args() -> Result<Args, String> {
|
||||
/// other way (`DefaultApp::run()` takes no payload).
|
||||
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||
let args = parse_args()?;
|
||||
let store = config::store();
|
||||
let server = match args.link {
|
||||
Some(link) => {
|
||||
let server = EnrolledServer::parse_link(&link)?;
|
||||
config::save_enrollment(&server)
|
||||
store
|
||||
.save(&server)
|
||||
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||
server
|
||||
}
|
||||
None => config::load_enrollment()
|
||||
None => store
|
||||
.load()
|
||||
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
@@ -77,8 +76,20 @@ fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||
)
|
||||
})?,
|
||||
};
|
||||
let ca_pem = std::fs::read(&args.ca_path)
|
||||
.map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?;
|
||||
// `--ca` wins where it was given, so a caller can point a link's
|
||||
// server at a certificate it did not carry -- and so the flag still
|
||||
// means what it did before the link could carry one.
|
||||
let ca_pem = match (&args.ca_path, &server.ca_pem) {
|
||||
(Some(path), _) => std::fs::read(path)
|
||||
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
|
||||
(None, Some(pem)) => pem.clone().into_bytes(),
|
||||
(None, None) => {
|
||||
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
|
||||
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
|
||||
minted by a server that includes one"
|
||||
.to_string());
|
||||
}
|
||||
};
|
||||
Ok((server, ca_pem))
|
||||
}
|
||||
|
||||
|
||||
+59
-26
@@ -88,9 +88,10 @@ pub struct FrameDiagnostics {
|
||||
}
|
||||
|
||||
impl AndroidRenderer {
|
||||
/// `Err` holds a full, human-readable report -- wgpu's own error text
|
||||
/// (`UiRenderNode::new`'s doc comment) plus the adapter identity and
|
||||
/// the limits/downlevel flags bind-group-layout validation checks
|
||||
/// `Err` holds a full, human-readable report for **every** way this
|
||||
/// can fail -- no surface, no adapter, no device, or wgpu's own error
|
||||
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
|
||||
/// and the limits/downlevel flags bind-group-layout validation checks
|
||||
/// against -- rather than the panic wgpu's default error handler would
|
||||
/// otherwise raise with no caller able to see it. This is what aborted
|
||||
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
|
||||
@@ -107,29 +108,67 @@ impl AndroidRenderer {
|
||||
height: u32,
|
||||
content_scale: f32,
|
||||
) -> Result<Self, String> {
|
||||
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
|
||||
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
|
||||
// build, to isolate whether the backend itself explains the frame
|
||||
// time gap against Compose. `cfg!` rather than a runtime switch:
|
||||
// there is no way to hand an env var to an already-launched Android
|
||||
// process on this machine (see the feature's doc in Cargo.toml).
|
||||
let backends = if cfg!(feature = "force-gles") {
|
||||
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
|
||||
// the build to GLES, to isolate whether the backend itself explains
|
||||
// the frame time gap against Compose. `cfg!` rather than a runtime
|
||||
// switch: there is no way to hand an env var to an already-launched
|
||||
// Android process on this machine (see the feature's doc in
|
||||
// Cargo.toml).
|
||||
//
|
||||
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
|
||||
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
|
||||
// offering only a GLES adapter had no adapter at all and this
|
||||
// function aborted the process -- this checkout's emulator, whose
|
||||
// Vulkan ICD carries no adapter behind it (`NotFound {
|
||||
// active_backends: VULKAN, no_adapter_backends: VULKAN,
|
||||
// supported_backends: VULKAN | GL }`), and the crash loop in
|
||||
// RUST.md's queue.
|
||||
//
|
||||
// The choice is made *before any surface exists*, with an instance
|
||||
// that never touches the window, because **an Android window can be
|
||||
// connected to one graphics API only**. One instance carrying both
|
||||
// backends does not work: `create_surface` builds a raw surface per
|
||||
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
|
||||
// first, and the GLES surface made from the same window then fails
|
||||
// `configure` as lost -- measured here as "In Surface::configure /
|
||||
// Invalid surface" followed by an abort in
|
||||
// `Surface::get_current_texture_view`, "Surface is not configured
|
||||
// for presentation".
|
||||
let mut backends = if cfg!(feature = "force-gles") {
|
||||
Backends::GL
|
||||
} else {
|
||||
Backends::PRIMARY
|
||||
};
|
||||
let instance = Instance::new(&InstanceDescriptor {
|
||||
let mut instance = Instance::new(&InstanceDescriptor {
|
||||
backends,
|
||||
..Default::default()
|
||||
});
|
||||
// A build already pinned to GLES has nowhere to fall back to.
|
||||
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||
log::warn!(
|
||||
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
|
||||
);
|
||||
backends = Backends::GL;
|
||||
instance = Instance::new(&InstanceDescriptor {
|
||||
backends,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// SAFETY: the `NativeWindow` outlives the surface built from it --
|
||||
// android-view drops the old renderer (and this surface with it)
|
||||
// before handing over a new window, in `surface_changed` below.
|
||||
let surface = instance
|
||||
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
|
||||
.expect("Could not create android surface!");
|
||||
.map_err(|error| format!("Could not create the android surface: {error}"))?;
|
||||
|
||||
// Every step from here to a live device reports rather than
|
||||
// panics, for the one reason: on the phone these builds run on
|
||||
// there is no `adb`, so an abort's message reaches a tombstone
|
||||
// nobody can read and the launcher simply restarts the app --
|
||||
// which is what a crash loop with no explanation is. The caller
|
||||
// (`android::view::IrisViewPeer::surface_changed`) puts this
|
||||
// string on screen and in the app's own log ring instead.
|
||||
let adapter = instance
|
||||
.request_adapter(&RequestAdapterOptions {
|
||||
power_preference: PowerPreference::default(),
|
||||
@@ -137,19 +176,7 @@ impl AndroidRenderer {
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.block_on()
|
||||
.expect("Could not get adapter!");
|
||||
|
||||
// Requesting the device itself still panics on failure: that is a
|
||||
// `RequestDeviceError` (a limit or feature the adapter cannot grant
|
||||
// at all), a different and already-diagnosable failure from the one
|
||||
// this function now recovers from -- `RUST.md`'s "Software mode ...
|
||||
// crashes for a third, different reason" is exactly that class, and
|
||||
// its message already names the limit and the requested/allowed
|
||||
// values with no truncation risk (it never reaches wgpu's
|
||||
// uncaptured-error path). What this function's `Result` return
|
||||
// covers is the *next* class of failure: the adapter grants the
|
||||
// device, and validation only fails once a specific bind group
|
||||
// layout is checked against it.
|
||||
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
|
||||
|
||||
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||
@@ -161,7 +188,13 @@ impl AndroidRenderer {
|
||||
..Default::default()
|
||||
})
|
||||
.block_on()
|
||||
.expect("Could not get device!");
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"The adapter {} ({:?}) refused a device: {error}",
|
||||
adapter.get_info().name,
|
||||
adapter.get_info().backend,
|
||||
)
|
||||
})?;
|
||||
|
||||
// wgpu's default handler for an error raised outside `UiRenderNode::
|
||||
// new`'s own error scopes (i.e. everything past device creation --
|
||||
|
||||
+96
-54
@@ -7,7 +7,7 @@ use android_view::{
|
||||
jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{GlobalRef, JValue},
|
||||
sys::{jint, jlong},
|
||||
sys::jint,
|
||||
},
|
||||
ndk::event::{Axis, Keycode, MotionAction},
|
||||
};
|
||||
@@ -20,7 +20,7 @@ use std::{
|
||||
marker::{PhantomData, Sized},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -312,12 +312,11 @@ pub struct IrisViewPeer<State: AndroidAppState> {
|
||||
pub(super) render: UiRenderState,
|
||||
pub(super) state: State,
|
||||
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
||||
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
|
||||
/// captured from the first `MotionEvent` this view receives and never
|
||||
/// changed after -- how `on_touch_event` dates every touch sample. Its
|
||||
/// path out is the peer's own drop: it holds nothing but two numbers
|
||||
/// and is meaningless to any other view.
|
||||
input_clock: Option<(Instant, jlong)>,
|
||||
/// Anchored on the first `MotionEvent` this view receives and never
|
||||
/// re-anchored after -- how `on_touch_event` dates every touch sample.
|
||||
/// Its path out is the peer's own drop: it holds nothing but three
|
||||
/// numbers and is meaningless to any other view.
|
||||
input_clock: Option<PointerClock>,
|
||||
}
|
||||
|
||||
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
||||
@@ -406,7 +405,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
/// magenta and screenshotting), but no primitive ever appears on top of
|
||||
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
|
||||
/// these in until that is root-caused; removing them loses the exact
|
||||
/// evidence a `logcat` capture needs to reproduce the state.
|
||||
/// evidence a `logcat` capture needs to reproduce the state. Gated on
|
||||
/// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's
|
||||
/// review, D1): unconditional, they were two `debug!` lines every
|
||||
/// rendered frame, and `client_core::log_ring`'s `RingLogger` records
|
||||
/// every level the app's already-`Debug` install lets through
|
||||
/// regardless of target, so they filled the whole ring in under ten
|
||||
/// seconds at 120Hz and left `Copy report` nothing else to show.
|
||||
fn render(&mut self, ctx: &mut CallbackCtx) {
|
||||
if self.state.android_state().renderer.is_none() {
|
||||
return;
|
||||
@@ -441,18 +446,29 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||
}
|
||||
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
||||
ui_state.root.is_some(),
|
||||
self.rsc.widgets().len(),
|
||||
self.render.active_widgets(),
|
||||
ui_state
|
||||
.root
|
||||
.as_ref()
|
||||
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
||||
self.window_size(),
|
||||
);
|
||||
// Gated the same way `iris::frame`'s own line is (docs/RUST.md's
|
||||
// "Phone logging" review, D1): a bare `log::debug!` reaches
|
||||
// `client_core::log_ring`'s ring regardless of level, since
|
||||
// `RingLogger::enabled` is unconditionally `true` and the app
|
||||
// installs at `LevelFilter::Debug` -- two of these a rendered
|
||||
// frame filled the 2000-line ring in under ten seconds at 120Hz,
|
||||
// leaving `Copy report` nothing but frame spam. See
|
||||
// `iris::diagnostics`'s module doc.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
||||
ui_state.root.is_some(),
|
||||
self.rsc.widgets().len(),
|
||||
self.render.active_widgets(),
|
||||
ui_state
|
||||
.root
|
||||
.as_ref()
|
||||
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
||||
self.window_size(),
|
||||
);
|
||||
}
|
||||
// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
||||
// taken" (b)): started here, at the same point a redraw request
|
||||
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
|
||||
@@ -498,21 +514,25 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
.record_split(frame_start.elapsed(), submit_to_present);
|
||||
crate::diagnostics::log_frame(&self.render, frame_start, submit_to_present, animating);
|
||||
// A frame callback is one-shot, so an animation that wants
|
||||
// another frame has to say so every frame -- unlike `after_input`,
|
||||
// which only has to ask when input dirtied something.
|
||||
if animating {
|
||||
ctx.view.post_frame_callback(&mut ctx.env);
|
||||
}
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
"render(): after update active={} root_px={:?}",
|
||||
self.render.active_widgets(),
|
||||
ui_state
|
||||
.root
|
||||
.as_ref()
|
||||
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
||||
);
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"render(): after update active={} root_px={:?}",
|
||||
self.render.active_widgets(),
|
||||
ui_state
|
||||
.root
|
||||
.as_ref()
|
||||
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
||||
);
|
||||
}
|
||||
|
||||
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
|
||||
// anything to raise -- when the named set actually changed this
|
||||
@@ -618,17 +638,30 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
|
||||
// `(Instant, nanos)` pair converts every later sample exactly.
|
||||
// Anchoring **once** rather than per event is what keeps the times
|
||||
// ordered: a fresh `Instant::now()` per event, minus each sample's
|
||||
// age inside it, can date a later event's first historical sample
|
||||
// before the previous event's last one whenever delivery jitters by
|
||||
// more than the batch spans -- and `VelocityTracker::add_sample`'s
|
||||
// debug assert would rightly fire on that. See `CursorState::time`.
|
||||
// ordered, and anchoring on the first event's *oldest* sample
|
||||
// rather than on its own time is what keeps that event's batch
|
||||
// from collapsing onto one instant -- `sense::PointerClock`'s doc
|
||||
// has both, and owns the arithmetic so it can be unit-tested off a
|
||||
// device (`sense_tests.rs`). See `CursorState::time`.
|
||||
let event_time = event.event_time_nanos(&mut ctx.env);
|
||||
let (anchor_at, anchor_nanos) =
|
||||
*self.input_clock.get_or_insert((Instant::now(), event_time));
|
||||
let at = |sample_time: jlong| {
|
||||
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
|
||||
};
|
||||
if self.input_clock.is_none() {
|
||||
let history = event.history_size(&mut ctx.env);
|
||||
let oldest = if history > 0 {
|
||||
event.historical_event_time_nanos(&mut ctx.env, 0)
|
||||
} else {
|
||||
event_time
|
||||
};
|
||||
self.input_clock = Some(PointerClock::anchored(Instant::now(), event_time, oldest));
|
||||
}
|
||||
let mut clock = self.input_clock.expect("anchored just above");
|
||||
// `iris::input`'s own doc (`sense::log_input_event`): collected
|
||||
// only when tracing is on, since this is otherwise a `Vec` per
|
||||
// `MotionEvent` for a line nobody is reading -- the JNI reads
|
||||
// themselves (`historical_axis`/`historical_event_time_nanos`
|
||||
// below) already happen unconditionally, for the replay this
|
||||
// function does regardless of tracing.
|
||||
let trace_input = crate::diagnostics::trace_enabled();
|
||||
let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new();
|
||||
|
||||
// **Historical samples first.** A flick on a 120Hz screen is
|
||||
// delivered as one or two `MotionEvent`s with the intermediate
|
||||
@@ -648,31 +681,30 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// the event's own sample as the newest of the batch; everything
|
||||
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
||||
// clock) assumes it, so say so here rather than at each reader.
|
||||
let mut previous = anchor_nanos;
|
||||
// `PointerClock::sample` is what asserts it, and it carries the
|
||||
// last sample seen *across* events, so the first sample of
|
||||
// every event is checked against the previous event's last one
|
||||
// rather than against the anchor.
|
||||
for pos in 0..history {
|
||||
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
|
||||
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
|
||||
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
||||
debug_assert!(
|
||||
ht >= previous,
|
||||
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
|
||||
sample ahead of it -- the input clock is not what this assumes"
|
||||
);
|
||||
previous = ht;
|
||||
let sample_at = clock.sample(ht);
|
||||
if trace_input {
|
||||
historical_ms.push((clock.ms_since_anchor(ht), hx, hy));
|
||||
}
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.cursor.pos = vec2(hx, hy);
|
||||
ui_state.cursor.time = at(ht);
|
||||
ui_state.cursor.time = sample_at;
|
||||
self.run_input_frame(ctx);
|
||||
}
|
||||
debug_assert!(
|
||||
event_time >= previous,
|
||||
"the event's own sample is dated {event_time}ns, before its last historical \
|
||||
sample at {previous}ns"
|
||||
);
|
||||
}
|
||||
|
||||
let event_at = clock.sample(event_time);
|
||||
let event_ms = clock.ms_since_anchor(event_time);
|
||||
self.input_clock = Some(clock);
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.cursor.time = at(event_time);
|
||||
ui_state.cursor.time = event_at;
|
||||
match action {
|
||||
MotionAction::Down => {
|
||||
ui_state.cursor.pos = vec2(x, y);
|
||||
@@ -695,6 +727,16 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
if trace_input {
|
||||
let action_word = match action {
|
||||
MotionAction::Down => "down",
|
||||
MotionAction::Move => "move",
|
||||
MotionAction::Up => "up",
|
||||
MotionAction::Cancel => "cancel",
|
||||
_ => "other",
|
||||
};
|
||||
crate::sense::log_input_event(action_word, x, y, event_ms, &historical_ms);
|
||||
}
|
||||
self.after_input(ctx);
|
||||
true
|
||||
}
|
||||
|
||||
+45
-1
@@ -294,6 +294,31 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
ui_state.focus = None;
|
||||
}
|
||||
if input_changed {
|
||||
// The winit half of `iris::input` (`sense::log_input_event`'s
|
||||
// own doc): no batching here, so `historical` is always empty
|
||||
// -- winit hands one `WindowEvent` per pointer sample, unlike
|
||||
// Android's `MotionEvent`. The action is read back off the
|
||||
// buttons `Input::event` just updated, the same test
|
||||
// `GestureOutcome`'s callers already use to tell a press from a
|
||||
// release. Computed only when tracing is on, same reasoning as
|
||||
// `log_input_event` itself gating on it.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
let action = if cursor_state.buttons.left.is_start() {
|
||||
"down"
|
||||
} else if cursor_state.buttons.left.is_end() {
|
||||
"up"
|
||||
} else {
|
||||
"move"
|
||||
};
|
||||
let t_ms = cursor_state.time.duration_since(render.epoch()).as_millis() as u64;
|
||||
crate::sense::log_input_event(
|
||||
action,
|
||||
cursor_state.pos.x,
|
||||
cursor_state.pos.y,
|
||||
t_ms,
|
||||
&[],
|
||||
);
|
||||
}
|
||||
let window_size = ui_state.window_size();
|
||||
render.run_sensors(rsc, state, cursor_state, window_size);
|
||||
}
|
||||
@@ -313,11 +338,14 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||
// Android. Nothing else in iris moves without an input
|
||||
// event.
|
||||
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
|
||||
let frame_start = std::time::Instant::now();
|
||||
let animating = rsc.ui_mut().tick_animations(frame_start);
|
||||
let ui_state = state.default_state_mut();
|
||||
render.update(&ui_state.root, rsc);
|
||||
ui_state.renderer.update(&mut rsc.ui, render);
|
||||
let draw_start = std::time::Instant::now();
|
||||
ui_state.renderer.draw();
|
||||
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
|
||||
if animating {
|
||||
ui_state.window.request_redraw();
|
||||
}
|
||||
@@ -334,6 +362,22 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
render.resize((size.width, size.height));
|
||||
ui_state.renderer.resize(size)
|
||||
}
|
||||
// Dragging the window to a display with a different scale.
|
||||
// Both copies again, the pair `new` sets at startup -- read
|
||||
// through `content_scale` rather than from the event, so
|
||||
// `IRIS_SCALE` still pins the density it was given (the
|
||||
// `--phone` window must not follow the monitor). winit sends
|
||||
// the matching `Resized` separately. Before 2026-09-07 this
|
||||
// event was unhandled, so every `dp` and every rasterised
|
||||
// glyph stayed at the density the window opened on
|
||||
// (docs/REVIEW-2026-09-07.md's R5) -- invisible on this
|
||||
// machine, where every display is 1.0.
|
||||
WindowEvent::ScaleFactorChanged { .. } => {
|
||||
let scale = content_scale(ui_state.window.as_ref());
|
||||
rsc.ui.text.density = scale;
|
||||
render.set_density(scale);
|
||||
ui_state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if let Some(sel) = ui_state.focus
|
||||
&& event.state.is_pressed()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's
|
||||
//! 2026-09-07 request: "add another button to copy input event info ...
|
||||
//! instrument a lot of the code with timings"), and the one place both
|
||||
//! call sites' `iris::frame` line is written from.
|
||||
//!
|
||||
//! **Why a crate-level flag instead of `log::log_enabled!`/
|
||||
//! `log::set_max_level`**: the app already installs its logger at
|
||||
//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so
|
||||
//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring
|
||||
//! regardless of what this instrument would prefer -- `RingLogger::enabled`
|
||||
//! is unconditionally `true` by design (its own doc: "the ring wants
|
||||
//! everything"). So the level alone cannot give these two targets a
|
||||
//! default-off switch; the gate has to live on this side, checked before
|
||||
//! `log::debug!` is even reached.
|
||||
//!
|
||||
//! **Why default off matters**: the ring is 2000 lines / 256 KiB
|
||||
//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a
|
||||
//! 120Hz session logging both a line per touch sample and a line per frame
|
||||
//! fills that in seconds -- so a caller turns this on only for the length
|
||||
//! of whatever is being investigated, and the report says so at its top
|
||||
//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top
|
||||
//! of whatever builds the report).
|
||||
//!
|
||||
//! **Not yet wired to a control**: the Diagnostics pane that would hold the
|
||||
//! switch is in `iris/android-app/src/bench_client.rs`, which another agent
|
||||
//! has open at the same time this was written. `set_trace` is the whole
|
||||
//! surface a button needs; wiring one is a follow-up.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iris_core::UiRenderState;
|
||||
|
||||
static TRACE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by
|
||||
/// default -- see the module doc for why turning the level on alone would
|
||||
/// not do it.
|
||||
pub fn set_trace(on: bool) {
|
||||
TRACE.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the `iris::input`/`iris::frame` lines are enabled right now --
|
||||
/// what a report's header reads before deciding what to say about the
|
||||
/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state
|
||||
/// first").
|
||||
pub fn trace_enabled() -> bool {
|
||||
TRACE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `iris::frame` line, called once per frame from each backend's own
|
||||
/// frame function -- `android::view::IrisViewPeer::render`,
|
||||
/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and
|
||||
/// `harness::Harness::frame` -- after the draw (or, on the harness, where a
|
||||
/// draw would be; `draw` is `Duration::ZERO` there since nothing is
|
||||
/// actually submitted to a GPU).
|
||||
///
|
||||
/// `render.update(...)` must already have run this frame: this reads back
|
||||
/// what it recorded (`UiRenderState::last_layout_duration`/
|
||||
/// `last_redraw_kind`/`frame_number`) rather than timing anything itself,
|
||||
/// so a caller's own measurement of the phase around `update()` and around
|
||||
/// its own draw call are the only two `Instant` pairs in the whole path --
|
||||
/// see each call site's own comment for why it is not restructured to fit
|
||||
/// this instead.
|
||||
pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) {
|
||||
if !trace_enabled() {
|
||||
return;
|
||||
}
|
||||
let since_input = render
|
||||
.time_since_input(now)
|
||||
.map(|d| format!("{}ms", d.as_millis()))
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \
|
||||
redraw={:?} primitives={} animating={animating}",
|
||||
render.frame_number(),
|
||||
now.duration_since(render.epoch()).as_millis(),
|
||||
render.last_layout_duration(),
|
||||
draw,
|
||||
render.last_redraw_kind(),
|
||||
render.active_primitive_count(),
|
||||
);
|
||||
}
|
||||
+25
-1
@@ -58,6 +58,18 @@ impl TouchAction {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
|
||||
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
|
||||
/// `.touch` file agree on one spelling of each action.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
Self::Down => "down",
|
||||
Self::Move => "move",
|
||||
Self::Up => "up",
|
||||
Self::Cancel => "cancel",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -345,8 +357,14 @@ impl Harness {
|
||||
update(&mut self.state, &mut self.rsc);
|
||||
}
|
||||
let now = self.at(t_ms);
|
||||
self.rsc.ui.tick_animations(now);
|
||||
let animating = self.rsc.ui.tick_animations(now);
|
||||
self.render.update(&self.state.root, &mut self.rsc);
|
||||
// No GPU here, so there is no draw phase to time -- `draw` is
|
||||
// always zero. `layout`/`redraw`/`primitives` are still real,
|
||||
// because `render.update` just ran; see
|
||||
// `iris::diagnostics::log_frame`'s own doc for why this reads
|
||||
// those back rather than timing anything itself.
|
||||
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
|
||||
}
|
||||
|
||||
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||
@@ -377,6 +395,12 @@ impl Harness {
|
||||
TouchAction::Move => {}
|
||||
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false),
|
||||
}
|
||||
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
|
||||
// doc): no batching happens here, so `historical` is always empty
|
||||
// and `t_ms` is the script's own column, which is what makes this
|
||||
// round-trip through `report_to_touch.py` back into an identical
|
||||
// `TouchScript`.
|
||||
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
|
||||
let cursor = self.cursor.clone();
|
||||
self.render
|
||||
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||
|
||||
@@ -150,8 +150,8 @@ fn hit_testing_follows_a_scrolled_widget() {
|
||||
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
||||
/// it set for itself -- `redraw` feeds it straight back in as the inherited
|
||||
/// mask, so storing the set one hands a `Masked` its own mask the second
|
||||
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
|
||||
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
|
||||
/// time round -- which `Painter::set_mask` asserts against, since a mask
|
||||
/// that chains to itself is a clip loop. That was an abort the first time
|
||||
/// the composer's new scroll area was redrawn on the emulator; a targeted
|
||||
/// redraw of a `Masked` is what any real screen does whenever anything
|
||||
/// inside it changes.
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod android;
|
||||
pub mod default;
|
||||
|
||||
pub mod attr;
|
||||
pub mod diagnostics;
|
||||
pub mod event;
|
||||
pub mod harness;
|
||||
pub mod platform;
|
||||
|
||||
+1013
-147
File diff suppressed because it is too large.
Load diff
+48
-1
@@ -7,7 +7,7 @@
|
||||
//! impl need no GPU or window.
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
use std::{cell::Cell, rc::Rc, time::Instant};
|
||||
|
||||
struct SenseRsc {
|
||||
ui: UiData,
|
||||
@@ -317,3 +317,50 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
// widget even once the finger leaves its box.
|
||||
assert_eq!(render.captured_pointer(), Some(scroll.id()));
|
||||
}
|
||||
|
||||
/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can
|
||||
/// be a `Move` -- the `Down` went to another view, or the view was attached
|
||||
/// mid-gesture -- and its batched samples are older than its own
|
||||
/// timestamp. Anchoring on that timestamp clamped every one of them onto
|
||||
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
|
||||
/// fit went degenerate, and the flick read 0 px/s.
|
||||
#[test]
|
||||
fn the_first_events_batched_samples_are_dated_apart() {
|
||||
const MS: i64 = 1_000_000;
|
||||
let now = Instant::now();
|
||||
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
|
||||
// own at 12ms.
|
||||
let clock = PointerClock::anchored(now, 12 * MS, 0);
|
||||
|
||||
assert_eq!(
|
||||
clock.at(12 * MS),
|
||||
now,
|
||||
"the event's own sample is the one that arrived now"
|
||||
);
|
||||
let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)];
|
||||
assert!(
|
||||
batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now,
|
||||
"the batch must keep the 4ms between its samples, got {:?}",
|
||||
batch
|
||||
.iter()
|
||||
.map(|t| now.duration_since(*t))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
|
||||
}
|
||||
|
||||
/// The same clock has to keep ordering *across* events: the sample it
|
||||
/// compares a new event's first sample against is the previous event's
|
||||
/// last one, never the anchor.
|
||||
#[test]
|
||||
fn the_clock_orders_samples_across_events() {
|
||||
const MS: i64 = 1_000_000;
|
||||
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
|
||||
let first = clock.sample(12 * MS);
|
||||
let second = clock.sample(28 * MS);
|
||||
assert!(second > first);
|
||||
assert_eq!(
|
||||
second.duration_since(first),
|
||||
std::time::Duration::from_millis(16)
|
||||
);
|
||||
}
|
||||
+353
-41
@@ -94,11 +94,22 @@
|
||||
//! row that has never been measured, so this stays independent of how many
|
||||
//! rows exist outside the loaded window.
|
||||
//!
|
||||
//! **What is deliberately not solved here.** No overscroll clamping: a
|
||||
//! `scroll()` past the first or last row leaves a gap rather than rubber-
|
||||
//! banding back (mirrors `Scroll`'s own documented one-frame-lag
|
||||
//! tolerance in LAYOUT.md, just not even auto-corrected -- there is
|
||||
//! nothing to measure "how much content is left" without walking it).
|
||||
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
|
||||
//! One rule, `intersects_viewport`, used by both halves of that sentence:
|
||||
//! a row straddling either edge is drawn in full and clipped by the
|
||||
//! `.masked()` its caller must place it in (`List::draw` asserts that),
|
||||
//! and a row that has left the viewport is not drawn at all. The walk
|
||||
//! still traverses whatever lies between the anchor and the viewport, and
|
||||
//! `rehome_anchor` moves the anchor back onto a visible row every frame so
|
||||
//! that "whatever lies between" stays empty however far the list is
|
||||
//! panned.
|
||||
//!
|
||||
//! **Overscroll is taken back on the next frame, never rubber-banded.** A
|
||||
//! `scroll()` or a fling past the first or last row leaves a gap for one
|
||||
//! frame; `clamp_to_content` measures it from the ends the walk already
|
||||
//! placed and gives it back (the same one-frame-lag `Scroll`'s content
|
||||
//! length has, per LAYOUT.md). A list shorter than its viewport is not
|
||||
//! overscrolled and is left alone, still bottom-anchored.
|
||||
|
||||
use crate::prelude::*;
|
||||
use iris_core::util::HashMap;
|
||||
@@ -415,8 +426,9 @@ impl List {
|
||||
|
||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||
/// Deliberately unclamped -- see the module doc's "what is not
|
||||
/// solved here."
|
||||
/// Unclamped here, on purpose: it is one write, and there is nothing
|
||||
/// at this point that knows where the content ends. The next `draw`
|
||||
/// gives back whatever this moved past ([`Self::clamp_to_content`]).
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
if let Some(a) = &mut self.anchor {
|
||||
a.offset -= amt;
|
||||
@@ -475,9 +487,32 @@ impl List {
|
||||
// through) would propagate silently into `deceleration_for`'s
|
||||
// `.ln()` -- the fling either never settles or jumps to NaN
|
||||
// positions with nothing on screen saying why (docs/
|
||||
// REVIEW-2026-09-06.md finding 3).
|
||||
debug_assert!(velocity_px_per_s.is_finite());
|
||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
||||
// REVIEW-2026-09-06.md finding 3). A plain `assert!` rather than a
|
||||
// `debug_assert!`: it is one comparison per *gesture*, and every
|
||||
// build anybody runs -- the emulator's and Iris's phone's -- is
|
||||
// release, where a debug-only guard against silently wrong output
|
||||
// is no guard at all (docs/REVIEW-2026-09-07.md's R1).
|
||||
assert!(velocity_px_per_s.is_finite());
|
||||
// Compose's two thresholds at a release, and **only** those two.
|
||||
//
|
||||
// The maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
|
||||
// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into
|
||||
// `VelocityTracker.calculateVelocity(maximumVelocity)`. It is
|
||||
// applied here rather than in the tracker because the tracker
|
||||
// works in pixels and has no density; this widget takes one from
|
||||
// the painter in `draw`.
|
||||
//
|
||||
// The minimum is 1px/s, from `DefaultFlingBehavior.performFling`'s
|
||||
// `abs(initialVelocity) > 1f` and its own stated reason ("we need
|
||||
// it since spline curve gives us NaNs") -- not
|
||||
// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s,
|
||||
// which Compose's scrolling never consults: its single use in
|
||||
// either artifact is `NestedScrollInteropConnection`, for View
|
||||
// interop. A 50dp/s floor would swallow slow, deliberate releases
|
||||
// that Compose flings, so it is deliberately not here.
|
||||
let max = MAX_FLING_VELOCITY_DP_S * self.density;
|
||||
let velocity_px_per_s = velocity_px_per_s.clamp(-max, max);
|
||||
if velocity_px_per_s.abs() <= 1.0 || self.anchor.is_none() {
|
||||
self.fling = None;
|
||||
return;
|
||||
}
|
||||
@@ -541,14 +576,22 @@ impl List {
|
||||
// observable nor observed while `distance_fraction` returned `t`
|
||||
// (`android_fling_spline`'s doc), which is why this is here rather
|
||||
// than the total-travel line the release log already carries.
|
||||
log::debug!(
|
||||
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||
elapsed.as_secs_f32(),
|
||||
delta,
|
||||
f.calc.velocity_at(velocity, elapsed),
|
||||
velocity,
|
||||
f.calc.distance(velocity) - target,
|
||||
);
|
||||
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
|
||||
// (docs/RUST.md's review, D1): one line per fling *tick*,
|
||||
// unconditional, was enough on its own to help fill the log
|
||||
// ring -- see `android::view::IrisViewPeer::render`'s own doc for
|
||||
// the same finding on its two per-frame lines.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||
elapsed.as_secs_f32(),
|
||||
delta,
|
||||
f.calc.velocity_at(velocity, elapsed),
|
||||
velocity,
|
||||
f.calc.distance(velocity) - target,
|
||||
);
|
||||
}
|
||||
self.scroll(delta);
|
||||
|
||||
// Clamp: a fling moving toward the start that has already reached
|
||||
@@ -790,6 +833,115 @@ impl List {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the anchor onto a row that is actually on screen, without
|
||||
/// moving anything that is drawn: the row it re-homes to keeps the
|
||||
/// exact top edge this frame's layout gave it.
|
||||
///
|
||||
/// [`Self::scroll`] moves the anchor's *offset* and nothing else, so
|
||||
/// panning away from the anchor's own row leaves that row further and
|
||||
/// further outside the viewport, and every row between it and the
|
||||
/// viewport has to be walked on every frame from then on -- before
|
||||
/// `place`'s intersection test, drawn too. Measured on the bench
|
||||
/// fixture before this: 8 scrolls of 3000px left **64 rows** placed in
|
||||
/// a 2012px viewport, ~59 of them off-screen, and the ones above it
|
||||
/// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07).
|
||||
/// Re-homing each frame makes the walk O(visible) again whatever
|
||||
/// distance was travelled, which is what the module doc claims.
|
||||
///
|
||||
/// Only when the anchor's own row has left the viewport, so
|
||||
/// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom
|
||||
/// edge at the viewport's own bottom, which intersects it -- is left
|
||||
/// exactly as it is rather than rewritten into a top-edge anchor that
|
||||
/// no longer reads as flush with the end.
|
||||
fn rehome_anchor(&mut self) {
|
||||
let Some(anchor) = self.anchor else {
|
||||
return;
|
||||
};
|
||||
if self.extents.values().any(|e| e.slot == anchor.slot) {
|
||||
return;
|
||||
}
|
||||
// The topmost row on screen, so the anchor's offset stays a small
|
||||
// number near the viewport's own leading edge rather than
|
||||
// whatever the last row's bottom happens to be.
|
||||
let Some(first) = self
|
||||
.extents
|
||||
.values()
|
||||
.min_by(|a, b| a.top.total_cmp(&b.top))
|
||||
.copied()
|
||||
else {
|
||||
// Nothing on screen at all -- a list scrolled past its own
|
||||
// content (`scroll` is deliberately unclamped). There is no
|
||||
// on-screen row to re-home to, and inventing one would move
|
||||
// the list; leave the anchor where it is and let the next
|
||||
// scroll or `repair_anchor` bring content back.
|
||||
return;
|
||||
};
|
||||
self.anchor = Some(Anchor {
|
||||
slot: first.slot,
|
||||
edge: Edge::Top,
|
||||
offset: first.top,
|
||||
});
|
||||
}
|
||||
|
||||
/// Take back an empty band at one edge that content on the other side
|
||||
/// of the viewport could fill -- the correction that makes a `scroll`
|
||||
/// or a fling past the end of the content settle *on* the end rather
|
||||
/// than beyond it.
|
||||
///
|
||||
/// `top`/`bottom` are the extreme edges this frame's walk actually
|
||||
/// placed, so the gap is already measured: `at_start` means nothing is
|
||||
/// above `top`, and if `top` is nevertheless below the viewport's own
|
||||
/// leading edge then those pixels are empty and always will be. This
|
||||
/// is the whole of what the module doc used to list as deliberately
|
||||
/// unsolved ("no overscroll clamping ... nothing to measure how much
|
||||
/// content is left without walking it") -- true of *total* content
|
||||
/// height, but the walk hands back both ends of the loaded run for
|
||||
/// free, which is all a clamp needs. `tick_fling` stops a fling that
|
||||
/// has reached an end, but stops it wherever the spline's last step
|
||||
/// had already put it: a hard fling to the top of the bench fixture
|
||||
/// left the first row **1398px below** a 600px viewport, i.e. the
|
||||
/// whole screen blank, and it stayed there (docs/IRIS_TODO.md,
|
||||
/// 2026-09-07: "black from the header down").
|
||||
///
|
||||
/// **Only when the opposite end is not also inside the viewport.**
|
||||
/// Both at once means the content is shorter than the viewport, where
|
||||
/// the space is not overscroll at all -- it is a bottom-anchored list
|
||||
/// with three rows in it, and pulling those to the top would be this
|
||||
/// widget rejecting its own default (`repair_anchor`).
|
||||
///
|
||||
/// Applied to the anchor, so it lands on the *next* frame rather than
|
||||
/// re-running this one: the same one-frame-lag `Scroll` accepts for
|
||||
/// its content length, and one frame is 8ms on the phone.
|
||||
fn clamp_to_content(&mut self, painter: &mut Painter, top: f32, bottom: f32) {
|
||||
if self.at_start == self.at_end {
|
||||
return;
|
||||
}
|
||||
// `at_start`/`at_end` already carry the sign of their own gap
|
||||
// (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap
|
||||
// itself, positive to move content toward the leading edge.
|
||||
let gap = if self.at_start {
|
||||
top
|
||||
} else {
|
||||
bottom - self.viewport_len
|
||||
};
|
||||
// Sub-pixel gaps are what floating-point row heights leave behind
|
||||
// every frame; correcting one would ask for another frame, which
|
||||
// would leave another, and the list would never stop redrawing.
|
||||
if gap.abs() < 0.5 {
|
||||
return;
|
||||
}
|
||||
self.scroll(gap);
|
||||
// Nothing else will ask: the frame this correction was discovered
|
||||
// in has already been laid out, and a fling that ran out at an end
|
||||
// (`tick_fling`'s `hit_bound`) has stopped requesting frames --
|
||||
// which is exactly the case that left the list parked past its own
|
||||
// first row.
|
||||
painter.draw_again();
|
||||
if let Some(redraw) = &self.redraw {
|
||||
redraw.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_snap_end(&mut self) {
|
||||
self.snap_end = match self.anchor {
|
||||
Some(a) => {
|
||||
@@ -801,6 +953,22 @@ impl List {
|
||||
};
|
||||
}
|
||||
|
||||
/// **The one rule for what this list draws**: a row is on screen if
|
||||
/// any part of it is, so a row straddling either edge is drawn *in
|
||||
/// full* and one that has left the viewport entirely is not drawn at
|
||||
/// all. Both halves matter and they failed in opposite directions on
|
||||
/// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled
|
||||
/// past were still being drawn, over the header above the list, and
|
||||
/// the part of a straddling row above the viewport had nothing
|
||||
/// clipping it. The viewport here is the list's own box -- `0 ..
|
||||
/// viewport_len`, `painter.region()` in window terms -- which is the
|
||||
/// same box `List::draw` requires a mask on, so that what this test
|
||||
/// admits and what the clip keeps are one region rather than two that
|
||||
/// can disagree.
|
||||
fn intersects_viewport(&self, top: f32, bottom: f32) -> bool {
|
||||
bottom > 0.0 && top < self.viewport_len
|
||||
}
|
||||
|
||||
fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion {
|
||||
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
|
||||
UiRegion::from_axis(axis, span, UiSpan::FULL)
|
||||
@@ -847,7 +1015,10 @@ impl List {
|
||||
// sites, not by this function, which would otherwise fail with a
|
||||
// bare "index out of bounds" and no context (docs/
|
||||
// REVIEW-2026-09-06.md finding 2). `slot_widget`, called from
|
||||
// here, is what actually indexes/`.expect`s on it.
|
||||
// here, is what actually indexes/`.expect`s on it. Stays a
|
||||
// `debug_assert!` under R1's rule: this runs once per row placed
|
||||
// per frame, and its release failure is the `.expect` below rather
|
||||
// than something silently wrong on screen.
|
||||
debug_assert!(
|
||||
self.slot_exists(slot),
|
||||
"place() called with a slot that doesn't exist: {slot:?}"
|
||||
@@ -865,6 +1036,26 @@ impl List {
|
||||
let key = self.slot_key(slot);
|
||||
let cached = key.and_then(|k| self.heights.get(&k).copied());
|
||||
|
||||
// A row entirely outside the viewport is traversed but not drawn
|
||||
// -- see `intersects_viewport`. The walk still has to *pass
|
||||
// through* it, because its height is what says where the rows
|
||||
// behind it land, but nothing about it reaches the screen, so
|
||||
// drawing it costs a redraw (and, unclipped, paints over whatever
|
||||
// is above the list) for content nobody can see. Only possible
|
||||
// for a row whose height is already known: a first-time row has
|
||||
// to be drawn to be measured at all, which is why the extent
|
||||
// below is recorded from the intersection test rather than from
|
||||
// "was this drawn".
|
||||
if let Some(h) = cached {
|
||||
let (top, bottom) = match placement {
|
||||
Placement::Top(top) => (top, top + h),
|
||||
Placement::Bottom(bottom) => (bottom - h, bottom),
|
||||
};
|
||||
if !self.intersects_viewport(top, bottom) {
|
||||
return (top, bottom);
|
||||
}
|
||||
}
|
||||
|
||||
let (top, bottom, height) = match (placement, cached) {
|
||||
(Placement::Top(top), Some(h)) => {
|
||||
// Offered a box sized to the *cached* height (cheap to
|
||||
@@ -928,7 +1119,13 @@ impl List {
|
||||
};
|
||||
if let Some(k) = key {
|
||||
self.heights.insert(k, height);
|
||||
self.extents.insert(k, RowExtent { slot, top, bottom });
|
||||
// `extents` is what is *on screen* (`key_at`'s doc, and
|
||||
// `rehome_anchor` below reads it as exactly that), so a
|
||||
// first-time row that had to be drawn to be measured and
|
||||
// turned out to be off-screen does not go in it.
|
||||
if self.intersects_viewport(top, bottom) {
|
||||
self.extents.insert(k, RowExtent { slot, top, bottom });
|
||||
}
|
||||
}
|
||||
(top, bottom)
|
||||
}
|
||||
@@ -961,6 +1158,26 @@ impl Widget for List {
|
||||
// density, and `draw` is where this widget meets the only thing
|
||||
// that knows it. See `fling`.
|
||||
self.density = painter.density();
|
||||
// A row that straddles either edge is drawn in full
|
||||
// (`intersects_viewport`), so the part of it outside this list's
|
||||
// box is on screen unless something clips it -- and with nothing
|
||||
// clipping it, a transcript panned to its top edge drew code and
|
||||
// paragraphs straight through the header bar above it on Iris's
|
||||
// phone (docs/IRIS_TODO.md, 2026-09-07). Clipping is `.masked()`,
|
||||
// one mechanism, applied by whoever places the list -- a `List`
|
||||
// cannot set the mask itself, since `Painter::set_mask` allows one
|
||||
// mask per widget and rows of this list already use their own
|
||||
// (`transcript-ui`'s `row.rs`, `tool.rs`). So it checks instead.
|
||||
//
|
||||
// `assert!`, not `debug_assert!`: one bool per draw, and what it
|
||||
// catches is a `List` painting over its surroundings with nothing
|
||||
// on screen saying so -- the fault e922b73 was written to fix.
|
||||
// Every build that runs is release (docs/REVIEW-2026-09-07.md's R1).
|
||||
assert!(
|
||||
painter.is_masked(),
|
||||
"a `List` must be drawn inside something `.masked()`: it draws rows straddling both \
|
||||
edges in full, so the parts outside its own box reach the screen otherwise",
|
||||
);
|
||||
let output_len = painter.output_size().axis(axis);
|
||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||
|
||||
@@ -1013,6 +1230,26 @@ impl Widget for List {
|
||||
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;
|
||||
|
||||
// Both halves of `intersects_viewport`'s rule, checked where they
|
||||
// are cheap to check: what this frame put on screen is exactly
|
||||
// what overlaps the viewport, and nothing above or below it can
|
||||
// be seen. The first failed silently for a whole build -- an
|
||||
// off-screen row draws correctly, it is just in the wrong place.
|
||||
// `assert!` for R1's reason: it walks the rows *on screen*, a
|
||||
// handful, once per draw, and a release build is the only build
|
||||
// this fault has ever been seen in.
|
||||
assert!(
|
||||
self.extents
|
||||
.values()
|
||||
.all(|e| self.intersects_viewport(e.top, e.bottom)),
|
||||
"a row outside the viewport (0..{}) is recorded as on screen: {:?}",
|
||||
self.viewport_len,
|
||||
self.extents
|
||||
.values()
|
||||
.find(|e| !self.intersects_viewport(e.top, e.bottom)),
|
||||
);
|
||||
self.rehome_anchor();
|
||||
self.clamp_to_content(painter, top, bottom);
|
||||
self.update_snap_end();
|
||||
Size::REST
|
||||
}
|
||||
@@ -1068,9 +1305,59 @@ mod tests {
|
||||
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
|
||||
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
|
||||
/// draws.
|
||||
///
|
||||
/// The root is a `Masked` around the list rather than the list
|
||||
/// itself, because that is what every real caller has to do -- a
|
||||
/// `List` draws the row straddling each edge in full and asserts
|
||||
/// something is clipping it (`List::draw`). The mask is the full
|
||||
/// window here, which is also the list's own box.
|
||||
fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget<List>, StrongWidget) {
|
||||
let strong = rsc.ui.widgets.add_strong(list);
|
||||
(strong.weak(), strong.any())
|
||||
let weak = strong.weak();
|
||||
let root = rsc.ui.widgets.add_strong(Masked {
|
||||
inner: strong.any(),
|
||||
});
|
||||
(weak, root.any())
|
||||
}
|
||||
|
||||
/// The case the top-edge cull and the overscroll clamp both had no
|
||||
/// reason to touch: fewer rows than fit. Every one of them is drawn
|
||||
/// (nothing here is outside the viewport), and `clamp_to_content`
|
||||
/// leaves the list bottom-anchored -- the gap above the first row is
|
||||
/// not overscroll, it is where this widget puts a short list, and
|
||||
/// pulling it to the top would be the clamp overriding
|
||||
/// `repair_anchor`'s own default.
|
||||
#[test]
|
||||
fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0);
|
||||
let (list_weak, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
// Several frames, since the clamp acts on the frame *after* the
|
||||
// one that measured a gap: a wrong one would walk the rows up the
|
||||
// screen 40px at a time rather than settle.
|
||||
for _ in 0..4 {
|
||||
render.update(&root, &mut rsc);
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert_eq!(
|
||||
list_ref.extents.len(),
|
||||
3,
|
||||
"every row of a short list is on screen"
|
||||
);
|
||||
let first = list_ref.extents[&0];
|
||||
let last = list_ref.extents[&2];
|
||||
assert!(
|
||||
(first.top - 40.0).abs() < 0.01 && (last.bottom - 100.0).abs() < 0.01,
|
||||
"a 60px list in a 100px viewport moved off the bottom: rows {}..{}",
|
||||
first.top,
|
||||
last.bottom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1142,7 +1429,7 @@ mod tests {
|
||||
bg_ids.push(bg_id);
|
||||
list.push_back(ListRow::new(key, row));
|
||||
}
|
||||
let root = rsc.ui.widgets.add_strong(list).any();
|
||||
let (_, root) = add_list(&mut rsc, list);
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
@@ -1323,7 +1610,12 @@ mod tests {
|
||||
render.update(&root, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
|
||||
// Backwards, into content that exists: a list opens flush with
|
||||
// its newest end, so scrolling *forward* from there is
|
||||
// overscroll, and `clamp_to_content` lays out a second time to
|
||||
// give it back -- a correct extra pass, but not the ordinary
|
||||
// scroll tick whose cost this test is about.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(-5.0);
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||
|
||||
@@ -1616,9 +1908,28 @@ mod tests {
|
||||
/// 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.
|
||||
/// How far a `build_flingable_list` list has scrolled from its very
|
||||
/// first row, in pixels: read off the topmost row on screen, whose
|
||||
/// content position is exactly `slot * ROW_H` because every row there
|
||||
/// is that tall. Measures the list's own accumulated movement (the
|
||||
/// thing `scroll`/`tick_fling` write) rather than the spline's
|
||||
/// bookkeeping, and unlike a single row's extent it stays defined
|
||||
/// however far the list travels -- `extents` holds only what is
|
||||
/// on screen (`List::intersects_viewport`).
|
||||
fn scroll_position(list: &List) -> f32 {
|
||||
let top = list
|
||||
.extents
|
||||
.values()
|
||||
.min_by(|a, b| a.top.total_cmp(&b.top))
|
||||
.expect("something is on screen");
|
||||
top.slot as f32 * FLING_ROW_H - top.top
|
||||
}
|
||||
|
||||
const FLING_ROW_H: f32 = 20.0;
|
||||
|
||||
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);
|
||||
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), FLING_ROW_H);
|
||||
let (list_weak, root) = add_list(rsc, list);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 600.0));
|
||||
@@ -1764,32 +2075,22 @@ mod tests {
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||
let start = Instant::now();
|
||||
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
|
||||
let mut prev = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||
let mut deltas = Vec::new();
|
||||
for step in 1..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
let Some(top) = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get(&list_weak)
|
||||
.unwrap()
|
||||
.extents
|
||||
.get(&0)
|
||||
.map(|e| e.top)
|
||||
else {
|
||||
break; // row 0 scrolled out of the loaded extents
|
||||
};
|
||||
deltas.push((prev_top - top).abs());
|
||||
prev_top = top;
|
||||
let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||
deltas.push((at - prev).abs());
|
||||
prev = at;
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
deltas.len() >= 3,
|
||||
"fling settled or left row 0's extent before collecting enough samples"
|
||||
"fling settled before collecting enough samples"
|
||||
);
|
||||
// Skip the first tick (the slop-transition jump the arbiter
|
||||
// applies is a `List::fling`-adjacent concern, not this curve,
|
||||
@@ -1871,15 +2172,26 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The frame that gives back whatever the fling's last step spent
|
||||
// past the first row -- `clamp_to_content` writes the anchor at
|
||||
// the end of a draw, so it lands on the next one.
|
||||
render.update(&root, &mut rsc);
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(
|
||||
list_ref.at_start,
|
||||
"fling should have clamped at the first row"
|
||||
);
|
||||
// Not `>= -0.5`: `extents` used to hold every row the walk placed,
|
||||
// on screen or not, so that read was satisfied by a first row
|
||||
// sitting *1398px below* a 600px viewport with the whole screen
|
||||
// blank -- the assertion could not fail in the direction the bug
|
||||
// actually went. Both edges, so neither an overshoot past the top
|
||||
// nor one left uncorrected can pass.
|
||||
let first = list_ref.extents[&0];
|
||||
assert!(
|
||||
first.top >= -0.5,
|
||||
"clamped fling overshot the first row's top: {}",
|
||||
first.top.abs() < 0.5,
|
||||
"a fling stopped at the start must leave the first row flush with the top, not {}px \
|
||||
from it",
|
||||
first.top
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::prelude::*;
|
||||
use crate::sense::{DragGesture, GestureOutcome};
|
||||
use crate::sense::{DragGesture, GestureOutcome, PressState};
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct Scroll {
|
||||
@@ -119,14 +119,16 @@ impl Scroll {
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
) {
|
||||
// `already_selected: false` -- a scroll area has no selection of
|
||||
// its own to extend, so a horizontal drag stays `Undecided` and a
|
||||
// A default `PressState`: a scroll area has no selection of its
|
||||
// own to extend, so a horizontal drag stays `Undecided` and a
|
||||
// vertical one past the slop pans, which is the whole contract
|
||||
// here. A caller that *does* own a selection (the transcript's
|
||||
// `Selection`) drives `DragGesture` itself instead.
|
||||
// here; and it never flings (see this method's doc), so there is
|
||||
// never a moving target to catch either. A caller that *does* own
|
||||
// a selection, or a fling (the transcript's `Selection`), drives
|
||||
// `DragGesture` itself instead.
|
||||
match self
|
||||
.gesture
|
||||
.handle(render, id, sense, pos_window, now, false)
|
||||
.handle(render, id, sense, pos_window, now, PressState::default())
|
||||
{
|
||||
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
||||
|
||||
@@ -78,12 +78,20 @@ impl TextView {
|
||||
}
|
||||
self.width = width;
|
||||
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||
log::debug!(
|
||||
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
||||
self.buf.text().chars().count(),
|
||||
tex.glyphs.len(),
|
||||
tex.size,
|
||||
);
|
||||
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
|
||||
// (docs/RUST.md's review, D1): one line per text *shape* (a cache
|
||||
// miss), unconditional, is many per frame while rows compose --
|
||||
// see `android::view::IrisViewPeer::render`'s own doc for the same
|
||||
// finding on its two per-frame lines.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
||||
self.buf.text().chars().count(),
|
||||
tex.glyphs.len(),
|
||||
tex.size,
|
||||
);
|
||||
}
|
||||
self.tex = Some(tex.clone());
|
||||
self.attrs.changed = false;
|
||||
self.buf.changed = false;
|
||||
|
||||
@@ -22,3 +22,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
|
||||
[dev-dependencies]
|
||||
winit = { workspace = true }
|
||||
# For the `iris::input`/`iris::frame` round-trip test: a capturing `log::Log`
|
||||
# to read back what `iris::diagnostics::log_frame`/`sense::log_input_event`
|
||||
# wrote, pinned to the same version `iris/Cargo.toml` already carries.
|
||||
log = "0.4.28"
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers", for catching a fling:
|
||||
//! the real transcript screen over the real bench fixture, at the phone's
|
||||
//! size and density, with no window, no compositor and no GPU.
|
||||
//!
|
||||
//! docs/IRIS_TODO.md's 2026-09-07 night report -- "sometimes when I try to
|
||||
//! catch it while it's still moving (particularly if I drag) then it fails
|
||||
//! to stop & snap to where finger is". The finger goes down on content
|
||||
//! that is still travelling and the content does not follow it until
|
||||
//! `DRAG_SLOP` has been crossed, which at a fling's speed is several
|
||||
//! frames of the content sliding *away* from a finger that is already
|
||||
//! down. Compose does not do that: a down while `isScrollInProgress`
|
||||
//! starts the drag immediately (`scrollable`'s `startDragImmediately`).
|
||||
|
||||
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||
use iris::prelude::*;
|
||||
use iris::sense::DRAG_SLOP;
|
||||
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
|
||||
/// The screen open on the fixture, framed twice -- once to draw, once for
|
||||
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
|
||||
/// anchor, which is what every assertion about scroll position reads.
|
||||
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
/// Where the content is, in window pixels: the top of whichever row is
|
||||
/// under the middle of the viewport. `List` has no travel accessor and
|
||||
/// this needs none -- a row's own extent moves exactly as far as the
|
||||
/// content does, and the row is picked once so the two readings compare.
|
||||
fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) {
|
||||
let middle = phone_size().y / 2.0;
|
||||
let list = (screen.list)(&mut h.rsc);
|
||||
let key = list.key_at(middle).expect("a row under the viewport");
|
||||
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||
(key, top)
|
||||
}
|
||||
|
||||
fn row_top(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||
(screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the tracked row is still loaded")
|
||||
.0
|
||||
}
|
||||
|
||||
/// Three finger samples 8ms apart, each moving `STEP` further down the
|
||||
/// screen. `STEP * 3` is deliberately **under** `DRAG_SLOP`: a gesture
|
||||
/// this small moves nothing at all on a settled list (the control below),
|
||||
/// so anything it moves here is the catch and not the slop being crossed.
|
||||
const STEP: f32 = 2.0;
|
||||
const SAMPLES: usize = 3;
|
||||
const CATCH_X: f32 = 540.0;
|
||||
|
||||
/// Feeds the down and its `SAMPLES` moves from `y0` at `t0`, asserting
|
||||
/// after each one that the content moved by exactly the finger's own
|
||||
/// delta. Returns the release time.
|
||||
fn drag_from(
|
||||
h: &mut Harness,
|
||||
screen: &transcript_ui::TranscriptScreen,
|
||||
key: RowKey,
|
||||
y0: f32,
|
||||
t0: u64,
|
||||
expect_tracking: bool,
|
||||
) -> u64 {
|
||||
let before = row_top(h, screen, key);
|
||||
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
|
||||
assert_eq!(
|
||||
row_top(h, screen, key),
|
||||
before,
|
||||
"the down itself must not move the content, only stop it"
|
||||
);
|
||||
let mut t = t0;
|
||||
for i in 1..=SAMPLES {
|
||||
let moved = STEP * i as f32;
|
||||
t = t0 + 8 * i as u64;
|
||||
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
|
||||
let travelled = row_top(h, screen, key) - before;
|
||||
if expect_tracking {
|
||||
assert!(
|
||||
(travelled - moved).abs() < 0.5,
|
||||
"sample {i}: the finger has moved {moved}px since the down and the content \
|
||||
{travelled:.1}px -- it is not pinned to the finger"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
travelled.abs() < 0.5,
|
||||
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
|
||||
nothing, but the content moved {travelled:.1}px"
|
||||
);
|
||||
}
|
||||
}
|
||||
t += 8;
|
||||
h.touch(
|
||||
TouchAction::Up,
|
||||
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
|
||||
t,
|
||||
);
|
||||
t
|
||||
}
|
||||
|
||||
/// The report itself: flick, let the fling run for 150ms, then put a
|
||||
/// finger down and drag it a little. From the down onwards the content is
|
||||
/// pinned to the finger, sample for sample -- no slop, and no coasting
|
||||
/// past the place the finger stopped it.
|
||||
#[test]
|
||||
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
|
||||
// Frames, not a file: the second half of this gesture has to arrive
|
||||
// *while* the fling is ticking, and a `.touch` replay inserts no
|
||||
// frames between its samples, so a fling recorded that way would be
|
||||
// running on paper and stationary in fact.
|
||||
let catch_at = flick.end_ms() + 150;
|
||||
h.frames_until(
|
||||
flick.end_ms() + PHONE_FRAME_MS,
|
||||
catch_at - PHONE_FRAME_MS,
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
assert!(
|
||||
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||
"the fling must still be running 150ms in, or this test catches nothing"
|
||||
);
|
||||
|
||||
let (key, _) = tracked_row(&mut h, &screen);
|
||||
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
|
||||
}
|
||||
|
||||
/// The other half of the same rule: a catch that never moved at all is a
|
||||
/// `Released(None)`, not a tap. Compose's scrollable consumes that DOWN,
|
||||
/// so no click detector under it ever sees the gesture -- stopping a
|
||||
/// fling with a finger must not also follow the link it landed on, and
|
||||
/// must not hand the list a velocity to start again with.
|
||||
#[test]
|
||||
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
let catch_at = flick.end_ms() + 150;
|
||||
h.frames_until(
|
||||
flick.end_ms() + PHONE_FRAME_MS,
|
||||
catch_at - PHONE_FRAME_MS,
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
assert!(
|
||||
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||
"the fling must still be running 150ms in, or this test catches nothing"
|
||||
);
|
||||
|
||||
let (key, _) = tracked_row(&mut h, &screen);
|
||||
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
|
||||
let stopped_at = row_top(&mut h, &screen, key);
|
||||
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"a press that stopped a fling and moved nothing must not start another"
|
||||
);
|
||||
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
|
||||
assert!(
|
||||
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
|
||||
"the content moved after a catch was released without moving"
|
||||
);
|
||||
assert_eq!(
|
||||
h.state.opened_urls,
|
||||
Vec::<String>::new(),
|
||||
"a catch is not a tap: nothing under it may be followed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half this change had no reason to touch: on a list that is *not*
|
||||
/// moving, the same tiny drag is still inside `DRAG_SLOP` and still moves
|
||||
/// nothing. Without this, making every press pin the content would pass
|
||||
/// the test above and take the slop away from every ordinary press.
|
||||
#[test]
|
||||
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
// Long past the spline's own 2071ms for this recording.
|
||||
let settled = h.frames_until(
|
||||
flick.end_ms() + PHONE_FRAME_MS,
|
||||
flick.end_ms() + 4000,
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
assert!(
|
||||
!(screen.list)(&mut h.rsc).is_scrolling(),
|
||||
"the fling must have stopped, or this is the same case as the test above"
|
||||
);
|
||||
|
||||
let (key, _) = tracked_row(&mut h, &screen);
|
||||
drag_from(
|
||||
&mut h,
|
||||
&screen,
|
||||
key,
|
||||
1200.0,
|
||||
settled + PHONE_FRAME_MS,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers", for the diagnostics
|
||||
//! themselves rather than a widget: `iris::diagnostics::set_trace` gates
|
||||
//! `iris::input`/`iris::frame` (Iris's 2026-09-07 request, "add another
|
||||
//! button to copy input event info ... instrument a lot of the code with
|
||||
//! timings"), and `docs/REVIEW-2026-09-07.md`'s D1 found that the switch
|
||||
//! existed but four older per-frame `debug!` lines were not wired to it,
|
||||
//! filling the app's 2000-line log ring with frame spam before `Copy
|
||||
//! report` had a chance to include anything else. This is what a fix to
|
||||
//! that has to prove, both directions:
|
||||
//!
|
||||
//! 1. **Off** (the default): replaying a real gesture through a real
|
||||
//! screen leaves the ring holding nothing below `info` -- so the
|
||||
//! lines D1 named, and everything this pass gated the same way, really
|
||||
//! are silent by default rather than merely "usually quiet."
|
||||
//! 2. **On**: the same replay produces `iris::input` lines that
|
||||
//! `report_to_touch.py` turns back into the exact `TouchScript` that
|
||||
//! was replayed, and `iris::frame` lines with real, non-zero
|
||||
//! durations dated on the harness's own clock.
|
||||
//!
|
||||
//! **Single capturing logger, single test function** (this file's only
|
||||
//! `#[test]`): `log::set_logger` can succeed exactly once per process, and
|
||||
//! AGENTS.md's "tracing caches callsite interest process-wide" lesson is
|
||||
//! the general form of why every exercise of a logging path has to share
|
||||
//! one subscriber -- so if a second test here ever needs the ring's
|
||||
//! contents, it must extend this one rather than install its own.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
|
||||
/// Records every line's level and formatted message -- enough to answer
|
||||
/// both "is the ring quiet" (no line at `Debug` or below) and "what did
|
||||
/// tracing actually write" (the `iris::input` lines, read back by
|
||||
/// `report_to_touch.py`).
|
||||
struct CaptureLogger {
|
||||
lines: Mutex<Vec<(log::Level, String)>>,
|
||||
}
|
||||
|
||||
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
|
||||
|
||||
impl log::Log for CaptureLogger {
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.lines
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((record.level(), record.args().to_string()));
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Installs the capture logger at `Debug` -- the same level
|
||||
/// `iris/android-app/src/lib.rs`'s `JNI_OnLoad` installs at, which is
|
||||
/// exactly why `iris::diagnostics::trace_enabled` has to be the gate
|
||||
/// (its own module doc) rather than the level.
|
||||
fn logger() -> &'static CaptureLogger {
|
||||
let logger = LOGGER.get_or_init(|| CaptureLogger {
|
||||
lines: Mutex::new(Vec::new()),
|
||||
});
|
||||
// Ignore "already set": a previous call in this same test binary
|
||||
// already won, and it is the same logger either way.
|
||||
let _ = log::set_logger(logger);
|
||||
log::set_max_level(log::LevelFilter::Debug);
|
||||
logger
|
||||
}
|
||||
|
||||
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
|
||||
std::mem::take(&mut *logger.lines.lock().unwrap())
|
||||
}
|
||||
|
||||
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||
let logger = logger();
|
||||
|
||||
// --- (1) off: a real flick through a real screen leaves the ring
|
||||
// with nothing at `Debug` or below.
|
||||
iris::diagnostics::set_trace(false);
|
||||
drain(logger); // whatever `opened()` itself logged while building
|
||||
let (mut h, screen) = opened();
|
||||
drain(logger); // and whatever opening logged
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
|
||||
let quiet = drain(logger);
|
||||
let debug_lines: Vec<_> = quiet
|
||||
.iter()
|
||||
.filter(|(level, _)| *level == log::Level::Debug)
|
||||
.collect();
|
||||
assert!(
|
||||
debug_lines.is_empty(),
|
||||
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
|
||||
);
|
||||
|
||||
// --- (2) on: the same replay, from a fresh screen so the anchor and
|
||||
// sequence numbers match `flick-120hz.touch` exactly again.
|
||||
iris::diagnostics::set_trace(true);
|
||||
let (mut h, screen) = opened();
|
||||
drain(logger);
|
||||
h.replay(&flick);
|
||||
let _ = (screen.list)(&mut h.rsc);
|
||||
let traced = drain(logger);
|
||||
iris::diagnostics::set_trace(false); // leave it off for any test after this one
|
||||
|
||||
let input_lines: Vec<&str> = traced
|
||||
.iter()
|
||||
.filter(|(_, msg)| msg.contains("iris input: action="))
|
||||
.map(|(_, msg)| msg.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
input_lines.len(),
|
||||
flick.samples.len(),
|
||||
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
|
||||
);
|
||||
let frame_lines: Vec<&str> = traced
|
||||
.iter()
|
||||
.filter(|(_, msg)| msg.starts_with("iris frame:"))
|
||||
.map(|(_, msg)| msg.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
!frame_lines.is_empty(),
|
||||
"expected at least one `iris::frame` line once tracing was on"
|
||||
);
|
||||
for line in &frame_lines {
|
||||
// `layout=` and `draw=` are `{:?}`-formatted `Duration`s, so a real
|
||||
// one reads like `12.34µs`/`1.2ms`, never the bare `0ns` a
|
||||
// no-op frame would print.
|
||||
assert!(
|
||||
!line.contains("layout=0ns"),
|
||||
"a frame that redrew should not report zero layout time: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- the round trip: pipe every `iris::input` line through
|
||||
// `report_to_touch.py` and parse the result back into a `TouchScript`,
|
||||
// which must equal the one that was replayed. `report_to_touch.py`
|
||||
// is prefix-agnostic (it `search`es for the marker), so handing it
|
||||
// the bare message is the same as handing it a real ring line.
|
||||
let report = input_lines.join("\n");
|
||||
let script_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../benches/report_to_touch.py");
|
||||
let mut child = Command::new("python3")
|
||||
.arg(script_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("python3 must be on PATH to run report_to_touch.py");
|
||||
{
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(report.as_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
let output = child.wait_with_output().expect("report_to_touch.py exited");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"report_to_touch.py failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
|
||||
let round_tripped =
|
||||
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
|
||||
|
||||
assert_eq!(
|
||||
round_tripped.samples.len(),
|
||||
flick.samples.len(),
|
||||
"round trip produced a different number of samples:\n{touch_text}"
|
||||
);
|
||||
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
|
||||
assert_eq!(original.t_ms, back.t_ms);
|
||||
assert_eq!(original.action, back.action);
|
||||
assert_eq!(original.pos, back.pos);
|
||||
}
|
||||
}
|
||||
@@ -47,21 +47,53 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
let velocity = (screen.list)(&mut h.rsc)
|
||||
.fling_velocity()
|
||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||
// Compose's own answer for this recording's five samples, printed by
|
||||
// `iris/benches/velocity_reference.py` -- not a number read off this
|
||||
// code. Negative because the flick runs *down* the screen and
|
||||
// `Selection::drag` flings the list by `-v` (see its `Released` arm).
|
||||
// The 2026-09-07 before/after: the old average estimator read
|
||||
// -12250px/s here, which is the fling Iris reported as too slow.
|
||||
assert!(
|
||||
velocity.abs() > 1_000.0,
|
||||
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
|
||||
(velocity + 15_250.0).abs() < 20.0,
|
||||
"expected ~-15250px/s from velocity_reference.py, got {velocity}"
|
||||
);
|
||||
|
||||
// Android's own spline says how long a fling at this speed runs. The
|
||||
// list learns its density from the painter, so this is the same
|
||||
// curve it is using.
|
||||
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
|
||||
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
|
||||
// `iris/benches/fling_spline_reference.py`'s own line for this exact
|
||||
// case -- `density=2.55 v=15250.0: distance=11057.424px
|
||||
// duration=2.0716s`. **Not** `FlingCalculator::new(PHONE_SCALE)`,
|
||||
// which is the calculator under test: bounding a fling with the thing
|
||||
// being measured is the "compared the code with itself" shape 73f956f
|
||||
// found in the spline's own tests, and it left this one able to fail
|
||||
// in the "ran too long" direction only -- never in the "stopped dead"
|
||||
// direction, which is what Iris actually reported
|
||||
// (docs/REVIEW-2026-09-07.md's T1).
|
||||
const REFERENCE_MS: u64 = 2071;
|
||||
const REFERENCE_PX: f32 = 11057.0;
|
||||
let end = flick.end_ms() + REFERENCE_MS * 2;
|
||||
let mut settled_at = None;
|
||||
let mut t = flick.end_ms();
|
||||
// Travel in pixels, measured from a row's own on-screen extent, since
|
||||
// `List` has no travel accessor and this needs none: follow whatever
|
||||
// row is under the viewport's middle until it leaves, then pick
|
||||
// another. Deliberately an *under*-count -- the frame a row leaves on
|
||||
// contributes nothing -- which is why it is only ever a lower bound.
|
||||
let middle = phone_size().y / 2.0;
|
||||
let mut travelled = 0.0f32;
|
||||
let mut tracked: Option<(RowKey, f32)> = None;
|
||||
while t <= end {
|
||||
h.frame(t);
|
||||
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||
let list = (screen.list)(&mut h.rsc);
|
||||
tracked =
|
||||
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
|
||||
Some((key, was, now)) => {
|
||||
travelled += (now - was).abs();
|
||||
Some((key, now))
|
||||
}
|
||||
None => list
|
||||
.key_at(middle)
|
||||
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
||||
};
|
||||
if settled_at.is_none() && !list.is_scrolling() {
|
||||
settled_at = Some(t);
|
||||
}
|
||||
t += PHONE_FRAME_MS;
|
||||
@@ -74,10 +106,24 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
);
|
||||
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||
let ran_for = settled_at - flick.end_ms();
|
||||
// Both directions. The lower bound is the one that fails when a fling
|
||||
// settles on its first tick; the upper is the one that was here.
|
||||
assert!(
|
||||
ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
|
||||
"the fling ran {ran_for}ms against the spline's own {}ms",
|
||||
expected.as_millis()
|
||||
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
|
||||
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||
);
|
||||
assert!(
|
||||
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
|
||||
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||
);
|
||||
// 80% of the reference, against 10527px measured today -- the 5%
|
||||
// shortfall is the frames a tracked row leaves the screen on. A fling
|
||||
// that moves one row's worth fails this; scaling `tick_fling`'s delta
|
||||
// by 0.01 reports 111px, which is how it was confirmed to fail in the
|
||||
// direction the bug goes.
|
||||
assert!(
|
||||
travelled >= REFERENCE_PX * 0.8,
|
||||
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers", for the transcript's
|
||||
//! own edges: the real screen over the real fixture, under a header bar
|
||||
//! like the bench app's, driven by `iris::harness`.
|
||||
//!
|
||||
//! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report --
|
||||
//! rows scrolled above the viewport still drawn, over the header, and a
|
||||
//! blank band where the row straddling the top edge should be. Both are
|
||||
//! one rule (`List::intersects_viewport`): a row is drawn if any part of
|
||||
//! it is inside the list's own box, and nothing outside that box reaches
|
||||
//! the screen.
|
||||
|
||||
use iris::harness::Harness;
|
||||
use iris::prelude::*;
|
||||
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
|
||||
/// A header band above the transcript, as `bench_client.rs` puts one --
|
||||
/// the surface the rows were drawing over on the phone. Its exact height
|
||||
/// does not matter; what matters is that the list's own box does not
|
||||
/// start at the top of the window, so "above the viewport" and "off the
|
||||
/// screen" are different places.
|
||||
const HEADER_H: f32 = 300.0;
|
||||
const HEADER: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
|
||||
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let (opened, tree) = transcript_fixture::build_screen(&mut h.rsc).expect("the fixture folds");
|
||||
let content = WidgetPtr::new().add(&mut h.rsc);
|
||||
content(&mut h.rsc).set(tree);
|
||||
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(&mut h.rsc)
|
||||
.any();
|
||||
h.state.set_root(root);
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
/// The list's own on-screen box, in window pixels.
|
||||
fn list_box(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> PixelRegion {
|
||||
h.render
|
||||
.window_region(&screen.list.id(), &h.rsc)
|
||||
.expect("the list is on screen")
|
||||
}
|
||||
|
||||
/// Every row the list drew this frame, as `(top, bottom)` window pixels,
|
||||
/// topmost first. A `List`'s direct children are exactly its rows, and
|
||||
/// `draw_inner`'s old-children diffing means a row it did not place this
|
||||
/// frame is not among them.
|
||||
fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
||||
let mut rows: Vec<(f32, f32)> = h
|
||||
.render
|
||||
.active
|
||||
.get(&screen.list.id())
|
||||
.expect("the list is drawn")
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|id| h.render.window_region(id, &h.rsc))
|
||||
.map(|px| (px.top_left.y, px.bot_right.y))
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
rows
|
||||
}
|
||||
|
||||
/// Scrolls `amount` (negative walks back through older rows) and runs the
|
||||
/// frame it asks for, returning the time of the next one.
|
||||
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
||||
(screen.list)(&mut h.rsc).scroll(amount);
|
||||
h.frame(t);
|
||||
t + PHONE_FRAME_MS
|
||||
}
|
||||
|
||||
/// (a) of docs/IRIS_TODO.md's reproduction: with a row across the top
|
||||
/// edge, that row is placed -- the viewport's first pixel belongs to
|
||||
/// something. A rule that culled a row once its *top* left the viewport
|
||||
/// would leave a blank band here, which is the second of Iris's two
|
||||
/// screenshots.
|
||||
#[test]
|
||||
fn the_row_across_the_top_edge_is_drawn() {
|
||||
let (mut h, screen) = opened();
|
||||
let top = list_box(&h, &screen).top_left.y;
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
// 40px a frame, the shape a finger pan arrives in, through a straddle
|
||||
// and out the other side of it many times over.
|
||||
for _ in 0..60 {
|
||||
t = scrolled(&mut h, &screen, -40.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let first = *rows.first().expect("something is on screen");
|
||||
assert!(
|
||||
first.0 <= top + 0.5,
|
||||
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
|
||||
at {top:.1}",
|
||||
first.0 - top,
|
||||
first.0,
|
||||
);
|
||||
assert!(
|
||||
first.1 > top,
|
||||
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
|
||||
{top:.1}",
|
||||
first.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// (b): what falls outside the list's box is clipped rather than drawn
|
||||
/// over whatever is there. The straddling row above is drawn *in full*,
|
||||
/// so the only thing between its earlier lines and the header bar is this
|
||||
/// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run
|
||||
/// benchmark" button.
|
||||
#[test]
|
||||
fn the_list_is_clipped_to_its_own_box() {
|
||||
let (h, screen) = opened();
|
||||
let active = h.render.active.get(&screen.list.id()).expect("drawn");
|
||||
assert!(
|
||||
active.mask != MaskIdx::NONE,
|
||||
"the transcript's list is drawn with nothing clipping it",
|
||||
);
|
||||
let clip = h.rsc.ui.masks[active.mask.idx()].region.to_px(h.size());
|
||||
let list = list_box(&h, &screen);
|
||||
assert!(
|
||||
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
|
||||
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
|
||||
edge still draws past it",
|
||||
);
|
||||
|
||||
// And the mask has to *reach* what the rows draw. The two above say a
|
||||
// mask exists and sits in the right place; neither says any primitive
|
||||
// references it, so a broken `Mask::parent` chain -- what d507ae4
|
||||
// introduced -- would leave them green while a code fence inside a row
|
||||
// drew unclipped again (docs/REVIEW-2026-09-07.md's T3).
|
||||
let rows = h
|
||||
.render
|
||||
.active
|
||||
.get(&screen.list.id())
|
||||
.expect("the list is drawn")
|
||||
.children
|
||||
.clone();
|
||||
let mut checked = 0;
|
||||
for row in rows {
|
||||
for prim in primitives_under(&h, row) {
|
||||
assert!(
|
||||
mask_chain(&h, prim).contains(&active.mask),
|
||||
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
|
||||
own mask {:?}",
|
||||
mask_chain(&h, prim),
|
||||
active.mask,
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
checked > 0,
|
||||
"no row primitive was checked, so this test asserted nothing",
|
||||
);
|
||||
}
|
||||
|
||||
/// Every primitive `id` and its descendants drew, as `MaskIdx`es -- images
|
||||
/// excluded, since they live in a separate instance array with their own
|
||||
/// indices (`Primitives::free`).
|
||||
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
||||
let Some(active) = h.render.active.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<MaskIdx> = active
|
||||
.primitives
|
||||
.iter()
|
||||
.filter(|p| p.binding != IMAGE_BINDING)
|
||||
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
|
||||
.collect();
|
||||
for child in &active.children {
|
||||
out.extend(primitives_under(h, *child));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The chain the fragment stage walks from `mask`, outermost last.
|
||||
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
||||
let mut chain = Vec::new();
|
||||
let mut at = mask;
|
||||
while at != MaskIdx::NONE {
|
||||
assert!(
|
||||
!chain.contains(&at),
|
||||
"the mask chain from {mask:?} loops back to {at:?}",
|
||||
);
|
||||
chain.push(at);
|
||||
at = h.rsc.ui.masks[at.idx()].parent;
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// A row that has left the viewport entirely is not drawn at all. Before
|
||||
/// the fix the walk ran from the anchor -- which `scroll` leaves wherever
|
||||
/// it was, however far outside the viewport that ends up -- and drew
|
||||
/// every row on the way: 8 scrolls of 3000px left **64 rows** placed for
|
||||
/// a 2012px viewport, ~59 of them off screen and painting over the
|
||||
/// header.
|
||||
///
|
||||
/// The box is asserted on every leg *except the first*, because a row
|
||||
/// whose height has never been measured has to be drawn to be measured
|
||||
/// (`List::place`'s doc), which on the first walk back is every row
|
||||
/// entering from the top. Every later leg crosses the same rows with
|
||||
/// every height already known -- including the second walk *back*, which
|
||||
/// is there because a regression that draws rows in the wrong place while
|
||||
/// travelling backwards would otherwise be checked only by the row count
|
||||
/// (docs/REVIEW-2026-09-07.md's T2). That is also the ordinary state of a
|
||||
/// transcript being panned around in. The bound on how many rows are
|
||||
/// placed at once holds on all three.
|
||||
#[test]
|
||||
fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||
// A handful of rows whatever distance has been travelled -- the
|
||||
// module doc's own claim about this widget.
|
||||
assert!(
|
||||
rows.len() <= 24,
|
||||
"{leg} {step}: {} rows drawn for one 2012px viewport",
|
||||
rows.len(),
|
||||
);
|
||||
};
|
||||
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||
for &(top, bottom) in rows {
|
||||
assert!(
|
||||
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
|
||||
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
|
||||
{list:?} and was drawn anyway",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for step in 0..40 {
|
||||
t = scrolled(&mut h, &screen, -400.0, t);
|
||||
bounded(&drawn_rows(&h, &screen), "measuring", step);
|
||||
}
|
||||
for step in 0..40 {
|
||||
t = scrolled(&mut h, &screen, 400.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
bounded(&rows, "forward", step);
|
||||
inside(&rows, "forward", step);
|
||||
}
|
||||
for step in 0..40 {
|
||||
t = scrolled(&mut h, &screen, -400.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
bounded(&rows, "back", step);
|
||||
inside(&rows, "back", step);
|
||||
}
|
||||
}
|
||||
|
||||
/// The end the fix had no reason to touch: the row across the *bottom*
|
||||
/// edge, where the composer starts. Same rule, other direction -- and the
|
||||
/// list opens pinned there, so this is the ordinary state of the screen
|
||||
/// rather than a scrolled-to one.
|
||||
#[test]
|
||||
fn the_row_across_the_bottom_edge_is_drawn() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..40 {
|
||||
t = scrolled(&mut h, &screen, -37.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let last = *rows.last().expect("something is on screen");
|
||||
assert!(
|
||||
last.1 >= list.bot_right.y - 0.5,
|
||||
"a band of {:.1}px above the composer belongs to no row",
|
||||
list.bot_right.y - last.1,
|
||||
);
|
||||
assert!(
|
||||
last.0 < list.bot_right.y,
|
||||
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
|
||||
{:.1}",
|
||||
last.0,
|
||||
list.bot_right.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Panning past the first row settles *on* it rather than beyond it. The
|
||||
/// list is scrolled far further back than the fixture is long, which is
|
||||
/// what a hard fling toward the top does; before `List::clamp_to_content`
|
||||
/// it stayed wherever that left it -- the phone's "black from the header
|
||||
/// down", and a whole blank screen in `iris`'s own
|
||||
/// `fling_toward_the_start_stops_at_the_first_row`.
|
||||
#[test]
|
||||
fn scrolling_past_the_first_row_settles_on_it() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..60 {
|
||||
t = scrolled(&mut h, &screen, -100_000.0, t);
|
||||
}
|
||||
// The correction is written at the end of a draw and lands on the
|
||||
// next one.
|
||||
h.frame(t);
|
||||
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let first = *rows.first().expect("the first row is on screen");
|
||||
assert!(
|
||||
(first.0 - list.top_left.y).abs() < 0.5,
|
||||
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
|
||||
first.0 - list.top_left.y,
|
||||
);
|
||||
}
|
||||
|
||||
/// The same clamp at the other end, which is where Iris met it second
|
||||
/// ("you shouldn't be able to scroll below the bottom (or above top)").
|
||||
/// The list opens flush with its newest row, so this drags *forward* off
|
||||
/// the end of the content and back.
|
||||
#[test]
|
||||
fn scrolling_past_the_last_row_settles_on_it() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..20 {
|
||||
t = scrolled(&mut h, &screen, 100_000.0, t);
|
||||
}
|
||||
h.frame(t);
|
||||
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let last = *rows.last().expect("the last row is on screen");
|
||||
assert!(
|
||||
(last.1 - list.bot_right.y).abs() < 0.5,
|
||||
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
|
||||
blank",
|
||||
list.bot_right.y - last.1,
|
||||
);
|
||||
}
|
||||
@@ -90,11 +90,12 @@ where
|
||||
// where the bar actually was.
|
||||
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
|
||||
// clip that keeps six lines' worth of a longer message inside the
|
||||
// bar. The mask is the caller's job rather than `Scroll`'s own,
|
||||
// because `Painter::set_mask` allows exactly one mask per widget and
|
||||
// a `Scroll` nested under another masked area would abort on the
|
||||
// second -- `.masked()` is the one mechanism for clipping and this is
|
||||
// one more use of it (tabs-ui's message area is the other).
|
||||
// bar. The mask is the caller's job rather than `Scroll`'s own:
|
||||
// `.masked()` is the one mechanism for clipping and this is one more
|
||||
// use of it (tabs-ui's message area is the other). A `Scroll` nested
|
||||
// under another masked area used to abort here; since 2026-09-07 the
|
||||
// inner mask chains to the outer one (`Mask::parent`) and the content
|
||||
// is clipped by both.
|
||||
// Without it the overflow paints *above* the bar, over the
|
||||
// transcript: measured before this change at 58px of stray text for a
|
||||
// 475px message in a 417px box.
|
||||
|
||||
@@ -422,7 +422,13 @@ where
|
||||
|
||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||
|
||||
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
||||
// `.masked()`: the list draws the row straddling each of its edges in
|
||||
// full (`List::intersects_viewport`), so without a clip the top of
|
||||
// that row is drawn above the list -- through whatever the app put
|
||||
// there, which on the phone is the header bar (docs/IRIS_TODO.md,
|
||||
// 2026-09-07: "code and a paragraph visible behind Run benchmark").
|
||||
// The same clip is what `List::draw` asserts it has.
|
||||
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
|
||||
@@ -259,16 +259,26 @@ impl Selection {
|
||||
now: Instant,
|
||||
render: &UiRenderState,
|
||||
) -> GestureOutcome {
|
||||
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.
|
||||
// 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 -- and, since
|
||||
// 2026-09-07, *tells the gesture there was one*. A press that
|
||||
// caught moving content is a catch: it pans from this very sample
|
||||
// rather than waiting out `DRAG_SLOP`, which is what pins the
|
||||
// content to the finger instead of leaving it coasting for the
|
||||
// first few frames (Iris's "it fails to stop & snap to where
|
||||
// finger is"). See `DragArbiter::press_start` for Compose's own
|
||||
// mechanism. `starts_press` rather than a `PressStart` test of our
|
||||
// own, so this fires on the recovered-press frames too.
|
||||
let mut press = PressState::default();
|
||||
if self.gesture.starts_press(sense) {
|
||||
press.scrolling = list(ui).is_scrolling();
|
||||
list(ui).cancel_fling();
|
||||
}
|
||||
let already_selected = self.has_selection(ui);
|
||||
let outcome =
|
||||
self.gesture
|
||||
.handle(render, list.id(), sense, pos_window, now, already_selected);
|
||||
press.already_selected = self.has_selection(ui);
|
||||
let outcome = self
|
||||
.gesture
|
||||
.handle(render, list.id(), sense, pos_window, now, press);
|
||||
match outcome {
|
||||
GestureOutcome::Undecided => {}
|
||||
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||
@@ -299,8 +309,16 @@ impl Selection {
|
||||
// The half that actually makes it move -- see
|
||||
// `List::fling`'s doc. Without it the velocity is
|
||||
// computed, stored, and never advanced by anything.
|
||||
let id = list.id();
|
||||
ui.ui_mut().animate(id);
|
||||
//
|
||||
// Only when `fling` actually took it: below Compose's
|
||||
// `|v| <= 1.0`, or with no anchor, there is nothing to
|
||||
// tick, and registering an animation for a widget that is
|
||||
// not animating asks the next frame to find that out
|
||||
// (docs/REVIEW-2026-09-07.md's second nit).
|
||||
if list(ui).is_scrolling() {
|
||||
let id = list.id();
|
||||
ui.ui_mut().animate(id);
|
||||
}
|
||||
}
|
||||
// A tap is nobody's business here -- `row.rs` reads it from
|
||||
// the returned outcome and follows a link if one was under
|
||||
|
||||
@@ -110,13 +110,19 @@ const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0);
|
||||
/// The mark that says a card opens, always drawn from the **monospace**
|
||||
/// face.
|
||||
///
|
||||
/// Not a style choice: `NotoSans-Regular.ttf`, which every other string
|
||||
/// here is set in, has no glyph at U+25B8/U+25BE/U+25B4 at all, while
|
||||
/// `NotoSansMono-Regular.ttf` does -- read out of both bundled `cmap`s on
|
||||
/// 2026-09-06. A missing glyph is the failure nobody who wrote the code
|
||||
/// ever sees, so the face that has the glyph is named at the one place the
|
||||
/// character is written. IRIS_TODO's "a drawn chevron" has the real fix,
|
||||
/// which needs a line primitive iris does not have.
|
||||
/// Not a style choice, historically: with the bundled Noto Sans this crate
|
||||
/// used to embed, the sans face every other string here is set in had no
|
||||
/// glyph at U+25B8/U+25BE/U+25B4 at all, while the bundled monospace face
|
||||
/// did. As of 2026-09-07 iris takes both faces from the platform's own
|
||||
/// collection instead (DECISIONS.md's 2026-09-07 entry, matching what
|
||||
/// Compose ships), so this is no longer a checked fact about a specific
|
||||
/// font's `cmap` -- it is a bet that whatever the platform resolves for
|
||||
/// `Family::Monospace` covers these three codepoints, same as it was
|
||||
/// before. If a platform's monospace face turns out not to, the fallback
|
||||
/// chain still applies (see `TextData::default`'s doc comment) and the
|
||||
/// glyph should still land, just not necessarily monospaced. IRIS_TODO's
|
||||
/// "a drawn chevron" has the real fix, which needs a line primitive iris
|
||||
/// does not have.
|
||||
const CLOSED_MARK: &str = "\u{25b8}";
|
||||
const OPEN_MARK: &str = "\u{25be}";
|
||||
const UP_MARK: &str = "\u{25b4}";
|
||||
|
||||
+44
-5
@@ -120,6 +120,35 @@ struct Args {
|
||||
throwaway_sessions: bool,
|
||||
}
|
||||
|
||||
/// Where this run keeps its certificates: `--certs`, else the XDG default.
|
||||
/// One reader because `--enroll-link` returns before the rest of startup
|
||||
/// gets there, and a link minted against a different directory's CA than
|
||||
/// the server presents is a handshake failure with nothing on screen
|
||||
/// saying why.
|
||||
fn certs_dir(certs: &Option<std::path::PathBuf>) -> std::path::PathBuf {
|
||||
certs
|
||||
.clone()
|
||||
.unwrap_or_else(|| config_home("ai-app").join("certs"))
|
||||
}
|
||||
|
||||
/// The CA every enrollment link carries (`wg_app_link::enroll::ca_param`),
|
||||
/// so an app that was not built on this machine can still pin it -- the
|
||||
/// iris client is cross-compiled in a VM and run against this server.
|
||||
///
|
||||
/// `--enroll-link` reads it before the server has been anywhere near
|
||||
/// `certs::ensure`, so the file may genuinely not exist yet; the message
|
||||
/// says what makes it exist rather than reporting a bare ENOENT.
|
||||
fn read_ca(certs_dir: &std::path::Path) -> Result<String> {
|
||||
let path = certs_dir.join("ca.pem");
|
||||
std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no CA certificate at {} -- start ai-server once so it generates one, \
|
||||
or point --certs at the directory that has it",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Both rustls crypto providers are in the dependency graph (ureq brings
|
||||
@@ -160,7 +189,13 @@ async fn main() -> Result<()> {
|
||||
)?;
|
||||
println!(
|
||||
"{}",
|
||||
enroll::enrollment_uri("aiapp", bind_ip, args.port, &token)
|
||||
enroll::enrollment_uri(
|
||||
"aiapp",
|
||||
bind_ip,
|
||||
args.port,
|
||||
&token,
|
||||
Some(&read_ca(&certs_dir(&args.certs))?)
|
||||
)?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -216,9 +251,7 @@ async fn main() -> Result<()> {
|
||||
// Before the interface check below, deliberately: the certificates are also
|
||||
// what the phone app embeds at build time, so they need to be obtainable on
|
||||
// a machine whose tunnel isn't up yet. The leaf is reissued on every start.
|
||||
let certs_dir = args
|
||||
.certs
|
||||
.unwrap_or_else(|| config_home("ai-app").join("certs"));
|
||||
let certs_dir = certs_dir(&args.certs);
|
||||
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
|
||||
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
||||
if certificates.ca_is_new {
|
||||
@@ -252,7 +285,13 @@ async fn main() -> Result<()> {
|
||||
if rotating {
|
||||
tracing::info!("rotated the enrolled token; the previous one is now invalid");
|
||||
}
|
||||
enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?;
|
||||
enroll::print_enrollment(
|
||||
"aiapp",
|
||||
bind_ip,
|
||||
args.port,
|
||||
&token,
|
||||
Some(&read_ca(&certs_dir)?),
|
||||
)?;
|
||||
}
|
||||
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||
|
||||
+1
-1
Submodule wg-app-link updated: d35c880753...22ec18fcf2.
Reference in new issue
Block a user