Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38bf6309cb | ||
|
|
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"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+16
-1
@@ -404,12 +404,27 @@ done
|
|||||||
# Percent-encoded because the app URL-decodes the deep link's query: a
|
# 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.
|
# 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")
|
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
|
cat <<INFO
|
||||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
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)
|
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||||
|
|
||||||
enrol the emulator (once; it survives sandbox restarts):
|
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:
|
drive it:
|
||||||
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||||
|
|||||||
Generated
+2
@@ -46,7 +46,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
name = "client-core"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ ureq = { version = "3", features = ["json"] }
|
|||||||
# the same parser at the same version, rather than a hand-written splitter
|
# the same parser at the same version, rather than a hand-written splitter
|
||||||
# that would drift from it.
|
# that would drift from it.
|
||||||
pulldown-cmark = "0.13.4"
|
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]
|
[dev-dependencies]
|
||||||
|
|||||||
+232
-16
@@ -6,33 +6,57 @@
|
|||||||
//! the same text a phone would scan as a QR, with no second format
|
//! the same text a phone would scan as a QR, with no second format
|
||||||
//! invented for it (RUST.md's E4).
|
//! invented for it (RUST.md's E4).
|
||||||
//!
|
//!
|
||||||
//! What this type deliberately does not decide: where it is persisted, and
|
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
|
||||||
//! under what file permissions. A phone seals its token in the Android
|
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
|
||||||
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
//! desktop app, the app-private files directory on Android. **Which**
|
||||||
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
//! directory is the only part left to the platform: the format, the file
|
||||||
//! never in the repo). Both are caller-specific, so they stay out of this
|
//! mode and the "nothing saved yet is not an error" answer are the same on
|
||||||
//! crate per the code rules' "ask for the least you need" -- see
|
//! both, and were written twice before this.
|
||||||
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
//!
|
||||||
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||||
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
/// with `token` as a bearer header.
|
||||||
/// 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
|
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||||
/// client is told a path).
|
/// `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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct EnrolledServer {
|
pub struct EnrolledServer {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub token: String,
|
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 {
|
impl EnrolledServer {
|
||||||
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
|
||||||
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
/// does not matter; unrecognised keys are ignored). `token` is
|
||||||
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
|
||||||
/// contain `+`, which turns into a space if left to a naive splitter.
|
/// 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> {
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||||
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -44,6 +68,7 @@ impl EnrolledServer {
|
|||||||
let mut host = None;
|
let mut host = None;
|
||||||
let mut port = None;
|
let mut port = None;
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
|
let mut ca = None;
|
||||||
for pair in query.split('&') {
|
for pair in query.split('&') {
|
||||||
let Some((key, value)) = pair.split_once('=') else {
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
continue;
|
continue;
|
||||||
@@ -53,6 +78,7 @@ impl EnrolledServer {
|
|||||||
"host" => host = Some(value),
|
"host" => host = Some(value),
|
||||||
"port" => port = Some(value),
|
"port" => port = Some(value),
|
||||||
"token" => token = Some(value),
|
"token" => token = Some(value),
|
||||||
|
"ca" => ca = Some(value),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +89,14 @@ impl EnrolledServer {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
.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 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.
|
/// 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 {
|
fn percent_decode(s: &str) -> String {
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
let mut out = Vec::with_capacity(bytes.len());
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
@@ -108,6 +214,7 @@ mod tests {
|
|||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 8547,
|
port: 8547,
|
||||||
token: "abcDEF123".to_string(),
|
token: "abcDEF123".to_string(),
|
||||||
|
ca_pem: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
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]
|
#[test]
|
||||||
fn a_non_numeric_port_is_named_in_the_error() {
|
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();
|
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 durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod log_ring;
|
||||||
pub mod markdown_blocks;
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
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
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-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)
|
## 2026-09-06 (how a tool call looks, P1b)
|
||||||
|
|
||||||
- **A card that never got a result says "no result", in yellow, and it is
|
- **A 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,"
|
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||||
have the full account. The iris-vs-Masonry choice itself is still
|
have the full account. The iris-vs-Masonry choice itself is still
|
||||||
Iris's to make.
|
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.
|
||||||
+231
@@ -8,6 +8,170 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-08: masks have a shape -- `.masked_by(shape)`, and clipping applies to touch
|
||||||
|
|
||||||
|
A mask no longer carries a rectangle. It carries **the slot of a
|
||||||
|
primitive already drawn**, and the fragment stage evaluates that
|
||||||
|
primitive's own coverage at each masked pixel and multiplies it into the
|
||||||
|
alpha -- the same rounded-rect SDF the primitive itself is drawn with.
|
||||||
|
Nothing about the shape is copied, so a rounded container's corner and
|
||||||
|
the corner its content is cut to cannot fall out of step, and nested
|
||||||
|
masks multiply rather than intersect: a pixel inside two feathered
|
||||||
|
corners is dimmed by both.
|
||||||
|
|
||||||
|
// before -- the mask clipped to the padded box, the rounding was
|
||||||
|
// only painted behind it, and the two knew nothing of each other
|
||||||
|
field.scrollable_on(Axis::X)
|
||||||
|
.masked()
|
||||||
|
.pad(dp(FRAME_PAD_DP))
|
||||||
|
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
|
|
||||||
|
// after -- one rect, drawn and clipped to
|
||||||
|
field.scrollable_on(Axis::X)
|
||||||
|
.pad(dp(FRAME_PAD_DP))
|
||||||
|
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
|
|
||||||
|
`.masked()` is unchanged for callers and still clips to the widget's own
|
||||||
|
box; under it, it now writes an undrawn rect primitive and points the
|
||||||
|
mask at that, so square-cornered clipping is the same mechanism rather
|
||||||
|
than a special case. `.masked_by(shape)` draws `shape` behind the
|
||||||
|
content, in its own layer, and clips to the first primitive it drew.
|
||||||
|
There is no radius or shape argument anywhere -- that is the point.
|
||||||
|
|
||||||
|
**A press now has to be inside the shape, not just the box.** A corner
|
||||||
|
the container rounded away is not there to be tapped, which needed the
|
||||||
|
coverage function on the CPU as well as in the shader;
|
||||||
|
`iris/tests/mask_sdf.rs` runs the shader's own text against the Rust one
|
||||||
|
over a grid of points so the two cannot drift apart.
|
||||||
|
|
||||||
|
One limit worth knowing before reaching for it: **a mask's shape must be
|
||||||
|
a rect**, asserted by name. Clipping to a glyph or an image would need,
|
||||||
|
respectively, a CPU-side alpha plane for the hit test and a bind-group
|
||||||
|
switch the fragment stage cannot make. The shader has the branch where
|
||||||
|
either would go.
|
||||||
|
|
||||||
|
## 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
|
## 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".
|
Layer 1 and 2 of docs/RUST.md's "Three test layers".
|
||||||
@@ -1067,3 +1231,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
|
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
|
reply shapes exactly **1** text layout, the same as into a
|
||||||
one-paragraph one.
|
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.
|
||||||
+182
-7
@@ -701,12 +701,15 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
|||||||
has no rules for stays plain rather than being coloured by the
|
has no rules for stays plain rather than being coloured by the
|
||||||
nearest one's.
|
nearest one's.
|
||||||
|
|
||||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
- [x] **Masks defined relative to each other. (Done: chaining
|
||||||
by something *and also* applies mask B — a mask can reference a parent
|
2026-09-07 in d507ae4, the multiply 2026-09-08.)** Built exactly
|
||||||
mask, the way the move chain references a parent offset. Today masks
|
beside the move chain, as this asked: `Mask::parent` is a slot index
|
||||||
are independent regions. Design it beside the move chain (same shape:
|
and the fragment stage walks it under the same bound the move chain
|
||||||
a parent index and a bounded walk in the shader); do it when a real
|
uses. Each step multiplies the referenced primitive's coverage into
|
||||||
widget needs it, not before.
|
the pixel's alpha, so a pixel inside two feathered corners is dimmed
|
||||||
|
by both — the "multiplies by something *and also* applies mask B" half.
|
||||||
|
The real widget that needed it was the transcript's code fence inside
|
||||||
|
the list. See docs/LAYOUT.md's "Masks with a shape".
|
||||||
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
||||||
rejected, letting a scroll update one float rather than positions:
|
rejected, letting a scroll update one float rather than positions:
|
||||||
input handling cares about most elements in a list, so absolute
|
input handling cares about most elements in a list, so absolute
|
||||||
@@ -934,7 +937,7 @@ do not duplicate it there.
|
|||||||
|
|
||||||
## From the phone, 2026-09-07 (build from ed04d4c)
|
## 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
|
them."** Two screenshots of the bench app's transcript at the top
|
||||||
edge, both wrong in opposite directions: in one, rows scrolled above
|
edge, both wrong in opposite directions: in one, rows scrolled above
|
||||||
the viewport are still drawn and bleed *through* the header bar
|
the viewport are still drawn and bleed *through* the header bar
|
||||||
@@ -955,3 +958,175 @@ do not duplicate it there.
|
|||||||
the header's bottom must be masked. Fix both with one rule: a row is
|
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
|
drawn if any part of it intersects the viewport, and the viewport is
|
||||||
the list's own region.
|
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:
|
||||||
|
|
||||||
|
- [x] **"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." (done 2026-09-07, b87f5a5.)** Built as described below.
|
||||||
|
`DragArbiter::press_start` takes a `PressState` -- what the target
|
||||||
|
looked like at the moment the press landed -- rather than asking the
|
||||||
|
list later, because by then the fling has already been cancelled and
|
||||||
|
the answer is no. The defect layer 1 found doing it: one touch-down
|
||||||
|
reaches every sensor under the finger, so a block and the tool row
|
||||||
|
containing it deliver the same `PressStart` twice, and re-reading the
|
||||||
|
state on the second delivery turned every catch back into an ordinary
|
||||||
|
slop-waiting press. Tests in
|
||||||
|
`iris/transcript-fixture/tests/catch_a_fling.rs`, with
|
||||||
|
`the_same_small_drag_on_a_settled_list_moves_nothing` as the half the
|
||||||
|
change had no reason to touch. **Not yet confirmed from the phone.**
|
||||||
|
The original reading follows. 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.
|
||||||
+36
-1
@@ -948,7 +948,7 @@ When this lands, copy this entry into `IRIS.md` (newest first):
|
|||||||
> design, the move-offset mechanism this shipped alongside, and the file
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
> list.
|
> list.
|
||||||
|
|
||||||
## Masks with a shape (decided 2026-09-07, not yet built)
|
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
|
||||||
|
|
||||||
Iris, on the code block's scrolling: "the code block scrolling currently
|
Iris, on the code block's scrolling: "the code block scrolling currently
|
||||||
masks in an inner rectangle. Ideally masks should have a shape
|
masks in an inner rectangle. Ideally masks should have a shape
|
||||||
@@ -1039,3 +1039,38 @@ and that the CPU SDF and the shader agree at a grid of points; a
|
|||||||
`run-headless.sh --phone` screenshot of a scrolled code block shows
|
`run-headless.sh --phone` screenshot of a scrolled code block shows
|
||||||
rounded corners with no square pixels poking out at the top and bottom
|
rounded corners with no square pixels poking out at the top and bottom
|
||||||
of the scrolled content. Record the commands in RUST.md when it lands.
|
of the scrolled content. Record the commands in RUST.md when it lands.
|
||||||
|
|
||||||
|
### What was built (2026-09-08), and where it differs
|
||||||
|
|
||||||
|
The commands and the screenshot are in docs/RUST.md's queue entry. Four
|
||||||
|
places the code is narrower than the design above, each deliberate:
|
||||||
|
|
||||||
|
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
|
||||||
|
The referenced instance already carries its own `binding`, so a copy
|
||||||
|
of it in the mask is a second thing to keep in step; *alpha only* is
|
||||||
|
the only mode there is, so there is nothing to select. Both are a
|
||||||
|
field away if a second mode appears.
|
||||||
|
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
|
||||||
|
by name, rather than leaving the shader to read a `rects` entry that
|
||||||
|
is not there. A glyph would need a CPU-side alpha plane before the
|
||||||
|
hit test could agree with the shader, and a standalone image needs a
|
||||||
|
bind-group switch the fragment stage cannot make (`masks_layout`'s own
|
||||||
|
comment on why an image's bind group must not name the masks buffer).
|
||||||
|
So **the texture-mask pass condition is not met and no texture mask
|
||||||
|
exists** — the point of the reference design is that adding one is a
|
||||||
|
binding check and a sampled alpha, with no new shader path, and the
|
||||||
|
shader's `mask_coverage` already has the branch where it would go.
|
||||||
|
- **The shape is a primitive of its own, not always a drawn one.** A
|
||||||
|
plain `.masked()` writes an undrawn `RectPrimitive` at its region
|
||||||
|
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
|
||||||
|
box" and "clip to that widget's rounded background" are one mechanism
|
||||||
|
and square-cornered clipping did not become a special case.
|
||||||
|
`.masked_by(shape)` draws `shape` behind the content — in its own
|
||||||
|
layer, the way `Stack` puts a background under its content — and
|
||||||
|
clips to the first primitive it drew.
|
||||||
|
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
|
||||||
|
the only test in the workspace that needs an adapter. It lifts
|
||||||
|
`distance_from_rect` and `rounded_rect_coverage` out of
|
||||||
|
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
|
||||||
|
so the thing under test is the shader itself rather than a copy of it
|
||||||
|
that would be edited alongside.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
+1005
-13
File diff suppressed because it is too large.
Load diff
Generated
+4
@@ -720,7 +720,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
name = "client-core"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1735,6 +1737,7 @@ dependencies = [
|
|||||||
"accesskit_winit",
|
"accesskit_winit",
|
||||||
"android-view",
|
"android-view",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"bytemuck",
|
||||||
"image",
|
"image",
|
||||||
"iris-core",
|
"iris-core",
|
||||||
"iris-macro",
|
"iris-macro",
|
||||||
@@ -3662,6 +3665,7 @@ dependencies = [
|
|||||||
"client-core",
|
"client-core",
|
||||||
"event-model",
|
"event-model",
|
||||||
"iris",
|
"iris",
|
||||||
|
"log",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"transcript-ui",
|
"transcript-ui",
|
||||||
"winit",
|
"winit",
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"]
|
|||||||
# package is fine -- cargo excludes dev-dependencies from the graph used
|
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||||
# to build the library itself, so this only matters for `--examples`.
|
# to build the library itself, so this only matters for `--examples`.
|
||||||
tabs-ui = { path = "tabs-ui" }
|
tabs-ui = { path = "tabs-ui" }
|
||||||
|
# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it
|
||||||
|
# reads back. wgpu and pollster are ordinary dependencies already.
|
||||||
|
bytemuck = { workspace = true }
|
||||||
|
|
||||||
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||||
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||||
|
|||||||
Generated
+2
@@ -744,7 +744,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|||||||
name = "client-core"
|
name = "client-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"event-model",
|
"event-model",
|
||||||
|
"log",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
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]
|
[profile.dev]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
@@ -24,8 +24,45 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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" />
|
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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;
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
@@ -20,9 +22,28 @@ public final class MainActivity extends Activity {
|
|||||||
System.loadLibrary("main");
|
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
|
@Override
|
||||||
public void onCreate(Bundle state) {
|
public void onCreate(Bundle state) {
|
||||||
super.onCreate(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);
|
IrisView view = new IrisView(this);
|
||||||
view.setLayoutParams(new FrameLayout.LayoutParams(
|
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
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
|
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||||
* place that reads these fields, so the static dispatch and the
|
* place that reads these fields, so the static dispatch and the
|
||||||
* animation callback above cannot come to report different things. */
|
* 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>;
|
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
|
/// 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
|
/// 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
|
/// (`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),
|
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||||
/// so the row's reserved height in the outer `Span::DOWN`
|
/// so the row's reserved height in the outer `Span::DOWN`
|
||||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
/// (`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 {
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||||
.on(
|
.on(
|
||||||
@@ -453,7 +495,9 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
.label("Run benchmark");
|
.label("Run benchmark");
|
||||||
let run = (
|
let run = (
|
||||||
run_rect,
|
run_rect,
|
||||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
wtext("Run benchmark")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.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))
|
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
ctx.state.copy_report();
|
ctx.state.copy_report(rsc);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.label("Copy report");
|
.label("Copy report");
|
||||||
let copy = (
|
let copy = (
|
||||||
copy_rect,
|
copy_rect,
|
||||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
wtext("Copy report")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
@@ -485,17 +531,61 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
.label("Diagnostics");
|
.label("Diagnostics");
|
||||||
let diagnostics = (
|
let diagnostics = (
|
||||||
diag_rect,
|
diag_rect,
|
||||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
wtext("Diagnostics")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.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)
|
(rect(HEADER_SURFACE), buttons)
|
||||||
.stack()
|
.stack()
|
||||||
.height(dp(56))
|
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||||
.pad(Padding::top(top_pad))
|
.pad(Padding::top(top_pad))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
@@ -526,6 +616,25 @@ impl BenchClient {
|
|||||||
self.last_report = Some(report);
|
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
|
/// The diagnostics report as text, with no side effect on what is on
|
||||||
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||||
/// the keyboard-open capture (which only logs it), so the two can
|
/// 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
|
// logcat on her phone, and "the keyboard does not push the
|
||||||
// composer up" cannot be told from "the listener never fired"
|
// composer up" cannot be told from "the listener never fired"
|
||||||
// without it (`AndroidUiState::insets_report`).
|
// 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
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
@@ -565,16 +687,40 @@ impl BenchClient {
|
|||||||
log::info!("iris keyboard diagnostics:\n{report}");
|
log::info!("iris keyboard diagnostics:\n{report}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_report(&mut self) {
|
/// Always copies something, and never depends on `Diagnostics` or
|
||||||
let Some(report) = &self.last_report else {
|
/// `Run benchmark` having been pressed first (docs/IRIS_TODO.md,
|
||||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
/// 2026-09-07 night: "the copy report button seemed impossible to hit
|
||||||
return;
|
/// 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 {
|
let Some(platform) = &self.platform else {
|
||||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
return;
|
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");
|
log::info!("iris bench report: copied to clipboard");
|
||||||
} else {
|
} else {
|
||||||
log::info!("iris bench report: clipboard copy failed");
|
log::info!("iris bench report: clipboard copy failed");
|
||||||
@@ -603,6 +749,11 @@ impl BenchClient {
|
|||||||
.and_then(|p| p.refresh_rate_hz())
|
.and_then(|p| p.refresh_rate_hz())
|
||||||
.unwrap_or(60.0);
|
.unwrap_or(60.0);
|
||||||
let cpu_start = process_cpu_ms();
|
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();
|
let run_started_at = Instant::now();
|
||||||
|
|
||||||
rsc.spawn_task(async move |mut ctx| {
|
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: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||||
TYPE_TEXT.chars().count()
|
TYPE_TEXT.chars().count()
|
||||||
);
|
);
|
||||||
|
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||||
let report = format!(
|
let report = format!(
|
||||||
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
|
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
|
||||||
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
|
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
|
||||||
|
{rss_line}\n{battery}"
|
||||||
);
|
);
|
||||||
log::info!("iris bench report: {report}");
|
log::info!("iris bench report: {report}");
|
||||||
state.report_display.edit(rsc).set(&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,
|
Context, View,
|
||||||
jni::{
|
jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
|
objects::{JClass, JString},
|
||||||
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||||
},
|
},
|
||||||
register_view_class,
|
register_view_class,
|
||||||
@@ -51,10 +52,25 @@ use iris::prelude::*;
|
|||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use std::ffi::c_void;
|
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")]
|
#[cfg(feature = "bench")]
|
||||||
mod bench_client;
|
mod bench_client;
|
||||||
#[cfg(feature = "bench")]
|
#[cfg(feature = "bench")]
|
||||||
mod bench_jni;
|
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")))]
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
mod transcript_client;
|
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.
|
/// mirrors android-view's own demo, which carries the same comment.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
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::init_once(
|
||||||
android_logger::Config::default()
|
android_logger::Config::default()
|
||||||
.with_max_level(LevelFilter::Debug)
|
.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);
|
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||||
JNI_VERSION_1_6
|
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
|
//! **Deliberate simplification, recorded rather than left to be
|
||||||
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
|
//! 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
|
//! session list here -- the first session `ApiClient::fetch_sessions`
|
||||||
//! pinned CA are baked in at build time (`build.rs`'s
|
//! returns is opened automatically, since there is nothing to tap to get
|
||||||
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
|
//! there, which is what `transcript-bench.sh` and `ui-trace` need to land
|
||||||
//! session `ApiClient::fetch_sessions` returns is opened automatically --
|
//! straight on the screen under test.
|
||||||
//! 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
|
//! Which server it opens it against is no longer baked in: it is the
|
||||||
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
|
//! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`,
|
||||||
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
|
//! and `desktop-app`'s identical `--link`), because an APK
|
||||||
//! not this pass's job.
|
//! cross-compiled here cannot pin the CA of a server on the host.
|
||||||
//!
|
//!
|
||||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||||
@@ -46,10 +46,6 @@ use iris::prelude::*;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
mod pinned {
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TranscriptClient {
|
pub struct TranscriptClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
/// The screen's own content -- everything under the fixed
|
/// 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
|
/// Builds one `UreqTransport` from the stored enrollment. Called twice per
|
||||||
/// twice per session load, same as `desktop-app`'s `build_transport`
|
/// session load, same as `desktop-app`'s `build_transport` closure --
|
||||||
/// closure -- `ApiClient` and the live-stream follow each need their own,
|
/// `ApiClient` and the live-stream follow each need their own, since
|
||||||
/// since `UreqTransport` holds its own `ureq::Agent`.
|
/// `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> {
|
fn build_transport() -> Result<UreqTransport, String> {
|
||||||
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
|
crate::enrollment::transport()
|
||||||
UreqTransport::new(
|
|
||||||
base_url,
|
|
||||||
pinned::TOKEN.to_string(),
|
|
||||||
pinned::CA_PEM.as_bytes(),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
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.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||||
# 2.75 is this checkout's emulator.
|
# 2.75 is this checkout's emulator.
|
||||||
for density in (2.55, 2.75):
|
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)
|
dur = fling_duration_s(velocity, density)
|
||||||
print(
|
print(
|
||||||
f"density={density} v={velocity}: "
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||||
pub enum Axis {
|
pub enum Axis {
|
||||||
X,
|
X,
|
||||||
Y,
|
Y,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
use std::ops::{Index, IndexMut};
|
use std::ops::{Index, IndexMut};
|
||||||
|
|
||||||
use crate::{
|
use crate::{render::LayerOrder, util::to_mut};
|
||||||
UiRegion, WidgetId,
|
|
||||||
render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
|
|
||||||
util::to_mut,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type LayerId = usize;
|
pub type LayerId = usize;
|
||||||
|
|
||||||
@@ -40,7 +36,10 @@ struct Child {
|
|||||||
tail: usize,
|
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> {
|
impl<T: Default> Layers<T> {
|
||||||
pub fn new() -> 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> {
|
impl<T: Default> Default for Layers<T> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|||||||
+109
-90
@@ -2,34 +2,14 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
|
|||||||
use parley::{
|
use parley::{
|
||||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||||
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||||
fontique::{Blob, FamilyId},
|
|
||||||
};
|
};
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
|
||||||
use swash::{
|
use swash::{
|
||||||
FontRef,
|
FontRef,
|
||||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||||
zeno::{Format, Vector},
|
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
|
/// 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
|
/// 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
|
/// once at startup ... the number of font families found, the default
|
||||||
@@ -80,91 +60,130 @@ pub struct TextData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Default for 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 {
|
fn default() -> Self {
|
||||||
let mut data = Self {
|
let mut font_cx = FontContext::new();
|
||||||
font_cx: FontContext::new(),
|
patch_android_monospace(&mut font_cx);
|
||||||
|
Self {
|
||||||
|
font_cx,
|
||||||
layout_cx: LayoutContext::new(),
|
layout_cx: LayoutContext::new(),
|
||||||
scale_cx: ScaleContext::new(),
|
scale_cx: ScaleContext::new(),
|
||||||
atlas: GlyphAtlas::default(),
|
atlas: GlyphAtlas::default(),
|
||||||
density: 1.0,
|
density: 1.0,
|
||||||
};
|
}
|
||||||
data.register_bundled_fonts();
|
|
||||||
data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextData {
|
/// Works around `fontique` 0.11.1's Android backend never resolving
|
||||||
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
|
/// `GenericFamily::Monospace` (confirmed against
|
||||||
/// Mono (regular/bold) as static faces, and puts them **first** in the
|
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
|
||||||
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
|
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
|
||||||
/// not instead of, whatever the platform already found, so a script
|
/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry,
|
||||||
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
|
/// "Platform fonts," for the full account). Two bugs stack, not one:
|
||||||
/// font the same as before this existed.
|
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
|
||||||
///
|
/// `fonts.xml` is parsed into that same name map, and even after parsing,
|
||||||
/// Exists because text rendering must not depend on the platform's own
|
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
|
||||||
/// font enumeration succeeding or resolving weight/style the way this
|
/// (not an `<alias>`) whose `<font>` children the backend's own parser
|
||||||
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
|
/// does not read (a `TODO` in that match arm) -- so the name gets a
|
||||||
/// rendering as blank gaps of the correct advance width (the glyph
|
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
|
||||||
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
|
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
|
||||||
/// the emulator's system fonts happened to resolve every style. A
|
/// /system/etc/fonts.xml` shows
|
||||||
/// bundled, static-per-style family removes fontique's Android font
|
/// `<family name="monospace"><font weight="400"
|
||||||
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
|
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
|
||||||
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
|
/// alias.
|
||||||
/// has to survive to reach the screen at all.
|
///
|
||||||
///
|
/// So this reads `fonts.xml` itself (already on-device, already the
|
||||||
/// Cost: six static `.ttf`s, ~3.6 MB uncompressed
|
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
|
||||||
/// (`iris/core/assets/fonts/`), landing in the APK compressed --
|
/// filename that declaration names, then finds which of fontique's
|
||||||
/// `build-apk.sh`'s own output is what says the delivered number, not
|
/// *actually* scanned families (from `/system/fonts`, which do carry real
|
||||||
/// this comment.
|
/// font data, just under whatever name the font's own metadata gives it --
|
||||||
fn register_bundled_fonts(&mut self) {
|
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
|
||||||
fn register(cx: &mut FontContext, bytes: &'static [u8]) -> Option<FamilyId> {
|
/// file with that name, and registers that family as the `Monospace`
|
||||||
let blob = Blob::new(Arc::new(bytes));
|
/// generic the way the backend itself would have if its parser had reified
|
||||||
cx.collection
|
/// the declaration. A no-op if the family is somehow already resolved
|
||||||
.register_fonts(blob, None)
|
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
|
||||||
.into_iter()
|
/// test, or a device that names it some other way).
|
||||||
.map(|(id, _)| id)
|
#[cfg(target_os = "android")]
|
||||||
.next()
|
fn patch_android_monospace(font_cx: &mut FontContext) {
|
||||||
}
|
use parley::fontique::SourceKind;
|
||||||
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);
|
|
||||||
|
|
||||||
if let Some(sans_id) = sans_id {
|
let already_resolved = font_cx
|
||||||
let existing: Vec<_> = self
|
|
||||||
.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
|
.collection
|
||||||
.generic_families(GenericFamily::Monospace)
|
.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();
|
.collect();
|
||||||
self.font_cx.collection.set_generic_families(
|
for name in names {
|
||||||
GenericFamily::Monospace,
|
let Some(id) = font_cx.collection.family_id(&name) else {
|
||||||
std::iter::once(mono_id).chain(existing),
|
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
|
||||||
|
.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
|
/// Builds the startup report -- see `FontDiagnostics`. Queries the
|
||||||
/// collection directly (`fontique::Query`) rather than shaping a real
|
/// collection directly (`fontique::Query`) rather than shaping a real
|
||||||
/// string, since all that's needed is which family each axis lands on.
|
/// string, since all that's needed is which family each axis lands on.
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ pub struct WindowUniform {
|
|||||||
pub height: f32,
|
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)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct PrimitiveInstance {
|
pub struct PrimitiveInstance {
|
||||||
@@ -18,24 +27,17 @@ pub struct PrimitiveInstance {
|
|||||||
pub move_idx: MoveIdx,
|
pub move_idx: MoveIdx,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveInstance {
|
/// The vertex layout of a layer's draw order: one `u32` slot into the
|
||||||
const ATTRIBS: [VertexAttribute; 8] = vertex_attr_array![
|
/// global instance arena per instance, stepped per instance. Everything a
|
||||||
0 => Float32x2,
|
/// primitive is made of used to be here as eight vertex attributes; it
|
||||||
1 => Float32x2,
|
/// moved into the storage buffer above so the fragment stage can read it
|
||||||
2 => Float32x2,
|
/// too.
|
||||||
3 => Float32x2,
|
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
|
||||||
4 => Uint32,
|
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
|
||||||
5 => Uint32,
|
|
||||||
6 => Uint32,
|
|
||||||
7 => Uint32,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn desc() -> VertexBufferLayout<'static> {
|
|
||||||
VertexBufferLayout {
|
VertexBufferLayout {
|
||||||
array_stride: std::mem::size_of::<Self>() as BufferAddress,
|
array_stride: std::mem::size_of::<u32>() as BufferAddress,
|
||||||
step_mode: VertexStepMode::Instance,
|
step_mode: VertexStepMode::Instance,
|
||||||
attributes: &Self::ATTRIBS,
|
attributes: &ATTRIBS,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,15 +49,53 @@ impl MaskIdx {
|
|||||||
|
|
||||||
pub type MoveIdx = Id<u32>;
|
pub type MoveIdx = Id<u32>;
|
||||||
|
|
||||||
|
/// A clip, as a reference to a primitive already written plus the mask it
|
||||||
|
/// nests inside. The fragment stage evaluates that primitive's coverage
|
||||||
|
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
|
||||||
|
/// from the same SDF the rect itself is drawn with -- and multiplies it
|
||||||
|
/// into the pixel's alpha, so a rounded container's corner and its
|
||||||
|
/// children's clipped corner are the same arithmetic and cannot disagree.
|
||||||
|
/// See LAYOUT.md's "Masks with a shape".
|
||||||
|
///
|
||||||
|
/// **No `kind` and no `flags`**, which the design sketched: the referenced
|
||||||
|
/// instance already carries its own `binding`, and a copy of it here is a
|
||||||
|
/// second thing to keep in step; alpha-only is the only mode there is, so
|
||||||
|
/// there is nothing to select. Both are a field away if a second mode
|
||||||
|
/// appears.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct Mask {
|
pub struct Mask {
|
||||||
pub region: UiRegion,
|
/// The slot in `UiRenderState::primitives` of the primitive whose
|
||||||
/// The mask-owning widget's own move slot -- resolved in the fragment
|
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
|
||||||
/// shader against the same chain the vertex shader walks for a
|
/// a standalone image would need, respectively, a CPU-side alpha
|
||||||
/// primitive's own corners, so a mask and the content clipped by it
|
/// plane for the hit test to agree with the shader, and a bind-group
|
||||||
/// can move independently. See LAYOUT.md section 2b.
|
/// switch the fragment stage cannot make -- `Painter::set_mask`
|
||||||
pub move_idx: MoveIdx,
|
/// rejects both by name rather than leaving the shader to read a rect
|
||||||
|
/// that is not there.
|
||||||
|
///
|
||||||
|
/// Who owns it depends on which way the mask was set. A plain
|
||||||
|
/// `.masked()` writes its own undrawn rect, so the primitive is in
|
||||||
|
/// the masking widget's `ActiveData::primitives` and lives exactly as
|
||||||
|
/// long as the mask. `.masked_by(shape)` points at a *child's*
|
||||||
|
/// primitive, which that child can free on any redraw of its own --
|
||||||
|
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
|
||||||
|
/// redraw whenever a referenced slot is freed, since that widget's
|
||||||
|
/// own `set_mask` is the only thing that resolves the slot again.
|
||||||
|
pub primitive: u32,
|
||||||
|
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
|
||||||
|
/// clipping nests: the fragment stage walks the chain and multiplies
|
||||||
|
/// every coverage on it, which is what makes a pixel inside two
|
||||||
|
/// feathered corners dimmed by both. 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
|
/// One widget's cumulative on-screen translation, and the slot of the
|
||||||
|
|||||||
+101
-71
@@ -1,6 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
UiData, UiRenderState,
|
UiData, UiRenderState,
|
||||||
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
|
render::{
|
||||||
|
data::{PrimitiveInstance, instance_slot_layout},
|
||||||
|
texture::GpuTextures,
|
||||||
|
util::ArrBuf,
|
||||||
|
},
|
||||||
util::{HashMap, Vec2},
|
util::{HashMap, Vec2},
|
||||||
};
|
};
|
||||||
use data::WindowUniform;
|
use data::WindowUniform;
|
||||||
@@ -14,6 +18,7 @@ mod atlas;
|
|||||||
mod data;
|
mod data;
|
||||||
mod frame_report;
|
mod frame_report;
|
||||||
mod primitive;
|
mod primitive;
|
||||||
|
mod sdf;
|
||||||
mod texture;
|
mod texture;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
@@ -21,8 +26,14 @@ pub use atlas::*;
|
|||||||
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
|
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
|
||||||
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
|
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
|
||||||
pub use primitive::*;
|
pub use primitive::*;
|
||||||
|
pub use sdf::{distance_from_rect, rounded_rect_coverage};
|
||||||
|
|
||||||
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
/// The one shader every primitive is drawn with. Public so a test can run
|
||||||
|
/// a function out of it against the CPU transliteration in [`sdf`] --
|
||||||
|
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
|
||||||
|
/// on: a masked corner that cannot be tapped and a masked corner that is
|
||||||
|
/// not drawn are only the same corner while the two agree.
|
||||||
|
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
||||||
|
|
||||||
/// The `wgpu::Limits` both platform backends (`android::render::
|
/// The `wgpu::Limits` both platform backends (`android::render::
|
||||||
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
|
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
|
||||||
@@ -120,6 +131,11 @@ impl WgpuErrorLog {
|
|||||||
pub struct UiRenderNode {
|
pub struct UiRenderNode {
|
||||||
uniform_group: BindGroup,
|
uniform_group: BindGroup,
|
||||||
primitive_layout: BindGroupLayout,
|
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_layout: BindGroupLayout,
|
||||||
rsc_group: BindGroup,
|
rsc_group: BindGroup,
|
||||||
|
|
||||||
@@ -129,6 +145,9 @@ pub struct UiRenderNode {
|
|||||||
active: Vec<usize>,
|
active: Vec<usize>,
|
||||||
window_buffer: Buffer,
|
window_buffer: Buffer,
|
||||||
textures: GpuTextures,
|
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>,
|
masks: ArrBuf<Mask>,
|
||||||
move_offsets: ArrBuf<MoveOffset>,
|
move_offsets: ArrBuf<MoveOffset>,
|
||||||
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
||||||
@@ -146,16 +165,16 @@ pub struct UiRenderNode {
|
|||||||
masks_group: BindGroup,
|
masks_group: BindGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One layer's vertex buffers: the slots it draws, in order. The
|
||||||
|
/// primitives themselves are in `UiRenderNode::instances`.
|
||||||
struct RenderLayer {
|
struct RenderLayer {
|
||||||
instance: ArrBuf<PrimitiveInstance>,
|
order: ArrBuf<u32>,
|
||||||
primitives: PrimitiveBuffers,
|
/// A standalone image's slots, kept apart from `order` because each
|
||||||
primitive_group: BindGroup,
|
/// one draws with its own bind group -- see `UiRenderNode::draw`.
|
||||||
/// A standalone image's instances, kept apart from `instance` because
|
images: ArrBuf<u32>,
|
||||||
/// each one draws with its own bind group -- see `UiRenderNode::draw`.
|
/// The texture slot each entry of `images` draws with, in the same
|
||||||
image_instance: ArrBuf<PrimitiveInstance>,
|
/// order, refreshed alongside it. Not in the vertex buffer itself
|
||||||
/// The texture slot each entry of `image_instance` draws with, in the
|
/// because it names a bind group, not shader data.
|
||||||
/// same order, refreshed alongside it. Not stored in the vertex buffer
|
|
||||||
/// itself because it names a bind group, not shader data.
|
|
||||||
image_tex_indices: Vec<u32>,
|
image_tex_indices: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +182,8 @@ impl UiRenderNode {
|
|||||||
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
||||||
pass.set_pipeline(&self.pipeline);
|
pass.set_pipeline(&self.pipeline);
|
||||||
pass.set_bind_group(0, &self.uniform_group, &[]);
|
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
|
// Set once, not per layer or per image: masks/move_offsets are read
|
||||||
// by every primitive and every standalone image alike, and living
|
// by every primitive and every standalone image alike, and living
|
||||||
// in their own group (rather than folded into group 2 alongside the
|
// in their own group (rather than folded into group 2 alongside the
|
||||||
@@ -172,14 +193,13 @@ impl UiRenderNode {
|
|||||||
pass.set_bind_group(3, &self.masks_group, &[]);
|
pass.set_bind_group(3, &self.masks_group, &[]);
|
||||||
for i in &self.active {
|
for i in &self.active {
|
||||||
let layer = &self.layers[i];
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
pass.set_bind_group(1, &layer.primitive_group, &[]);
|
if layer.order.len() > 0 {
|
||||||
if layer.instance.len() > 0 {
|
|
||||||
pass.set_bind_group(2, &self.rsc_group, &[]);
|
pass.set_bind_group(2, &self.rsc_group, &[]);
|
||||||
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
|
pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
|
||||||
pass.draw(0..4, 0..layer.instance.len() as u32);
|
pass.draw(0..4, 0..layer.order.len() as u32);
|
||||||
}
|
}
|
||||||
// Images draw after this layer's rects and glyphs, one draw call
|
// Images draw after this layer's rects and glyphs, one draw call
|
||||||
// each with its own bind group. That draws every image "on top"
|
// each with its own bind group. That draws every image "on top"
|
||||||
@@ -188,8 +208,8 @@ impl UiRenderNode {
|
|||||||
// draw order was already undefined before images had their own
|
// draw order was already undefined before images had their own
|
||||||
// list -- nothing before this relied on interleaving a rect
|
// list -- nothing before this relied on interleaving a rect
|
||||||
// between two images at a particular position.
|
// between two images at a particular position.
|
||||||
if layer.image_instance.len() > 0 {
|
if layer.images.len() > 0 {
|
||||||
pass.set_vertex_buffer(0, layer.image_instance.buffer.slice(..));
|
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
|
||||||
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
|
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
|
||||||
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
|
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
|
||||||
pass.draw(0..4, k as u32..k as u32 + 1);
|
pass.draw(0..4, k as u32..k as u32 + 1);
|
||||||
@@ -206,67 +226,45 @@ impl UiRenderNode {
|
|||||||
ui_render: &mut UiRenderState,
|
ui_render: &mut UiRenderState,
|
||||||
) -> FrameUpdateStats {
|
) -> FrameUpdateStats {
|
||||||
self.active.clear();
|
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);
|
self.active.push(i);
|
||||||
for change in primitives.apply_free() {
|
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
|
||||||
if let Some(inst) = ui_render.active.get_mut(&change.id) {
|
order: ArrBuf::new(
|
||||||
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,
|
device,
|
||||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||||
"instance",
|
"layer order",
|
||||||
),
|
),
|
||||||
primitives,
|
images: ArrBuf::new(
|
||||||
primitive_group,
|
|
||||||
image_instance: ArrBuf::new(
|
|
||||||
device,
|
device,
|
||||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||||
"image instance",
|
"layer image order",
|
||||||
),
|
),
|
||||||
image_tex_indices: Vec::new(),
|
image_tex_indices: Vec::new(),
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if primitives.updated {
|
if order.updated {
|
||||||
rlayer
|
rlayer.order.update(device, queue, order.order());
|
||||||
.instance
|
rlayer.images.update(device, queue, order.images());
|
||||||
.update(device, queue, primitives.instances());
|
rlayer.image_tex_indices = order
|
||||||
rlayer.primitives.update(device, queue, primitives.data());
|
.images()
|
||||||
rlayer.primitive_group = Self::primitive_group(
|
|
||||||
device,
|
|
||||||
&self.primitive_layout,
|
|
||||||
rlayer.primitives.buffers(),
|
|
||||||
);
|
|
||||||
rlayer
|
|
||||||
.image_instance
|
|
||||||
.update(device, queue, primitives.image_instances());
|
|
||||||
rlayer.image_tex_indices = primitives
|
|
||||||
.image_instances()
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|inst| inst.idx)
|
.map(|&slot| ui_render.primitives.instance(slot).idx)
|
||||||
.collect();
|
.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 {
|
let masks_resized = if ui.masks.changed {
|
||||||
ui.masks.changed = false;
|
ui.masks.changed = false;
|
||||||
self.masks.update(device, queue, &ui.masks[..])
|
self.masks.update(device, queue, &ui.masks[..])
|
||||||
@@ -280,9 +278,14 @@ impl UiRenderNode {
|
|||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
if masks_resized || moves_resized {
|
if masks_resized || moves_resized || instances_resized {
|
||||||
self.masks_group =
|
self.masks_group = Self::masks_group(
|
||||||
Self::masks_group(device, &self.masks_layout, &self.masks, &self.move_offsets);
|
device,
|
||||||
|
&self.masks_layout,
|
||||||
|
&self.masks,
|
||||||
|
&self.move_offsets,
|
||||||
|
&self.instances,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
|
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
|
||||||
if rebuild_main {
|
if rebuild_main {
|
||||||
@@ -408,6 +411,14 @@ impl UiRenderNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let tex_manager = GpuTextures::new(device, queue);
|
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(
|
let masks = ArrBuf::new(
|
||||||
device,
|
device,
|
||||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||||
@@ -422,7 +433,8 @@ impl UiRenderNode {
|
|||||||
let rsc_layout = Self::rsc_layout(device);
|
let rsc_layout = Self::rsc_layout(device);
|
||||||
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
|
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
|
||||||
let masks_layout = Self::masks_layout(device);
|
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 {
|
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
||||||
label: Some("UI Shape Pipeline Layout"),
|
label: Some("UI Shape Pipeline Layout"),
|
||||||
@@ -440,7 +452,7 @@ impl UiRenderNode {
|
|||||||
vertex: VertexState {
|
vertex: VertexState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("vs_main"),
|
entry_point: Some("vs_main"),
|
||||||
buffers: &[PrimitiveInstance::desc()],
|
buffers: &[instance_slot_layout()],
|
||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
},
|
},
|
||||||
fragment: Some(FragmentState {
|
fragment: Some(FragmentState {
|
||||||
@@ -486,6 +498,8 @@ impl UiRenderNode {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
uniform_group,
|
uniform_group,
|
||||||
primitive_layout,
|
primitive_layout,
|
||||||
|
primitives,
|
||||||
|
primitive_group,
|
||||||
rsc_layout,
|
rsc_layout,
|
||||||
rsc_group,
|
rsc_group,
|
||||||
pipeline,
|
pipeline,
|
||||||
@@ -493,6 +507,7 @@ impl UiRenderNode {
|
|||||||
layers: HashMap::default(),
|
layers: HashMap::default(),
|
||||||
active: Vec::new(),
|
active: Vec::new(),
|
||||||
textures: tex_manager,
|
textures: tex_manager,
|
||||||
|
instances,
|
||||||
masks,
|
masks,
|
||||||
move_offsets,
|
move_offsets,
|
||||||
masks_layout,
|
masks_layout,
|
||||||
@@ -627,6 +642,16 @@ impl UiRenderNode {
|
|||||||
},
|
},
|
||||||
count: None,
|
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"),
|
label: Some("ui masks"),
|
||||||
})
|
})
|
||||||
@@ -637,6 +662,7 @@ impl UiRenderNode {
|
|||||||
layout: &BindGroupLayout,
|
layout: &BindGroupLayout,
|
||||||
masks: &ArrBuf<Mask>,
|
masks: &ArrBuf<Mask>,
|
||||||
move_offsets: &ArrBuf<MoveOffset>,
|
move_offsets: &ArrBuf<MoveOffset>,
|
||||||
|
instances: &ArrBuf<PrimitiveInstance>,
|
||||||
) -> BindGroup {
|
) -> BindGroup {
|
||||||
device.create_bind_group(&BindGroupDescriptor {
|
device.create_bind_group(&BindGroupDescriptor {
|
||||||
layout,
|
layout,
|
||||||
@@ -649,6 +675,10 @@ impl UiRenderNode {
|
|||||||
binding: 1,
|
binding: 1,
|
||||||
resource: move_offsets.buffer.as_entire_binding(),
|
resource: move_offsets.buffer.as_entire_binding(),
|
||||||
},
|
},
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: instances.buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
label: Some("ui masks"),
|
label: Some("ui masks"),
|
||||||
})
|
})
|
||||||
|
|||||||
+276
-180
@@ -11,51 +11,20 @@ use crate::{
|
|||||||
use bytemuck::Pod;
|
use bytemuck::Pod;
|
||||||
use wgpu::*;
|
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
|
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
|
||||||
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
|
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
|
||||||
/// one from -- a bind group already selects the texture -- so this only ever
|
/// 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
|
/// has to match the shader's `TEXTURE` constant and flag "this instance is
|
||||||
/// in `Primitives::images`, not `Primitives::instances`" to the code below.
|
/// drawn with its own bind group" to the code below.
|
||||||
pub const IMAGE_BINDING: u32 = 1;
|
pub const IMAGE_BINDING: u32 = 1;
|
||||||
|
|
||||||
pub trait Primitive: Pod {
|
pub trait Primitive: Pod {
|
||||||
const BINDING: u32;
|
const BINDING: u32;
|
||||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
||||||
|
/// The read-only half of [`Self::vec`], for a caller that wants to
|
||||||
|
/// look one entry up rather than write one -- a mask reading the
|
||||||
|
/// radius of the rect it clips to ([`Primitives::data`]).
|
||||||
|
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! primitives {
|
macro_rules! primitives {
|
||||||
@@ -121,6 +90,9 @@ macro_rules! primitives {
|
|||||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
|
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
|
||||||
&mut data.$name
|
&mut data.$name
|
||||||
}
|
}
|
||||||
|
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
|
||||||
|
&data.$name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)*
|
)*
|
||||||
};
|
};
|
||||||
@@ -134,18 +106,61 @@ macro_rules! primitives {
|
|||||||
(@count $t:tt) => { 1 };
|
(@count $t:tt) => { 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveInst<P> {
|
/// Every primitive instance in the tree, in one arena that all layers
|
||||||
pub id: WidgetId,
|
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
|
||||||
pub primitive: P,
|
///
|
||||||
pub region: UiRegion,
|
/// **Why one arena rather than one per layer**, which is what this was:
|
||||||
pub mask_idx: MaskIdx,
|
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
|
||||||
pub move_idx: MoveIdx,
|
/// (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 {
|
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,
|
&mut self,
|
||||||
layer: usize,
|
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
id,
|
id,
|
||||||
primitive,
|
primitive,
|
||||||
@@ -153,154 +168,118 @@ impl Primitives {
|
|||||||
mask_idx,
|
mask_idx,
|
||||||
move_idx,
|
move_idx,
|
||||||
}: PrimitiveInst<P>,
|
}: PrimitiveInst<P>,
|
||||||
) -> PrimitiveHandle {
|
) -> (u32, usize) {
|
||||||
self.updated = true;
|
let data_idx = P::vec(&mut self.data).add(primitive);
|
||||||
let vec = P::vec(&mut self.data);
|
let slot = self.push(
|
||||||
let i = vec.add(primitive);
|
PrimitiveInstance {
|
||||||
let inst = PrimitiveInstance {
|
|
||||||
region,
|
region,
|
||||||
idx: i as u32,
|
idx: data_idx as u32,
|
||||||
mask_idx,
|
mask_idx,
|
||||||
move_idx,
|
move_idx,
|
||||||
binding: P::BINDING,
|
binding: P::BINDING,
|
||||||
};
|
},
|
||||||
let inst_i = if let Some(i) = self.free.pop() {
|
id,
|
||||||
self.instances[i] = inst;
|
);
|
||||||
self.assoc[i] = id;
|
(slot, data_idx)
|
||||||
i
|
|
||||||
} else {
|
|
||||||
let i = self.instances.len();
|
|
||||||
self.instances.push(inst);
|
|
||||||
self.assoc.push(id);
|
|
||||||
i
|
|
||||||
};
|
|
||||||
PrimitiveHandle::new::<P>(layer, inst_i, i)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes an image instance directly -- there is no `Primitive` impl for
|
/// A standalone image, which has no `PrimitiveData` entry to allocate
|
||||||
/// it to go through `write`, since it has nowhere in `PrimitiveData` to
|
/// -- its bind group already picks the texture, so `texture_idx` rides
|
||||||
/// put a per-instance entry. `texture_idx` is the slot the bind group at
|
/// in the otherwise-unused `idx` field and names the bind group the
|
||||||
/// draw time is chosen from, carried in the otherwise-unused `idx` field.
|
/// draw call selects.
|
||||||
pub fn write_image(
|
pub fn alloc_image(
|
||||||
&mut self,
|
&mut self,
|
||||||
layer: usize,
|
|
||||||
id: WidgetId,
|
id: WidgetId,
|
||||||
texture_idx: u32,
|
texture_idx: u32,
|
||||||
region: UiRegion,
|
region: UiRegion,
|
||||||
mask_idx: MaskIdx,
|
mask_idx: MaskIdx,
|
||||||
move_idx: MoveIdx,
|
move_idx: MoveIdx,
|
||||||
) -> PrimitiveHandle {
|
) -> u32 {
|
||||||
self.updated = true;
|
self.push(
|
||||||
let inst = PrimitiveInstance {
|
PrimitiveInstance {
|
||||||
region,
|
region,
|
||||||
idx: texture_idx,
|
idx: texture_idx,
|
||||||
mask_idx,
|
mask_idx,
|
||||||
move_idx,
|
move_idx,
|
||||||
binding: IMAGE_BINDING,
|
binding: IMAGE_BINDING,
|
||||||
};
|
},
|
||||||
let inst_i = if let Some(i) = self.image_free.pop() {
|
id,
|
||||||
self.images[i] = inst;
|
)
|
||||||
self.image_assoc[i] = id;
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
|
||||||
|
self.updated = true;
|
||||||
|
let slot = if let Some(i) = self.reusable.pop() {
|
||||||
|
self.instances[i] = inst;
|
||||||
|
self.assoc[i] = id;
|
||||||
i
|
i
|
||||||
} else {
|
} else {
|
||||||
let i = self.images.len();
|
self.instances.push(inst);
|
||||||
self.images.push(inst);
|
self.assoc.push(id);
|
||||||
self.image_assoc.push(id);
|
self.instances.len() - 1
|
||||||
i
|
|
||||||
};
|
};
|
||||||
PrimitiveHandle {
|
slot as u32
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
if h.binding == IMAGE_BINDING {
|
let slot = h.slot as usize;
|
||||||
self.image_free.push(h.inst_idx);
|
if h.binding != IMAGE_BINDING {
|
||||||
self.images[h.inst_idx].mask_idx
|
|
||||||
} else {
|
|
||||||
self.data.free(h.binding, h.data_idx);
|
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
|
/// Hands this frame's freed slots back for reuse. Called once per
|
||||||
/// the orphan check, so the O(primitives) walk below only runs on a
|
/// frame from `UiRenderState::update`, **after** every layer has
|
||||||
/// frame that already looks wrong. See
|
/// 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`].
|
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||||
pub fn live_count(&self) -> usize {
|
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,
|
/// Every live instance as `(slot, owner, is_image)` -- everything
|
||||||
/// owner, is_image)` -- everything except the slots already handed to
|
/// except the freed and the reusable. Only
|
||||||
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
|
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
|
||||||
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
|
/// that every live primitive still belongs to a live widget.
|
||||||
/// to check that every drawn primitive still belongs to a live widget.
|
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
|
||||||
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
|
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
|
||||||
let free: HashSet<usize> = self.free.iter().copied().collect();
|
(0..self.instances.len())
|
||||||
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
|
.filter(move |i| !dead.contains(i))
|
||||||
let rects = (0..self.instances.len())
|
.map(|i| {
|
||||||
.filter(move |i| !free.contains(i))
|
(
|
||||||
.map(|i| (i, self.assoc[i], false));
|
i as u32,
|
||||||
let images = (0..self.images.len())
|
self.assoc[i],
|
||||||
.filter(move |i| !image_free.contains(i))
|
self.instances[i].binding == IMAGE_BINDING,
|
||||||
.map(|i| (i, self.image_assoc[i], true));
|
)
|
||||||
rects.chain(images)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data(&self) -> &PrimitiveData {
|
pub fn data(&self) -> &PrimitiveData {
|
||||||
@@ -311,46 +290,163 @@ impl Primitives {
|
|||||||
&self.instances
|
&self.instances
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
|
||||||
|
&self.instances[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-primitive data behind `slot`, or `None` if that slot holds
|
||||||
|
/// a different kind of primitive -- the `binding` check is the same
|
||||||
|
/// one the shader's dispatch switch makes, and it is what stops a
|
||||||
|
/// caller reading a glyph's index into the rect table.
|
||||||
|
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
|
||||||
|
let inst = self.instance(slot);
|
||||||
|
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
if h.binding == IMAGE_BINDING {
|
&mut self.instances[h.slot as usize].region
|
||||||
&mut self.images[h.inst_idx].region
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
} else {
|
||||||
&mut self.instances[h.inst_idx].region
|
&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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveChange {
|
/// A primitive whose position in a layer's draw order moved when
|
||||||
pub id: WidgetId,
|
/// something before it was freed -- `slot` names which primitive, so its
|
||||||
/// Which of `Primitives::instances`/`Primitives::images` this change
|
/// owner's handle can be found and pointed at `pos`.
|
||||||
/// belongs to -- their `old`/`new` indices are independent, so a
|
pub struct OrderChange {
|
||||||
/// consumer matching only on `(layer, inst_idx)` could apply an image's
|
pub slot: u32,
|
||||||
/// renumbering to a rect's handle that happens to share the same index.
|
/// 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 is_image: bool,
|
||||||
pub old: usize,
|
pub pos: usize,
|
||||||
pub new: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is
|
||||||
|
/// a primitive written only to be *referenced* -- a mask's shape
|
||||||
|
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
|
||||||
|
/// freed exactly like any other; it is simply never rasterized.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Drawn {
|
||||||
|
Yes,
|
||||||
|
No,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
|
||||||
|
/// there is no position to renumber or free.
|
||||||
|
pub const NOT_DRAWN: usize = usize::MAX;
|
||||||
|
|
||||||
|
/// Where one primitive lives: its stable slot in the global arena, and
|
||||||
|
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
|
||||||
|
/// only referenced).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PrimitiveHandle {
|
pub struct PrimitiveHandle {
|
||||||
pub layer: usize,
|
pub layer: usize,
|
||||||
pub inst_idx: usize,
|
pub pos: usize,
|
||||||
|
pub slot: u32,
|
||||||
pub data_idx: usize,
|
pub data_idx: usize,
|
||||||
pub binding: u32,
|
pub binding: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveHandle {
|
impl PrimitiveHandle {
|
||||||
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
|
pub fn is_image(&self) -> bool {
|
||||||
Self {
|
self.binding == IMAGE_BINDING
|
||||||
layer,
|
|
||||||
inst_idx,
|
|
||||||
data_idx,
|
|
||||||
binding: P::BINDING,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PrimitiveInst<P> {
|
||||||
|
pub id: WidgetId,
|
||||||
|
pub primitive: P,
|
||||||
|
pub region: UiRegion,
|
||||||
|
pub mask_idx: MaskIdx,
|
||||||
|
pub move_idx: MoveIdx,
|
||||||
|
}
|
||||||
|
|
||||||
primitives!(
|
primitives!(
|
||||||
rects: RectPrimitive => 0,
|
rects: RectPrimitive => 0,
|
||||||
glyphs: GlyphPrimitive => 2,
|
glyphs: GlyphPrimitive => 2,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! The rounded-rect coverage function, on the CPU.
|
||||||
|
//!
|
||||||
|
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
|
||||||
|
//! transliteration of these two, line for line, and
|
||||||
|
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
|
||||||
|
//! at a grid of points against values the shader itself produced. They are
|
||||||
|
//! kept together here, in the crate both a renderer and a hit test can
|
||||||
|
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
|
||||||
|
//! agreeing: a masked corner that cannot be tapped and a masked corner
|
||||||
|
//! that is not drawn have to be the same corner, and they are only the
|
||||||
|
//! same corner while one function decides both.
|
||||||
|
//!
|
||||||
|
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
|
||||||
|
//! units, which the shader has already resolved by the time it evaluates
|
||||||
|
//! this.
|
||||||
|
|
||||||
|
use crate::util::Vec2;
|
||||||
|
|
||||||
|
/// The signed distance from `pos` to a rounded rect given by its centre,
|
||||||
|
/// its corner offset (half its size) and its corner `radius`. Negative
|
||||||
|
/// inside.
|
||||||
|
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
|
||||||
|
// vec from center to pixel
|
||||||
|
let p = pos - center;
|
||||||
|
// vec from inner rect corner to pixel
|
||||||
|
let q = Vec2::new(
|
||||||
|
p.x.abs() - (corner.x - radius),
|
||||||
|
p.y.abs() - (corner.y - radius),
|
||||||
|
);
|
||||||
|
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
|
||||||
|
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
|
||||||
|
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
|
||||||
|
///
|
||||||
|
/// The half-pixel feather is why a hit test asks for **more than a half**
|
||||||
|
/// rather than "any coverage at all": half is where the geometric edge is,
|
||||||
|
/// so the two answer the same question the drawn shape does.
|
||||||
|
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
|
||||||
|
let edge: f32 = 0.5;
|
||||||
|
let corner = (bot_right - top_left) / 2.0;
|
||||||
|
let center = top_left + corner;
|
||||||
|
let dist = distance_from_rect(pos, center, corner, radius);
|
||||||
|
1.0 - smoothstep(-edge.min(radius), edge, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
|
||||||
|
/// when `low == high`, which is why the caller above never passes a zero
|
||||||
|
/// radius into the low edge without `edge` bounding it.
|
||||||
|
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
|
||||||
|
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
|
}
|
||||||
@@ -30,10 +30,12 @@ struct GlyphInfo {
|
|||||||
flags: u32,
|
flags: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
|
||||||
|
/// clips this mask's subtree, and the mask it nests inside
|
||||||
|
/// (`4294967295u` at the top).
|
||||||
struct Mask {
|
struct Mask {
|
||||||
x: UiSpan,
|
primitive: u32,
|
||||||
y: UiSpan,
|
parent: u32,
|
||||||
move_idx: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One widget's cumulative on-screen translation and the slot of the
|
/// One widget's cumulative on-screen translation and the slot of the
|
||||||
@@ -53,11 +55,6 @@ struct UiScalar {
|
|||||||
abs: f32,
|
abs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UiVec2 {
|
|
||||||
rel: vec2<f32>,
|
|
||||||
abs: vec2<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
||||||
// texture with headroom and copies the old layers across -- see
|
// texture with headroom and copies the old layers across -- see
|
||||||
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
|
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
|
||||||
@@ -79,8 +76,15 @@ var samp: sampler;
|
|||||||
var<storage> masks: array<Mask>;
|
var<storage> masks: array<Mask>;
|
||||||
@group(3) @binding(1)
|
@group(3) @binding(1)
|
||||||
var<storage> move_offsets: array<MoveOffset>;
|
var<storage> move_offsets: array<MoveOffset>;
|
||||||
|
// 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
|
// render_state.rs, which walks the identical chain on the CPU side for
|
||||||
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
// 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
|
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||||
@@ -90,7 +94,7 @@ var<storage> move_offsets: array<MoveOffset>;
|
|||||||
// Past the bound both walks simply stop summing, so the widget draws and
|
// 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
|
// hit-tests short by whatever the outer slots held, with nothing on
|
||||||
// screen to say so.
|
// 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
|
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||||
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||||
@@ -98,7 +102,7 @@ const MOVE_CHAIN_LIMIT: u32 = 64u;
|
|||||||
fn resolve_move(idx: u32) -> vec2<f32> {
|
fn resolve_move(idx: u32) -> vec2<f32> {
|
||||||
var total = vec2<f32>(0.0, 0.0);
|
var total = vec2<f32>(0.0, 0.0);
|
||||||
var i = idx;
|
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];
|
let entry = move_offsets[i];
|
||||||
total += entry.delta;
|
total += entry.delta;
|
||||||
if entry.parent == 4294967295u {
|
if entry.parent == 4294967295u {
|
||||||
@@ -113,15 +117,20 @@ struct WindowUniform {
|
|||||||
dim: vec2<f32>,
|
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 {
|
struct InstanceInput {
|
||||||
@location(0) x_start: vec2<f32>,
|
@location(0) slot: u32,
|
||||||
@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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@@ -141,21 +150,38 @@ struct Region {
|
|||||||
bot_right: vec2<f32>,
|
bot_right: vec2<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One primitive's on-screen corners in window pixels. Written once and
|
||||||
|
/// used by both stages: the vertex stage for the primitive it is drawing,
|
||||||
|
/// the fragment stage for a mask's -- so the shape a mask clips to and the
|
||||||
|
/// shape that was drawn cannot be computed two different ways.
|
||||||
|
struct Corners {
|
||||||
|
top_left: vec2<f32>,
|
||||||
|
bot_right: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corners_of(inst: PrimitiveInstance) -> Corners {
|
||||||
|
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(inst.move_idx);
|
||||||
|
return Corners(
|
||||||
|
floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta,
|
||||||
|
floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_main(
|
fn vs_main(
|
||||||
@builtin(vertex_index) vi: u32,
|
@builtin(vertex_index) vi: u32,
|
||||||
in: InstanceInput,
|
in: InstanceInput,
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
var out: VertexOutput;
|
var out: VertexOutput;
|
||||||
|
let inst = instances[in.slot];
|
||||||
|
|
||||||
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
|
let c = corners_of(inst);
|
||||||
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
|
let top_left = c.top_left;
|
||||||
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
|
let bot_right = c.bot_right;
|
||||||
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
|
|
||||||
|
|
||||||
let move_delta = resolve_move(in.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;
|
let size = bot_right - top_left;
|
||||||
|
|
||||||
let uv = vec2<f32>(
|
let uv = vec2<f32>(
|
||||||
@@ -165,11 +191,11 @@ fn vs_main(
|
|||||||
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
|
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.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
|
||||||
out.uv = uv;
|
out.uv = uv;
|
||||||
out.binding = in.binding;
|
out.binding = inst.binding;
|
||||||
out.idx = in.idx;
|
out.idx = inst.idx;
|
||||||
out.top_left = top_left;
|
out.top_left = top_left;
|
||||||
out.bot_right = bot_right;
|
out.bot_right = bot_right;
|
||||||
out.mask_idx = in.mask_idx;
|
out.mask_idx = inst.mask_idx;
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -196,21 +222,39 @@ fn fs_main(
|
|||||||
color = vec4(1.0, 0.0, 1.0, 1.0);
|
color = vec4(1.0, 0.0, 1.0, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if in.mask_idx != 4294967295u {
|
// Every mask on the chain, not just the innermost: a widget that set
|
||||||
let mask = masks[in.mask_idx];
|
// its own mask inside another is clipped by both, and the coverages
|
||||||
let mask_delta = resolve_move(mask.move_idx);
|
// multiply -- so a pixel inside two feathered corners is dimmed by
|
||||||
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
|
// both, which is what a compositor does (`Mask::parent` in data.rs).
|
||||||
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
|
var mask_idx = in.mask_idx;
|
||||||
|
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||||
let top_left = floor(tl.rel * window.dim) + floor(tl.abs) + mask_delta;
|
if mask_idx == 4294967295u {
|
||||||
let bot_right = floor(br.rel * window.dim) + floor(br.abs) + mask_delta;
|
break;
|
||||||
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
|
|
||||||
color *= 0.0;
|
|
||||||
}
|
}
|
||||||
|
let mask = masks[mask_idx];
|
||||||
|
color.a *= mask_coverage(pos, mask);
|
||||||
|
mask_idx = mask.parent;
|
||||||
}
|
}
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much of `pos` one mask lets through: the referenced primitive's
|
||||||
|
/// own coverage at that pixel, from the same SDF the primitive is drawn
|
||||||
|
/// with. Nothing about the shape is copied into the mask, so a rounded
|
||||||
|
/// container's corner and its children's clipped corner are the same
|
||||||
|
/// arithmetic.
|
||||||
|
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
|
||||||
|
let inst = instances[mask.primitive];
|
||||||
|
if inst.binding != RECT {
|
||||||
|
// Unreachable: `Painter::set_mask` rejects a glyph or an image
|
||||||
|
// shape by name (see `Mask::primitive`). Letting the pixel
|
||||||
|
// through rather than reading a `rects` entry that is not there.
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let c = corners_of(inst);
|
||||||
|
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_texture(region: Region) -> vec4<f32> {
|
fn draw_texture(region: Region) -> vec4<f32> {
|
||||||
return textureSample(image_texture, samp, region.uv);
|
return textureSample(image_texture, samp, region.uv);
|
||||||
}
|
}
|
||||||
@@ -226,19 +270,35 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
|
||||||
|
/// function both a drawn rect and a mask go through, and the
|
||||||
|
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
|
||||||
|
/// which the hit test uses so a corner that cannot be tapped and a corner
|
||||||
|
/// that is not drawn are the same corner.
|
||||||
|
fn rounded_rect_coverage(
|
||||||
|
pos: vec2<f32>,
|
||||||
|
top_left: vec2<f32>,
|
||||||
|
bot_right: vec2<f32>,
|
||||||
|
radius: f32,
|
||||||
|
) -> f32 {
|
||||||
|
let edge = 0.5;
|
||||||
|
let corner = (bot_right - top_left) / 2.0;
|
||||||
|
let center = top_left + corner;
|
||||||
|
let dist = distance_from_rect(pos, center, corner, radius);
|
||||||
|
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
||||||
var color = unpack4x8unorm(rect.color);
|
var color = unpack4x8unorm(rect.color);
|
||||||
|
|
||||||
let edge = 0.5;
|
let edge = 0.5;
|
||||||
|
|
||||||
|
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
|
||||||
|
|
||||||
|
if rect.thickness > 0.0 {
|
||||||
let size = region.bot_right - region.top_left;
|
let size = region.bot_right - region.top_left;
|
||||||
let corner = size / 2.0;
|
let corner = size / 2.0;
|
||||||
let center = region.top_left + corner;
|
let center = region.top_left + corner;
|
||||||
|
|
||||||
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
|
|
||||||
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
|
|
||||||
|
|
||||||
if rect.thickness > 0.0 {
|
|
||||||
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
|
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
|
||||||
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
|
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-13
@@ -1,7 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion,
|
Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
|
||||||
UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
||||||
render::{GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst},
|
render::{
|
||||||
|
Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
|
||||||
|
RectPrimitive,
|
||||||
|
},
|
||||||
util::Vec2,
|
util::Vec2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,8 +29,20 @@ pub struct Painter<'a> {
|
|||||||
|
|
||||||
impl<'a> Painter<'a> {
|
impl<'a> Painter<'a> {
|
||||||
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
||||||
let h = self.state.layers.write(
|
self.write_primitive(primitive, region, Drawn::Yes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one path every primitive this widget owns goes through --
|
||||||
|
/// drawn or, for a mask's shape, only referenced.
|
||||||
|
fn write_primitive<P: Primitive>(
|
||||||
|
&mut self,
|
||||||
|
primitive: P,
|
||||||
|
region: UiRegion,
|
||||||
|
drawn: Drawn,
|
||||||
|
) -> u32 {
|
||||||
|
let h = self.state.write_primitive(
|
||||||
self.layer,
|
self.layer,
|
||||||
|
drawn,
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
primitive,
|
primitive,
|
||||||
@@ -40,7 +55,9 @@ impl<'a> Painter<'a> {
|
|||||||
// TODO: I have no clue if this works at all :joy:
|
// TODO: I have no clue if this works at all :joy:
|
||||||
self.rsc.ui_mut().masks.push_ref(self.mask);
|
self.rsc.ui_mut().masks.push_ref(self.mask);
|
||||||
}
|
}
|
||||||
|
let slot = h.slot;
|
||||||
self.primitives.push(h);
|
self.primitives.push(h);
|
||||||
|
slot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a primitive to be rendered
|
/// Writes a primitive to be rendered
|
||||||
@@ -53,8 +70,16 @@ impl<'a> Painter<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clip everything this widget draws, itself and its descendants, to
|
/// Clip everything this widget draws, itself and its descendants, to
|
||||||
/// `region`. One per widget: a second call would need the two to be
|
/// `region`. One call per widget; a widget drawn inside another
|
||||||
/// intersected, which nothing here does.
|
/// widget's mask nests instead -- the new mask chains to the inherited
|
||||||
|
/// one (`Mask::parent`) and the fragment stage multiplies both
|
||||||
|
/// coverages, which is what lets a transcript row's code fence clip
|
||||||
|
/// to itself *and* to the list it scrolls inside.
|
||||||
|
///
|
||||||
|
/// The clip is a **primitive**, not a rectangle copied into the mask:
|
||||||
|
/// this writes an undrawn `RectPrimitive` at `region` and points the
|
||||||
|
/// mask at it, so the fragment stage evaluates the same rounded-rect
|
||||||
|
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
|
||||||
///
|
///
|
||||||
/// The slot is allocated once and **rewritten in place** on every
|
/// The slot is allocated once and **rewritten in place** on every
|
||||||
/// later draw rather than pushed again, because a descendant whose own
|
/// later draw rather than pushed again, because a descendant whose own
|
||||||
@@ -62,24 +87,109 @@ impl<'a> Painter<'a> {
|
|||||||
/// so keeps pointing at whichever slot it was drawn under. See
|
/// so keeps pointing at whichever slot it was drawn under. See
|
||||||
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||||
pub fn set_mask(&mut self, region: UiRegion) {
|
pub fn set_mask(&mut self, region: UiRegion) {
|
||||||
assert!(self.mask == MaskIdx::NONE);
|
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
|
||||||
|
self.set_mask_to(shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clip everything this widget draws after this call to `shape`'s
|
||||||
|
/// own shape -- the first primitive `shape`'s subtree drew, which
|
||||||
|
/// must already have been drawn this frame
|
||||||
|
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
|
||||||
|
/// clip a container's content to the rounded background it draws,
|
||||||
|
/// with no radius argument anywhere that could fall out of step with
|
||||||
|
/// the one being drawn.
|
||||||
|
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
|
||||||
|
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
|
||||||
|
clip to",
|
||||||
|
self.rsc.widgets().label(shape.id()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
self.set_mask_to(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points this widget's mask at a primitive that has already been
|
||||||
|
/// written -- the shared half of [`Self::set_mask`].
|
||||||
|
fn set_mask_to(&mut self, shape: u32) {
|
||||||
|
// `assert!`, not `debug_assert!`: one comparison per widget draw,
|
||||||
|
// and the second call silently *replacing* the first is a widget
|
||||||
|
// drawn unclipped -- which reaches the screen and nothing says so.
|
||||||
|
// Every build anybody runs here is release
|
||||||
|
// (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",
|
||||||
|
);
|
||||||
|
// A glyph would need a CPU-side alpha plane for the hit test to
|
||||||
|
// agree with the shader, and a standalone image a bind-group
|
||||||
|
// switch the fragment stage cannot make -- see `Mask::primitive`.
|
||||||
|
// Named here rather than left to the shader, which would read a
|
||||||
|
// rect that is not there and clip to nothing.
|
||||||
|
let binding = self.state.primitives.instance(shape).binding;
|
||||||
|
assert_eq!(
|
||||||
|
binding,
|
||||||
|
RectPrimitive::BINDING,
|
||||||
|
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
|
||||||
|
);
|
||||||
|
let parent = self.mask;
|
||||||
let mask = Mask {
|
let mask = Mask {
|
||||||
region,
|
primitive: shape,
|
||||||
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);
|
let slot = self.rsc.ui_mut().masks.push(mask);
|
||||||
// The one ref this widget holds on its own slot, so the slot
|
// The one ref this widget holds on its own slot, so the slot
|
||||||
// outlives any single frame's primitives; released in
|
// outlives any single frame's primitives; released in
|
||||||
// `UiRenderState::remove`'s `undraw` branch.
|
// `UiRenderState::remove`'s `undraw` branch.
|
||||||
self.rsc.ui_mut().masks.push_ref(slot);
|
self.rsc.ui_mut().masks.push_ref(slot);
|
||||||
self.own_mask = slot;
|
self.own_mask = slot;
|
||||||
|
MaskIdx::NONE
|
||||||
} else {
|
} else {
|
||||||
|
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
|
||||||
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
*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;
|
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
|
/// Draws a widget within this widget's region, returning the size it
|
||||||
/// reported using.
|
/// reported using.
|
||||||
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
|
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
|
||||||
@@ -170,7 +280,7 @@ impl<'a> Painter<'a> {
|
|||||||
/// the layer's one instanced draw, so it goes through
|
/// the layer's one instanced draw, so it goes through
|
||||||
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
||||||
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
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.layer,
|
||||||
self.id,
|
self.id,
|
||||||
texture_idx,
|
texture_idx,
|
||||||
@@ -216,8 +326,10 @@ impl<'a> Painter<'a> {
|
|||||||
// A caller re-emitting quads placed against an atlas that has since
|
// A caller re-emitting quads placed against an atlas that has since
|
||||||
// been cleared draws every glyph from coordinates now holding
|
// been cleared draws every glyph from coordinates now holding
|
||||||
// something else. Caught at the submission rather than on screen,
|
// something else. Caught at the submission rather than on screen,
|
||||||
// where it reads as fragments of unrelated letters.
|
// where it reads as fragments of unrelated letters. `assert_eq!`
|
||||||
debug_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,
|
text.generation,
|
||||||
self.atlas_generation(),
|
self.atlas_generation(),
|
||||||
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
||||||
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
||||||
render::{IMAGE_BINDING, MoveOffset},
|
render::{
|
||||||
|
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
|
||||||
|
RectPrimitive, rounded_rect_coverage,
|
||||||
|
},
|
||||||
util::{HashMap, HashSet, Id, Vec2},
|
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 struct UiRenderState {
|
||||||
pub active: HashMap<WidgetId, ActiveData>,
|
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 layers: PrimitiveLayers,
|
||||||
pub(super) output_size: Vec2,
|
pub(super) output_size: Vec2,
|
||||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||||
@@ -58,6 +83,31 @@ pub struct UiRenderState {
|
|||||||
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
||||||
/// which `TextView::render` only reaches on a cache miss.
|
/// which `TextView::render` only reaches on a cache miss.
|
||||||
pub(super) shape_count: u64,
|
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,
|
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||||
@@ -70,12 +120,18 @@ pub struct UiRenderState {
|
|||||||
/// prints the chain). A chain past the bound is not reported anywhere at
|
/// 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
|
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||||
/// tested short by whatever the outer slots held.
|
/// 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 {
|
impl UiRenderState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
|
primitives: Default::default(),
|
||||||
layers: Default::default(),
|
layers: Default::default(),
|
||||||
output_size: Vec2::ZERO,
|
output_size: Vec2::ZERO,
|
||||||
density: 1.0,
|
density: 1.0,
|
||||||
@@ -87,6 +143,11 @@ impl UiRenderState {
|
|||||||
region_mut_count: 0,
|
region_mut_count: 0,
|
||||||
mov_count: 0,
|
mov_count: 0,
|
||||||
shape_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 +169,75 @@ impl UiRenderState {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes a primitive into the arena and, unless it is
|
||||||
|
/// [`Drawn::No`], into `layer`'s draw order.
|
||||||
|
pub(super) fn write_primitive<P: Primitive>(
|
||||||
|
&mut self,
|
||||||
|
layer: usize,
|
||||||
|
drawn: Drawn,
|
||||||
|
inst: PrimitiveInst<P>,
|
||||||
|
) -> PrimitiveHandle {
|
||||||
|
let (slot, data_idx) = self.primitives.alloc(inst);
|
||||||
|
let pos = match drawn {
|
||||||
|
Drawn::Yes => self.layers[layer].push(slot, false),
|
||||||
|
Drawn::No => NOT_DRAWN,
|
||||||
|
};
|
||||||
|
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>) {
|
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
||||||
self.output_size = size.into();
|
self.output_size = size.into();
|
||||||
self.resized = true;
|
self.resized = true;
|
||||||
@@ -119,7 +249,16 @@ impl UiRenderState {
|
|||||||
/// different triggers (a surface resize on every rotation or keyboard
|
/// different triggers (a surface resize on every rotation or keyboard
|
||||||
/// open; a density change only if the app follows the display to a
|
/// open; a density change only if the app follows the display to a
|
||||||
/// different screen, which Android surfaces separately).
|
/// 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) {
|
pub fn set_density(&mut self, density: f32) {
|
||||||
|
if density != self.density {
|
||||||
|
self.resized = true;
|
||||||
|
}
|
||||||
self.density = density;
|
self.density = density;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,17 +289,94 @@ impl UiRenderState {
|
|||||||
"a previous frame left {} widget(s) marked as mid-draw",
|
"a previous frame left {} widget(s) marked as mid-draw",
|
||||||
self.draw_started.len(),
|
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.redraw_all(root, rsc);
|
||||||
self.old_root = root.map(|r| r.id());
|
self.old_root = root.map(|r| r.id());
|
||||||
self.resized = false;
|
self.resized = false;
|
||||||
|
RedrawKind::All
|
||||||
} else if rsc.widgets().has_updates() {
|
} else if rsc.widgets().has_updates() {
|
||||||
self.redraw_updates(rsc);
|
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)]
|
#[cfg(debug_assertions)]
|
||||||
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
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).
|
||||||
|
///
|
||||||
|
/// A mask's shape does not count: it is a [`Drawn::No`] primitive
|
||||||
|
/// that is never rasterized, so including it would put one extra on
|
||||||
|
/// the line for every masked widget and make a number Iris reads off
|
||||||
|
/// a phone report disagree with what is drawn.
|
||||||
|
pub fn active_primitive_count(&self) -> usize {
|
||||||
|
self.active
|
||||||
|
.values()
|
||||||
|
.map(|a| a.primitives.iter().filter(|h| h.pos != NOT_DRAWN).count())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||||
self.clear(rsc);
|
self.clear(rsc);
|
||||||
// free all resources & cache
|
// free all resources & cache
|
||||||
@@ -252,7 +468,7 @@ impl UiRenderState {
|
|||||||
// instead of redrawing. See LAYOUT.md section 3.
|
// instead of redrawing. See LAYOUT.md section 3.
|
||||||
let from = active.region;
|
let from = active.region;
|
||||||
for h in &active.primitives {
|
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);
|
*r = r.outside(&from).within(®ion);
|
||||||
self.region_mut_count += 1;
|
self.region_mut_count += 1;
|
||||||
}
|
}
|
||||||
@@ -324,10 +540,9 @@ impl UiRenderState {
|
|||||||
// own new one -- and `ActiveData::mask`'s only consumer is
|
// own new one -- and `ActiveData::mask`'s only consumer is
|
||||||
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
||||||
// the set one instead handed a `Masked` its own mask on every
|
// the set one instead handed a `Masked` its own mask on every
|
||||||
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
// targeted redraw -- an abort the first time the composer's scroll
|
||||||
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
// area was redrawn on the emulator, and now (masks nest) a mask
|
||||||
// first time the composer's scroll area was redrawn on the
|
// whose parent is itself, which `set_mask`'s own assert names.
|
||||||
// emulator.
|
|
||||||
let inherited_mask = mask;
|
let inherited_mask = mask;
|
||||||
let mut painter = Painter {
|
let mut painter = Painter {
|
||||||
state: self,
|
state: self,
|
||||||
@@ -490,11 +705,15 @@ impl UiRenderState {
|
|||||||
let mut active = self.active.remove(&id);
|
let mut active = self.active.remove(&id);
|
||||||
if let Some(active) = &mut active {
|
if let Some(active) = &mut active {
|
||||||
for h in &active.primitives {
|
for h in &active.primitives {
|
||||||
let mask = self.layers.free(h);
|
let mask = self.primitives.free(h);
|
||||||
|
if h.pos != NOT_DRAWN {
|
||||||
|
self.layers[h.layer].free(h.pos, h.is_image());
|
||||||
|
}
|
||||||
if mask != MaskIdx::NONE {
|
if mask != MaskIdx::NONE {
|
||||||
rsc.ui_mut().masks.remove(mask);
|
rsc.ui_mut().masks.remove(mask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc);
|
||||||
active.textures.clear();
|
active.textures.clear();
|
||||||
rsc.ui_mut().textures.free();
|
rsc.ui_mut().textures.free();
|
||||||
if undraw {
|
if undraw {
|
||||||
@@ -514,8 +733,15 @@ impl UiRenderState {
|
|||||||
// section 2's lifecycle note).
|
// section 2's lifecycle note).
|
||||||
if active.own_mask != MaskIdx::NONE {
|
if active.own_mask != MaskIdx::NONE {
|
||||||
// The self-ownership ref `Painter::set_mask` took when
|
// 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);
|
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;
|
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
||||||
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
||||||
@@ -528,6 +754,54 @@ impl UiRenderState {
|
|||||||
active
|
active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A mask whose shape primitive was just freed clips to a slot that
|
||||||
|
/// now holds something else, so the widget that owns it is marked for
|
||||||
|
/// redraw -- its own `set_mask` is the only thing that resolves the
|
||||||
|
/// slot, and it is the same mechanism a dirty widget already goes
|
||||||
|
/// through.
|
||||||
|
///
|
||||||
|
/// `own` is the mask belonging to the widget being removed and is
|
||||||
|
/// skipped: this runs in the middle of that widget's own redraw,
|
||||||
|
/// which sets its mask again on the way out, and a mark left on
|
||||||
|
/// itself would redraw it every frame from then on. Skipping it is
|
||||||
|
/// also what keeps the O(active) scan off the ordinary path -- a
|
||||||
|
/// plain `.masked()` frees exactly its own shape, so `stale` is empty
|
||||||
|
/// and this returns before touching `active`.
|
||||||
|
///
|
||||||
|
/// Both `Vec`s start empty and stay unallocated in that case, and
|
||||||
|
/// membership is a linear scan of two lists that are a handful long
|
||||||
|
/// (a widget's own primitives, and the live masks): this runs once
|
||||||
|
/// per widget removed, which is once per dirty widget per frame, and
|
||||||
|
/// a set built there would be an allocation on the phone's frame
|
||||||
|
/// path in exchange for nothing at these sizes.
|
||||||
|
fn remask_shape_users(
|
||||||
|
active: &HashMap<WidgetId, ActiveData>,
|
||||||
|
id: WidgetId,
|
||||||
|
own: MaskIdx,
|
||||||
|
freed: &[PrimitiveHandle],
|
||||||
|
rsc: &mut dyn UiRsc,
|
||||||
|
) {
|
||||||
|
let mut stale: Vec<MaskIdx> = Vec::new();
|
||||||
|
for (i, mask) in rsc.ui().masks.iter().enumerate() {
|
||||||
|
let idx = Id::preset(i as u32);
|
||||||
|
if idx != own && freed.iter().any(|h| h.slot == mask.primitive) {
|
||||||
|
stale.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stale.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut owners: Vec<WidgetId> = Vec::new();
|
||||||
|
for (widget, data) in active {
|
||||||
|
if *widget != id && stale.contains(&data.own_mask) {
|
||||||
|
owners.push(*widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner in owners {
|
||||||
|
rsc.widgets_mut().needs_redraw.insert(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
|
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
|
||||||
let inst = self.remove(id, true, rsc);
|
let inst = self.remove(id, true, rsc);
|
||||||
if let Some(inst) = &inst {
|
if let Some(inst) = &inst {
|
||||||
@@ -543,6 +817,7 @@ impl UiRenderState {
|
|||||||
rsc.on_undraw(&active);
|
rsc.on_undraw(&active);
|
||||||
}
|
}
|
||||||
self.layers.clear();
|
self.layers.clear();
|
||||||
|
self.primitives.clear();
|
||||||
rsc.widgets_mut().needs_redraw.clear();
|
rsc.widgets_mut().needs_redraw.clear();
|
||||||
rsc.free();
|
rsc.free();
|
||||||
}
|
}
|
||||||
@@ -585,8 +860,9 @@ impl UiRenderState {
|
|||||||
/// Primitive instances still bound for the GPU whose owner is no
|
/// Primitive instances still bound for the GPU whose owner is no
|
||||||
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
||||||
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
||||||
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
|
/// frame at whatever position it last had. `(slot, owner)` each --
|
||||||
/// 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
|
/// Asserted empty at the end of every [`Self::update`], because this
|
||||||
/// is exactly the shape of the duplicated transcript row on Iris's
|
/// is exactly the shape of the duplicated transcript row on Iris's
|
||||||
@@ -595,20 +871,15 @@ impl UiRenderState {
|
|||||||
/// much alive -- it is the *earlier* set of primitives that got
|
/// much alive -- it is the *earlier* set of primitives that got
|
||||||
/// stranded when the widget was drawn a second time without the first
|
/// stranded when the widget was drawn a second time without the first
|
||||||
/// draw being freed. O(primitives), debug builds only.
|
/// 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();
|
let mut orphans = Vec::new();
|
||||||
for (layer, primitives) in self.layers.iter() {
|
for (slot, owner, _) in self.primitives.live_instances() {
|
||||||
for (inst_idx, owner, is_image) in primitives.live_instances() {
|
let owned = self
|
||||||
let owned = self.active.get(&owner).is_some_and(|a| {
|
.active
|
||||||
a.primitives.iter().any(|h| {
|
.get(&owner)
|
||||||
h.layer == layer
|
.is_some_and(|a| a.primitives.iter().any(|h| h.slot == slot));
|
||||||
&& h.inst_idx == inst_idx
|
|
||||||
&& (h.binding == IMAGE_BINDING) == is_image
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if !owned {
|
if !owned {
|
||||||
orphans.push((layer, inst_idx, owner));
|
orphans.push((slot, owner));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
orphans
|
orphans
|
||||||
@@ -622,7 +893,7 @@ impl UiRenderState {
|
|||||||
/// transcript is tens of thousands and made a debug build on a phone
|
/// transcript is tens of thousands and made a debug build on a phone
|
||||||
/// too slow to finish a benchmark run.
|
/// too slow to finish a benchmark run.
|
||||||
fn primitive_counts_agree(&self) -> bool {
|
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();
|
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
||||||
live == owned
|
live == owned
|
||||||
}
|
}
|
||||||
@@ -636,10 +907,10 @@ impl UiRenderState {
|
|||||||
let mut lines: Vec<String> = orphans
|
let mut lines: Vec<String> = orphans
|
||||||
.iter()
|
.iter()
|
||||||
.take(8)
|
.take(8)
|
||||||
.map(|(layer, idx, owner)| {
|
.map(|(slot, owner)| {
|
||||||
let alive = self.active.contains_key(owner);
|
let alive = self.active.contains_key(owner);
|
||||||
format!(
|
format!(
|
||||||
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
|
" instance {slot}: owner '{}' ({owner:?}), owner still active: {alive}",
|
||||||
rsc.widgets().label(*owner),
|
rsc.widgets().label(*owner),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -684,12 +955,12 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn debug_layers(&self) {
|
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 indent = " ".repeat(depth * 2);
|
||||||
let len = primitives.instances().len();
|
let len = order.order().len();
|
||||||
print!("{indent}{idx}: {len} primitives");
|
print!("{indent}{idx}: {len} primitives");
|
||||||
if len >= 1 {
|
if len >= 1 {
|
||||||
print!(" ({})", primitives.instances()[0].binding);
|
print!(" ({})", self.primitives.instance(order.order()[0]).binding);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
@@ -713,13 +984,13 @@ impl UiRenderState {
|
|||||||
|
|
||||||
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
||||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||||
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
/// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||||
/// about where the chain ends.
|
/// about where the chain ends.
|
||||||
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||||
let offsets = &rsc.ui().move_offsets;
|
let offsets = &rsc.ui().move_offsets;
|
||||||
let mut delta = Vec2::ZERO;
|
let mut delta = Vec2::ZERO;
|
||||||
let mut at = slot;
|
let mut at = slot;
|
||||||
for i in 0..MOVE_CHAIN_LIMIT {
|
for i in 0..PARENT_CHAIN_LIMIT {
|
||||||
let entry = &offsets[at.idx()];
|
let entry = &offsets[at.idx()];
|
||||||
delta.x += entry.delta[0];
|
delta.x += entry.delta[0];
|
||||||
delta.y += entry.delta[1];
|
delta.y += entry.delta[1];
|
||||||
@@ -732,8 +1003,9 @@ impl UiRenderState {
|
|||||||
// follow are different faults with different fixes, and the
|
// follow are different faults with different fixes, and the
|
||||||
// slot numbers are the only thing that tells them apart.
|
// slot numbers are the only thing that tells them apart.
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
i + 1 < MOVE_CHAIN_LIMIT,
|
i + 1 < PARENT_CHAIN_LIMIT,
|
||||||
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
|
"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 \
|
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",
|
nests deeper than shader.wgsl's own walk of the same bound",
|
||||||
chain = Self::move_chain_debug(slot, offsets)
|
chain = Self::move_chain_debug(slot, offsets)
|
||||||
@@ -743,13 +1015,13 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
/// 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
|
/// rather than as a chain that merely stops. Only ever called from the
|
||||||
/// failed assertion above.
|
/// failed assertion above.
|
||||||
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
let mut at = slot;
|
let mut at = slot;
|
||||||
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
|
for _ in 0..PARENT_CHAIN_LIMIT * 2 {
|
||||||
let entry = &offsets[at.idx()];
|
let entry = &offsets[at.idx()];
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"{}({}, {})",
|
"{}({}, {})",
|
||||||
@@ -765,6 +1037,92 @@ impl UiRenderState {
|
|||||||
parts.join(" -> ")
|
parts.join(" -> ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One primitive's corners in window pixels -- the transliteration of
|
||||||
|
/// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is
|
||||||
|
/// the whole reason this is not `region.to_px()`: the shader floors
|
||||||
|
/// each half separately before adding the move delta, and a hit test
|
||||||
|
/// that skipped it would disagree with the pixels by up to one along
|
||||||
|
/// each edge -- invisible in every test written against a whole-pixel
|
||||||
|
/// layout and wrong on the phone, whose 2.55 density makes nothing
|
||||||
|
/// land on a whole pixel.
|
||||||
|
pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion {
|
||||||
|
let inst = self.primitives.instance(slot);
|
||||||
|
let delta = self.resolve_move_chain(inst.move_idx, rsc);
|
||||||
|
let size = self.output_size;
|
||||||
|
let corner = |c: UiVec2| (c.get_rel() * size).floor() + c.get_abs().floor() + delta;
|
||||||
|
PixelRegion {
|
||||||
|
top_left: corner(inst.region.top_left()),
|
||||||
|
bot_right: corner(inst.region.bot_right()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a mask's clip actually is on screen: the box of the
|
||||||
|
/// primitive it references. Its *shape* within that box is
|
||||||
|
/// [`Self::mask_coverage`]'s -- this is the bounding box, which is
|
||||||
|
/// what a test asking "is the clip over the right part of the screen"
|
||||||
|
/// wants and all a square-cornered mask has ever had.
|
||||||
|
pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion {
|
||||||
|
self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the pixel at `pos` (window pixels) survives `mask` and
|
||||||
|
/// every mask it nests inside: the referenced primitives' own
|
||||||
|
/// coverage, multiplied along the chain. The CPU half of
|
||||||
|
/// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same
|
||||||
|
/// `rounded_rect_coverage` -- so a corner that cannot be tapped and a
|
||||||
|
/// corner that is not drawn are the same corner (LAYOUT.md's "Masks
|
||||||
|
/// with a shape", point 4).
|
||||||
|
///
|
||||||
|
/// A mask whose shape is not a rect covers everything, exactly as the
|
||||||
|
/// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects
|
||||||
|
/// those by name, so this is the unreachable half of the same
|
||||||
|
/// agreement rather than a second policy.
|
||||||
|
pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 {
|
||||||
|
let mut coverage = 1.0;
|
||||||
|
let mut at = mask;
|
||||||
|
for i in 0..PARENT_CHAIN_LIMIT {
|
||||||
|
if at == MaskIdx::NONE {
|
||||||
|
return coverage;
|
||||||
|
}
|
||||||
|
let m = rsc.ui().masks[at.idx()];
|
||||||
|
if let Some(rect) = self.primitives.primitive_data::<RectPrimitive>(m.primitive) {
|
||||||
|
let c = self.primitive_corners(m.primitive, rsc);
|
||||||
|
coverage *= rounded_rect_coverage(pos, c.top_left, c.bot_right, rect.radius);
|
||||||
|
}
|
||||||
|
at = m.parent;
|
||||||
|
debug_assert!(
|
||||||
|
i + 1 < PARENT_CHAIN_LIMIT || at == MaskIdx::NONE,
|
||||||
|
"mask chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}) from {mask:?} -- 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
coverage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `pos` is inside `mask` at all -- more than half covered,
|
||||||
|
/// which is where the drawn edge is (`rounded_rect_coverage`'s doc).
|
||||||
|
/// What a hit test asks.
|
||||||
|
pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool {
|
||||||
|
self.mask_coverage(mask, pos, rsc) > 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first primitive `id`'s subtree wrote this frame, depth first
|
||||||
|
/// in draw order -- what a mask pointed at a widget clips to
|
||||||
|
/// (`Painter::set_mask_to_widget`). A widget that draws more than one
|
||||||
|
/// (a bordered rect is one primitive; a card with a stripe is two)
|
||||||
|
/// gives its first; a widget that wants another names it.
|
||||||
|
pub fn first_primitive(&self, id: WidgetId) -> Option<u32> {
|
||||||
|
let active = self.active.get(&id)?;
|
||||||
|
if let Some(h) = active.primitives.first() {
|
||||||
|
return Some(h.slot);
|
||||||
|
}
|
||||||
|
active
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.find_map(|child| self.first_primitive(*child))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
||||||
let region = self.resolved_region(id, rsc)?;
|
let region = self.resolved_region(id, rsc)?;
|
||||||
Some(region.to_px(self.output_size))
|
Some(region.to_px(self.output_size))
|
||||||
|
|||||||
+10
-113
@@ -1,19 +1,13 @@
|
|||||||
//! Where the desktop app keeps the enrollment it should not have to be
|
//! Where the desktop app keeps its enrollment: `client_core::config`'s
|
||||||
//! told about a second time: `client_core::config::EnrolledServer`,
|
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
//! Only the directory is this app's -- the file's name, its JSON, and its
|
||||||
//! rules (`format`) are for configs a person hand-edits, and this file
|
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
|
||||||
//! never is one -- only this program ever writes or reads it, and
|
//! are the store's, shared with the Android client so the two cannot come
|
||||||
//! `serde_json` is already in the dependency graph through `client-core`,
|
//! to disagree about them.
|
||||||
//! so nothing new is added to reach for it.
|
|
||||||
|
|
||||||
use client_core::config::EnrolledServer;
|
use client_core::config::EnrollmentStore;
|
||||||
use std::io;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
|
/// `$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
|
/// 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")
|
base.join("ai-app-desktop")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enrollment_file(dir: &Path) -> PathBuf {
|
pub fn store() -> EnrollmentStore {
|
||||||
dir.join("enrollment.json")
|
EnrollmentStore::new(config_dir())
|
||||||
}
|
|
||||||
|
|
||||||
/// 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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,20 @@
|
|||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//!
|
//!
|
||||||
//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T'
|
//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B'
|
||||||
//! desktop-app --ca /path/to/ca.pem # after the first run above
|
//! 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
|
//! `--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
|
//! 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
|
//! 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
|
//! saved once; later runs read it back and `--link` is only needed again
|
||||||
//! `--link` is only needed again to enrol against a different server. The
|
//! to enrol against a different server.
|
||||||
//! CA is never persisted -- it is a public certificate whose path a
|
//!
|
||||||
//! caller is expected to already know (`AGENTS.md`'s "prefer exercising
|
//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which
|
||||||
//! the server directly": the same `certs/ca.pem` a `curl --cacert` call
|
//! `ai-server` now always includes) and is saved with it. `--ca` is the
|
||||||
//! uses).
|
//! override for a link that carries none, and names the same
|
||||||
|
//! `certs/ca.pem` a `curl --cacert` call uses.
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -24,7 +26,7 @@ mod config;
|
|||||||
use client_core::config::EnrolledServer;
|
use client_core::config::EnrolledServer;
|
||||||
|
|
||||||
struct Args {
|
struct Args {
|
||||||
ca_path: std::path::PathBuf,
|
ca_path: Option<std::path::PathBuf>,
|
||||||
link: Option<String>,
|
link: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +45,7 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
other => return Err(format!("unrecognised argument '{other}'")),
|
other => return Err(format!("unrecognised argument '{other}'")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Args {
|
Ok(Args { ca_path, link })
|
||||||
ca_path: ca_path.ok_or(
|
|
||||||
"--ca PATH is required (the pinned CA's certificate, e.g. \
|
|
||||||
~/.config/ai-app/certs/ca.pem)",
|
|
||||||
)?,
|
|
||||||
link,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
|
/// 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).
|
/// other way (`DefaultApp::run()` takes no payload).
|
||||||
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||||
let args = parse_args()?;
|
let args = parse_args()?;
|
||||||
|
let store = config::store();
|
||||||
let server = match args.link {
|
let server = match args.link {
|
||||||
Some(link) => {
|
Some(link) => {
|
||||||
let server = EnrolledServer::parse_link(&link)?;
|
let server = EnrolledServer::parse_link(&link)?;
|
||||||
config::save_enrollment(&server)
|
store
|
||||||
|
.save(&server)
|
||||||
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||||
server
|
server
|
||||||
}
|
}
|
||||||
None => config::load_enrollment()
|
None => store
|
||||||
|
.load()
|
||||||
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -77,8 +76,20 @@ fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
|||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
};
|
};
|
||||||
let ca_pem = std::fs::read(&args.ca_path)
|
// `--ca` wins where it was given, so a caller can point a link's
|
||||||
.map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?;
|
// 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))
|
Ok((server, ca_pem))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -18,6 +18,12 @@ struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct InputFn {
|
struct InputFn {
|
||||||
|
/// Everything written above the `fn` -- in practice a `///` doc
|
||||||
|
/// comment, which is why this exists: `masked_by` and its siblings
|
||||||
|
/// are public API and rustdoc is where their contract is read, so a
|
||||||
|
/// macro that silently rejected `///` sent the explanation into an
|
||||||
|
/// ordinary `//` comment nobody generating docs ever sees.
|
||||||
|
attrs: Vec<Attribute>,
|
||||||
sig: Signature,
|
sig: Signature,
|
||||||
body: Block,
|
body: Block,
|
||||||
}
|
}
|
||||||
@@ -32,9 +38,10 @@ impl Parse for Input {
|
|||||||
input.parse::<Token![;]>()?;
|
input.parse::<Token![;]>()?;
|
||||||
let mut fns = Vec::new();
|
let mut fns = Vec::new();
|
||||||
while !input.is_empty() {
|
while !input.is_empty() {
|
||||||
|
let attrs = input.call(Attribute::parse_outer)?;
|
||||||
let sig = input.parse()?;
|
let sig = input.parse()?;
|
||||||
let body = input.parse()?;
|
let body = input.parse()?;
|
||||||
fns.push(InputFn { sig, body })
|
fns.push(InputFn { attrs, sig, body })
|
||||||
}
|
}
|
||||||
if !input.is_empty() {
|
if !input.is_empty() {
|
||||||
input.error("function expected");
|
input.error("function expected");
|
||||||
@@ -59,10 +66,15 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
|
|||||||
fns,
|
fns,
|
||||||
} = parse_macro_input!(input as Input);
|
} = parse_macro_input!(input as Input);
|
||||||
|
|
||||||
let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect();
|
// The attributes go on the trait's own signature, which is the one
|
||||||
|
// rustdoc renders; the impl gets the bare `fn`.
|
||||||
|
let sigs: Vec<_> = fns
|
||||||
|
.iter()
|
||||||
|
.map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig })
|
||||||
|
.collect();
|
||||||
let impls: Vec<_> = fns
|
let impls: Vec<_> = fns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|InputFn { sig, body }| quote! { #sig #body })
|
.map(|InputFn { sig, body, .. }| quote! { #sig #body })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let Some(GenericParam::Type(state)) = generics.params.first() else {
|
let Some(GenericParam::Type(state)) = generics.params.first() else {
|
||||||
|
|||||||
+59
-26
@@ -88,9 +88,10 @@ pub struct FrameDiagnostics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AndroidRenderer {
|
impl AndroidRenderer {
|
||||||
/// `Err` holds a full, human-readable report -- wgpu's own error text
|
/// `Err` holds a full, human-readable report for **every** way this
|
||||||
/// (`UiRenderNode::new`'s doc comment) plus the adapter identity and
|
/// can fail -- no surface, no adapter, no device, or wgpu's own error
|
||||||
/// the limits/downlevel flags bind-group-layout validation checks
|
/// 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
|
/// against -- rather than the panic wgpu's default error handler would
|
||||||
/// otherwise raise with no caller able to see it. This is what aborted
|
/// 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
|
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
|
||||||
@@ -107,29 +108,67 @@ impl AndroidRenderer {
|
|||||||
height: u32,
|
height: u32,
|
||||||
content_scale: f32,
|
content_scale: f32,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
|
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
|
||||||
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
|
// the build to GLES, to isolate whether the backend itself explains
|
||||||
// build, to isolate whether the backend itself explains the frame
|
// the frame time gap against Compose. `cfg!` rather than a runtime
|
||||||
// time gap against Compose. `cfg!` rather than a runtime switch:
|
// switch: there is no way to hand an env var to an already-launched
|
||||||
// there is no way to hand an env var to an already-launched Android
|
// Android process on this machine (see the feature's doc in
|
||||||
// process on this machine (see the feature's doc in Cargo.toml).
|
// Cargo.toml).
|
||||||
let backends = if cfg!(feature = "force-gles") {
|
//
|
||||||
|
// 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
|
Backends::GL
|
||||||
} else {
|
} else {
|
||||||
Backends::PRIMARY
|
Backends::PRIMARY
|
||||||
};
|
};
|
||||||
let instance = Instance::new(&InstanceDescriptor {
|
let mut instance = Instance::new(&InstanceDescriptor {
|
||||||
backends,
|
backends,
|
||||||
..Default::default()
|
..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 --
|
// SAFETY: the `NativeWindow` outlives the surface built from it --
|
||||||
// android-view drops the old renderer (and this surface with it)
|
// android-view drops the old renderer (and this surface with it)
|
||||||
// before handing over a new window, in `surface_changed` below.
|
// before handing over a new window, in `surface_changed` below.
|
||||||
let surface = instance
|
let surface = instance
|
||||||
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
|
.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
|
let adapter = instance
|
||||||
.request_adapter(&RequestAdapterOptions {
|
.request_adapter(&RequestAdapterOptions {
|
||||||
power_preference: PowerPreference::default(),
|
power_preference: PowerPreference::default(),
|
||||||
@@ -137,19 +176,7 @@ impl AndroidRenderer {
|
|||||||
force_fallback_adapter: false,
|
force_fallback_adapter: false,
|
||||||
})
|
})
|
||||||
.block_on()
|
.block_on()
|
||||||
.expect("Could not get adapter!");
|
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
|
||||||
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Same request as the winit backend's `UiRenderer::new` -- no
|
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||||
// binding-array features, see TEXTURES.md's "Recommended shape".
|
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||||
@@ -161,7 +188,13 @@ impl AndroidRenderer {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.block_on()
|
.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::
|
// wgpu's default handler for an error raised outside `UiRenderNode::
|
||||||
// new`'s own error scopes (i.e. everything past device creation --
|
// new`'s own error scopes (i.e. everything past device creation --
|
||||||
|
|||||||
+74
-32
@@ -7,7 +7,7 @@ use android_view::{
|
|||||||
jni::{
|
jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
objects::{GlobalRef, JValue},
|
objects::{GlobalRef, JValue},
|
||||||
sys::{jint, jlong},
|
sys::jint,
|
||||||
},
|
},
|
||||||
ndk::event::{Axis, Keycode, MotionAction},
|
ndk::event::{Axis, Keycode, MotionAction},
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,7 @@ use std::{
|
|||||||
marker::{PhantomData, Sized},
|
marker::{PhantomData, Sized},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, Instant},
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -312,12 +312,11 @@ pub struct IrisViewPeer<State: AndroidAppState> {
|
|||||||
pub(super) render: UiRenderState,
|
pub(super) render: UiRenderState,
|
||||||
pub(super) state: State,
|
pub(super) state: State,
|
||||||
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
||||||
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
|
/// Anchored on the first `MotionEvent` this view receives and never
|
||||||
/// captured from the first `MotionEvent` this view receives and never
|
/// re-anchored after -- how `on_touch_event` dates every touch sample.
|
||||||
/// changed after -- how `on_touch_event` dates every touch sample. Its
|
/// Its path out is the peer's own drop: it holds nothing but three
|
||||||
/// path out is the peer's own drop: it holds nothing but two numbers
|
/// numbers and is meaningless to any other view.
|
||||||
/// and is meaningless to any other view.
|
input_clock: Option<PointerClock>,
|
||||||
input_clock: Option<(Instant, jlong)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
||||||
@@ -406,7 +405,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
/// magenta and screenshotting), but no primitive ever appears on top of
|
/// magenta and screenshotting), but no primitive ever appears on top of
|
||||||
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
|
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
|
||||||
/// these in until that is root-caused; removing them loses the exact
|
/// 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) {
|
fn render(&mut self, ctx: &mut CallbackCtx) {
|
||||||
if self.state.android_state().renderer.is_none() {
|
if self.state.android_state().renderer.is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -441,8 +446,18 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
let ui_state = self.state.android_state();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
|
target: "iris::frame",
|
||||||
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
||||||
ui_state.root.is_some(),
|
ui_state.root.is_some(),
|
||||||
self.rsc.widgets().len(),
|
self.rsc.widgets().len(),
|
||||||
@@ -453,6 +468,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
||||||
self.window_size(),
|
self.window_size(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
||||||
// taken" (b)): started here, at the same point a redraw request
|
// taken" (b)): started here, at the same point a redraw request
|
||||||
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
|
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
|
||||||
@@ -498,14 +514,17 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
.android_state_mut()
|
.android_state_mut()
|
||||||
.frame_report
|
.frame_report
|
||||||
.record_split(frame_start.elapsed(), submit_to_present);
|
.record_split(frame_start.elapsed(), submit_to_present);
|
||||||
|
crate::diagnostics::log_frame(&self.render, frame_start, submit_to_present, animating);
|
||||||
// A frame callback is one-shot, so an animation that wants
|
// A frame callback is one-shot, so an animation that wants
|
||||||
// another frame has to say so every frame -- unlike `after_input`,
|
// another frame has to say so every frame -- unlike `after_input`,
|
||||||
// which only has to ask when input dirtied something.
|
// which only has to ask when input dirtied something.
|
||||||
if animating {
|
if animating {
|
||||||
ctx.view.post_frame_callback(&mut ctx.env);
|
ctx.view.post_frame_callback(&mut ctx.env);
|
||||||
}
|
}
|
||||||
|
if crate::diagnostics::trace_enabled() {
|
||||||
let ui_state = self.state.android_state();
|
let ui_state = self.state.android_state();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
|
target: "iris::frame",
|
||||||
"render(): after update active={} root_px={:?}",
|
"render(): after update active={} root_px={:?}",
|
||||||
self.render.active_widgets(),
|
self.render.active_widgets(),
|
||||||
ui_state
|
ui_state
|
||||||
@@ -513,6 +532,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
|
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
|
||||||
// anything to raise -- when the named set actually changed this
|
// 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
|
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
|
||||||
// `(Instant, nanos)` pair converts every later sample exactly.
|
// `(Instant, nanos)` pair converts every later sample exactly.
|
||||||
// Anchoring **once** rather than per event is what keeps the times
|
// Anchoring **once** rather than per event is what keeps the times
|
||||||
// ordered: a fresh `Instant::now()` per event, minus each sample's
|
// ordered, and anchoring on the first event's *oldest* sample
|
||||||
// age inside it, can date a later event's first historical sample
|
// rather than on its own time is what keeps that event's batch
|
||||||
// before the previous event's last one whenever delivery jitters by
|
// from collapsing onto one instant -- `sense::PointerClock`'s doc
|
||||||
// more than the batch spans -- and `VelocityTracker::add_sample`'s
|
// has both, and owns the arithmetic so it can be unit-tested off a
|
||||||
// debug assert would rightly fire on that. See `CursorState::time`.
|
// device (`sense_tests.rs`). See `CursorState::time`.
|
||||||
let event_time = event.event_time_nanos(&mut ctx.env);
|
let event_time = event.event_time_nanos(&mut ctx.env);
|
||||||
let (anchor_at, anchor_nanos) =
|
if self.input_clock.is_none() {
|
||||||
*self.input_clock.get_or_insert((Instant::now(), event_time));
|
let history = event.history_size(&mut ctx.env);
|
||||||
let at = |sample_time: jlong| {
|
let oldest = if history > 0 {
|
||||||
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
|
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
|
// **Historical samples first.** A flick on a 120Hz screen is
|
||||||
// delivered as one or two `MotionEvent`s with the intermediate
|
// 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
|
// the event's own sample as the newest of the batch; everything
|
||||||
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
||||||
// clock) assumes it, so say so here rather than at each reader.
|
// 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 {
|
for pos in 0..history {
|
||||||
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
|
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 hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
|
||||||
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
||||||
debug_assert!(
|
let sample_at = clock.sample(ht);
|
||||||
ht >= previous,
|
if trace_input {
|
||||||
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
|
historical_ms.push((clock.ms_since_anchor(ht), hx, hy));
|
||||||
sample ahead of it -- the input clock is not what this assumes"
|
}
|
||||||
);
|
|
||||||
previous = ht;
|
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
ui_state.cursor.pos = vec2(hx, hy);
|
ui_state.cursor.pos = vec2(hx, hy);
|
||||||
ui_state.cursor.time = at(ht);
|
ui_state.cursor.time = sample_at;
|
||||||
self.run_input_frame(ctx);
|
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();
|
let ui_state = self.state.android_state_mut();
|
||||||
ui_state.cursor.time = at(event_time);
|
ui_state.cursor.time = event_at;
|
||||||
match action {
|
match action {
|
||||||
MotionAction::Down => {
|
MotionAction::Down => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
@@ -695,6 +727,16 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
}
|
}
|
||||||
_ => return false,
|
_ => 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);
|
self.after_input(ctx);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-1
@@ -294,6 +294,31 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
ui_state.focus = None;
|
ui_state.focus = None;
|
||||||
}
|
}
|
||||||
if input_changed {
|
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();
|
let window_size = ui_state.window_size();
|
||||||
render.run_sensors(rsc, state, cursor_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
|
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||||
// Android. Nothing else in iris moves without an input
|
// Android. Nothing else in iris moves without an input
|
||||||
// event.
|
// 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();
|
let ui_state = state.default_state_mut();
|
||||||
render.update(&ui_state.root, rsc);
|
render.update(&ui_state.root, rsc);
|
||||||
ui_state.renderer.update(&mut rsc.ui, render);
|
ui_state.renderer.update(&mut rsc.ui, render);
|
||||||
|
let draw_start = std::time::Instant::now();
|
||||||
ui_state.renderer.draw();
|
ui_state.renderer.draw();
|
||||||
|
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
|
||||||
if animating {
|
if animating {
|
||||||
ui_state.window.request_redraw();
|
ui_state.window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -334,6 +362,22 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
render.resize((size.width, size.height));
|
render.resize((size.width, size.height));
|
||||||
ui_state.renderer.resize(size)
|
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, .. } => {
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
if let Some(sel) = ui_state.focus
|
if let Some(sel) = ui_state.focus
|
||||||
&& event.state.is_pressed()
|
&& event.state.is_pressed()
|
||||||
|
|||||||
@@ -82,19 +82,40 @@ impl UiRenderer {
|
|||||||
pub fn new(window: Arc<Window>) -> Self {
|
pub fn new(window: Arc<Window>) -> Self {
|
||||||
let size = window.inner_size();
|
let size = window.inner_size();
|
||||||
|
|
||||||
let instance = Instance::new(&InstanceDescriptor {
|
|
||||||
// `force-gles` on the desktop too, not just on Android: the
|
// `force-gles` on the desktop too, not just on Android: the
|
||||||
// GLES backend has behaviour of its own (a one-layer array
|
// GLES backend has behaviour of its own (a one-layer array
|
||||||
// texture is a `GL_TEXTURE_2D` -- see
|
// texture is a `GL_TEXTURE_2D` -- see
|
||||||
// `GpuTextures::create_array_texture`), and a machine with a
|
// `GpuTextures::create_array_texture`), and a machine with a
|
||||||
// real GPU is where that is cheap to reproduce and screenshot.
|
// real GPU is where that is cheap to reproduce and screenshot.
|
||||||
backends: if cfg!(feature = "force-gles") {
|
let mut backends = if cfg!(feature = "force-gles") {
|
||||||
Backends::GL
|
Backends::GL
|
||||||
} else {
|
} else {
|
||||||
Backends::PRIMARY
|
Backends::PRIMARY
|
||||||
},
|
};
|
||||||
|
let mut instance = Instance::new(&InstanceDescriptor {
|
||||||
|
backends,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
// The same fallback the Android backend grew in 85869d0, and for
|
||||||
|
// the same reason: a machine can advertise a Vulkan ICD with no
|
||||||
|
// device behind it, and refusing to draw at all because the only
|
||||||
|
// usable adapter is a GLES one is iris's bug rather than the
|
||||||
|
// machine's. On this VM the virtio-gpu Venus device disappears
|
||||||
|
// whenever the host runs out of virgl contexts, so `run-headless.
|
||||||
|
// sh` -- layer 2 of the test rig -- aborted with `Could not get
|
||||||
|
// adapter!` while GL was sitting there working. Probed before the
|
||||||
|
// surface exists, matching Android, where an instance carrying
|
||||||
|
// both backends fails worse than one carrying the wrong one.
|
||||||
|
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
|
||||||
|
);
|
||||||
|
backends = Backends::GL;
|
||||||
|
instance = Instance::new(&InstanceDescriptor {
|
||||||
|
backends,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let surface = instance
|
let surface = instance
|
||||||
.create_surface(window.clone())
|
.create_surface(window.clone())
|
||||||
@@ -107,7 +128,9 @@ impl UiRenderer {
|
|||||||
force_fallback_adapter: false,
|
force_fallback_adapter: false,
|
||||||
})
|
})
|
||||||
.block_on()
|
.block_on()
|
||||||
.expect("Could not get adapter!");
|
.unwrap_or_else(|error| {
|
||||||
|
panic!("No usable GPU adapter for backends {backends:?}: {error}")
|
||||||
|
});
|
||||||
|
|
||||||
// No features beyond what wgpu asks for by default, and no
|
// No features beyond what wgpu asks for by default, and no
|
||||||
// binding-array limits: the atlas is one texture_2d_array and a
|
// binding-array limits: the atlas is one texture_2d_array and a
|
||||||
|
|||||||
@@ -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,
|
_ => 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)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -345,8 +357,14 @@ impl Harness {
|
|||||||
update(&mut self.state, &mut self.rsc);
|
update(&mut self.state, &mut self.rsc);
|
||||||
}
|
}
|
||||||
let now = self.at(t_ms);
|
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);
|
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
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||||
@@ -377,6 +395,12 @@ impl Harness {
|
|||||||
TouchAction::Move => {}
|
TouchAction::Move => {}
|
||||||
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false),
|
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();
|
let cursor = self.cursor.clone();
|
||||||
self.render
|
self.render
|
||||||
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||||
|
|||||||
+258
-6
@@ -150,8 +150,8 @@ fn hit_testing_follows_a_scrolled_widget() {
|
|||||||
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
/// `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
|
/// 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
|
/// 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
|
/// time round -- which `Painter::set_mask` asserts against, since a mask
|
||||||
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
|
/// 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
|
/// 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
|
/// redraw of a `Masked` is what any real screen does whenever anything
|
||||||
/// inside it changes.
|
/// inside it changes.
|
||||||
@@ -161,7 +161,10 @@ fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
|
|||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||||
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
let masked = rsc.ui.widgets.add_strong(Masked {
|
||||||
|
shape: None,
|
||||||
|
inner: inner_root,
|
||||||
|
});
|
||||||
let masked_id = masked.id();
|
let masked_id = masked.id();
|
||||||
let root = masked.any();
|
let root = masked.any();
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
@@ -184,7 +187,10 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
|||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500);
|
let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500);
|
||||||
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
let masked = rsc.ui.widgets.add_strong(Masked {
|
||||||
|
shape: None,
|
||||||
|
inner: inner_root,
|
||||||
|
});
|
||||||
let masked_id = masked.id();
|
let masked_id = masked.id();
|
||||||
let root = masked.any();
|
let root = masked.any();
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
@@ -418,7 +424,10 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
|||||||
ui: UiData::default(),
|
ui: UiData::default(),
|
||||||
};
|
};
|
||||||
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||||
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
|
let masked = rsc.ui.widgets.add_strong(Masked {
|
||||||
|
shape: None,
|
||||||
|
inner: inner_root,
|
||||||
|
});
|
||||||
let masked_id = masked.id();
|
let masked_id = masked.id();
|
||||||
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
|
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
|
||||||
// which is what moves the bar away from the provisional slot it is
|
// which is what moves the bar away from the provisional slot it is
|
||||||
@@ -453,7 +462,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
|||||||
);
|
);
|
||||||
let mask = *rsc.ui.masks.iter().next().unwrap();
|
let mask = *rsc.ui.masks.iter().next().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mask.region,
|
render.primitives.instance(mask.primitive).region,
|
||||||
render.active.get(&masked_id).unwrap().region,
|
render.active.get(&masked_id).unwrap().region,
|
||||||
"the mask a descendant clips against must be this widget's current box"
|
"the mask a descendant clips against must be this widget's current box"
|
||||||
);
|
);
|
||||||
@@ -655,3 +664,246 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
|
|||||||
after={after:?}"
|
after={after:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1.
|
||||||
|
//
|
||||||
|
// The shape a mask clips to is a *primitive already drawn*, never a copy
|
||||||
|
// of one, so "the child's clipped corner" and "the container's own corner"
|
||||||
|
// are the same arithmetic. These say so by evaluating both and demanding
|
||||||
|
// exact equality: an approximate assertion would also pass a second copy
|
||||||
|
// of the radius that merely happened to agree.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RADIUS: f32 = 20.0;
|
||||||
|
|
||||||
|
/// A rounded container with `.masked_by` it, holding a `Rect::REST` child
|
||||||
|
/// that fills it -- so the child's own corners are exactly the corners
|
||||||
|
/// being clipped away. Returns the drawn state, the mask, the child, and
|
||||||
|
/// the shape primitive the mask points at.
|
||||||
|
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
|
||||||
|
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let child_id = child.id();
|
||||||
|
let shape = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
|
||||||
|
let shape_id = shape.id();
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Masked {
|
||||||
|
shape: Some(shape.any()),
|
||||||
|
inner: child.any(),
|
||||||
|
})
|
||||||
|
.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((200.0, 100.0));
|
||||||
|
render.update(&root, rsc);
|
||||||
|
|
||||||
|
let mask = render
|
||||||
|
.active
|
||||||
|
.get(&child_id)
|
||||||
|
.expect("the child is drawn")
|
||||||
|
.mask;
|
||||||
|
assert_ne!(
|
||||||
|
mask,
|
||||||
|
MaskIdx::NONE,
|
||||||
|
"the child was drawn with no clip at all"
|
||||||
|
);
|
||||||
|
let slot = render
|
||||||
|
.first_primitive(shape_id)
|
||||||
|
.expect("the shape widget drew a rect");
|
||||||
|
(render, mask, child_id, slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pass condition: the child's coverage at a corner pixel *equals*
|
||||||
|
/// the container's own coverage there. Exactly equal, because it is the
|
||||||
|
/// same primitive evaluated once -- LAYOUT.md's point 1.
|
||||||
|
#[test]
|
||||||
|
fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (render, mask, _child, slot) = rounded_container(&mut rsc);
|
||||||
|
let corners = render.primitive_corners(slot, &rsc);
|
||||||
|
let radius = render
|
||||||
|
.primitives
|
||||||
|
.primitive_data::<RectPrimitive>(slot)
|
||||||
|
.expect("a mask's shape is a rect")
|
||||||
|
.radius;
|
||||||
|
|
||||||
|
// Across the whole corner arc, not one point on it: a single sample
|
||||||
|
// is satisfied by a mask that clips to the box and happens to agree
|
||||||
|
// where the two coincide. Swept from the arc's own centre -- the
|
||||||
|
// straight chord between the two ends of the arc lies *inside* the
|
||||||
|
// circle everywhere, so a walk along it never leaves the shape and
|
||||||
|
// the `outside` count below is what caught that.
|
||||||
|
let arc_center = corners.top_left + Vec2::new(radius, radius);
|
||||||
|
let (mut outside, mut inside) = (0, 0);
|
||||||
|
for i in 0..=20 {
|
||||||
|
let angle = std::f32::consts::FRAC_PI_2 * i as f32 / 20.0;
|
||||||
|
let dir = Vec2::new(-angle.cos(), -angle.sin());
|
||||||
|
for out in [-1.5f32, 0.0, 1.5] {
|
||||||
|
let pos = arc_center + dir * (radius + out);
|
||||||
|
let container = rounded_rect_coverage(pos, corners.top_left, corners.bot_right, radius);
|
||||||
|
assert_eq!(
|
||||||
|
render.mask_coverage(mask, pos, &rsc),
|
||||||
|
container,
|
||||||
|
"at {pos:?} the child's clip and the container's own edge disagree",
|
||||||
|
);
|
||||||
|
if container < 0.5 {
|
||||||
|
outside += 1;
|
||||||
|
} else {
|
||||||
|
inside += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
outside > 0 && inside > 0,
|
||||||
|
"the sweep stayed on one side of the curve ({outside} out, {inside} in), so it proved \
|
||||||
|
nothing about the corner"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hit test asks the same question the pixels do: the corner the
|
||||||
|
/// container rounded away is not there to be pressed, and a point just
|
||||||
|
/// inside the curve is. LAYOUT.md's point 4.
|
||||||
|
#[test]
|
||||||
|
fn a_mask_s_shape_decides_what_can_be_pressed() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (render, mask, _child, slot) = rounded_container(&mut rsc);
|
||||||
|
let corners = render.primitive_corners(slot, &rsc);
|
||||||
|
|
||||||
|
// The very corner of the box, which the radius cut off.
|
||||||
|
let cut = corners.top_left + Vec2::new(1.0, 1.0);
|
||||||
|
assert!(
|
||||||
|
!render.mask_admits(mask, cut, &rsc),
|
||||||
|
"the corner the container rounded away is still pressable",
|
||||||
|
);
|
||||||
|
// The same distance in along the diagonal, past the curve.
|
||||||
|
let inside = corners.top_left + Vec2::new(RADIUS, RADIUS);
|
||||||
|
assert!(
|
||||||
|
render.mask_admits(mask, inside, &rsc),
|
||||||
|
"a point well inside the curve is not pressable",
|
||||||
|
);
|
||||||
|
// And the middle of an edge, which no radius touches -- the half the
|
||||||
|
// rounding had no reason to change.
|
||||||
|
let edge = Vec2::new(
|
||||||
|
(corners.top_left.x + corners.bot_right.x) / 2.0,
|
||||||
|
corners.top_left.y + 1.0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
render.mask_admits(mask, edge, &rsc),
|
||||||
|
"a straight edge between two corners is not pressable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested masks multiply, so a pixel inside two feathered corners is
|
||||||
|
/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be
|
||||||
|
/// decreased / multiplied" Iris asked for. Written as a product of the
|
||||||
|
/// two the shader would compute separately, which is what "multiply"
|
||||||
|
/// means and what an intersection test would get wrong.
|
||||||
|
#[test]
|
||||||
|
fn nested_masks_multiply_their_coverage() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let child_id = child.id();
|
||||||
|
|
||||||
|
let inner_shape = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
|
||||||
|
let inner_shape_id = inner_shape.id();
|
||||||
|
let inner = rsc.ui.widgets.add_strong(Masked {
|
||||||
|
shape: Some(inner_shape.any()),
|
||||||
|
inner: child.any(),
|
||||||
|
});
|
||||||
|
let outer_shape = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
|
||||||
|
let outer_shape_id = outer_shape.id();
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Masked {
|
||||||
|
shape: Some(outer_shape.any()),
|
||||||
|
inner: inner.any(),
|
||||||
|
})
|
||||||
|
.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((200.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mask = render.active.get(&child_id).expect("drawn").mask;
|
||||||
|
let one = |render: &UiRenderState, rsc: &TestRsc, id, pos| {
|
||||||
|
let slot = render.first_primitive(id).expect("a shape rect");
|
||||||
|
let c = render.primitive_corners(slot, rsc);
|
||||||
|
let radius = render
|
||||||
|
.primitives
|
||||||
|
.primitive_data::<RectPrimitive>(slot)
|
||||||
|
.unwrap()
|
||||||
|
.radius;
|
||||||
|
rounded_rect_coverage(pos, c.top_left, c.bot_right, radius)
|
||||||
|
};
|
||||||
|
|
||||||
|
// A point on the corner arc, where both feathers are partial -- the
|
||||||
|
// only place a product and a minimum differ.
|
||||||
|
let slot = render.first_primitive(inner_shape_id).unwrap();
|
||||||
|
let corners = render.primitive_corners(slot, &rsc);
|
||||||
|
let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3);
|
||||||
|
let inner_cov = one(&render, &rsc, inner_shape_id, pos);
|
||||||
|
let outer_cov = one(&render, &rsc, outer_shape_id, pos);
|
||||||
|
assert!(
|
||||||
|
inner_cov > 0.0 && inner_cov < 1.0,
|
||||||
|
"the sample point is not inside a feather ({inner_cov}), so this proves nothing"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render.mask_coverage(mask, pos, &rsc),
|
||||||
|
inner_cov * outer_cov,
|
||||||
|
"two nested masks must multiply, not intersect",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain `.masked()` -- no shape given -- still clips to the widget's
|
||||||
|
/// own box with square corners, which is what every list and scroll area
|
||||||
|
/// relies on. The half the shape work had no reason to touch, and the one
|
||||||
|
/// that would silently round every existing clip if `set_mask` ever wrote
|
||||||
|
/// a radius of its own.
|
||||||
|
#[test]
|
||||||
|
fn a_plain_mask_still_clips_to_a_square_box() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Masked {
|
||||||
|
shape: None,
|
||||||
|
inner: inner_root,
|
||||||
|
})
|
||||||
|
.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((200.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mask = *rsc.ui.masks.iter().next().expect("one mask");
|
||||||
|
let corners = render.primitive_corners(mask.primitive, &rsc);
|
||||||
|
let mask_idx = MaskIdx::preset(0);
|
||||||
|
assert!(
|
||||||
|
render.mask_admits(mask_idx, corners.top_left + Vec2::new(0.5, 0.5), &rsc),
|
||||||
|
"a square clip must admit its own corner pixel",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!render.mask_admits(mask_idx, corners.top_left - Vec2::new(2.0, 2.0), &rsc),
|
||||||
|
"a square clip must reject a point outside it",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod android;
|
|||||||
pub mod default;
|
pub mod default;
|
||||||
|
|
||||||
pub mod attr;
|
pub mod attr;
|
||||||
|
pub mod diagnostics;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod harness;
|
pub mod harness;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
|||||||
+1025
-145
File diff suppressed because it is too large.
Load diff
+48
-1
@@ -7,7 +7,7 @@
|
|||||||
//! impl need no GPU or window.
|
//! impl need no GPU or window.
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::{cell::Cell, rc::Rc};
|
use std::{cell::Cell, rc::Rc, time::Instant};
|
||||||
|
|
||||||
struct SenseRsc {
|
struct SenseRsc {
|
||||||
ui: UiData,
|
ui: UiData,
|
||||||
@@ -317,3 +317,50 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
|||||||
// widget even once the finger leaves its box.
|
// widget even once the finger leaves its box.
|
||||||
assert_eq!(render.captured_pointer(), Some(scroll.id()));
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
+345
-32
@@ -94,11 +94,22 @@
|
|||||||
//! row that has never been measured, so this stays independent of how many
|
//! row that has never been measured, so this stays independent of how many
|
||||||
//! rows exist outside the loaded window.
|
//! rows exist outside the loaded window.
|
||||||
//!
|
//!
|
||||||
//! **What is deliberately not solved here.** No overscroll clamping: a
|
//! **Only what overlaps the viewport is drawn, and it is drawn whole.**
|
||||||
//! `scroll()` past the first or last row leaves a gap rather than rubber-
|
//! One rule, `intersects_viewport`, used by both halves of that sentence:
|
||||||
//! banding back (mirrors `Scroll`'s own documented one-frame-lag
|
//! a row straddling either edge is drawn in full and clipped by the
|
||||||
//! tolerance in LAYOUT.md, just not even auto-corrected -- there is
|
//! `.masked()` its caller must place it in (`List::draw` asserts that),
|
||||||
//! nothing to measure "how much content is left" without walking it).
|
//! 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 crate::prelude::*;
|
||||||
use iris_core::util::HashMap;
|
use iris_core::util::HashMap;
|
||||||
@@ -415,8 +426,9 @@ impl List {
|
|||||||
|
|
||||||
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
/// Move the anchor's edge by `amt` pixels. Positive moves later
|
||||||
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
/// content into view (mirrors `Scroll::scroll`'s sign convention).
|
||||||
/// Deliberately unclamped -- see the module doc's "what is not
|
/// Unclamped here, on purpose: it is one write, and there is nothing
|
||||||
/// solved here."
|
/// 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) {
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
if let Some(a) = &mut self.anchor {
|
if let Some(a) = &mut self.anchor {
|
||||||
a.offset -= amt;
|
a.offset -= amt;
|
||||||
@@ -475,9 +487,32 @@ impl List {
|
|||||||
// through) would propagate silently into `deceleration_for`'s
|
// through) would propagate silently into `deceleration_for`'s
|
||||||
// `.ln()` -- the fling either never settles or jumps to NaN
|
// `.ln()` -- the fling either never settles or jumps to NaN
|
||||||
// positions with nothing on screen saying why (docs/
|
// positions with nothing on screen saying why (docs/
|
||||||
// REVIEW-2026-09-06.md finding 3).
|
// REVIEW-2026-09-06.md finding 3). A plain `assert!` rather than a
|
||||||
debug_assert!(velocity_px_per_s.is_finite());
|
// `debug_assert!`: it is one comparison per *gesture*, and every
|
||||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
// 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;
|
self.fling = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -541,7 +576,14 @@ impl List {
|
|||||||
// observable nor observed while `distance_fraction` returned `t`
|
// observable nor observed while `distance_fraction` returned `t`
|
||||||
// (`android_fling_spline`'s doc), which is why this is here rather
|
// (`android_fling_spline`'s doc), which is why this is here rather
|
||||||
// than the total-travel line the release log already carries.
|
// than the total-travel line the release log already carries.
|
||||||
|
// 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!(
|
log::debug!(
|
||||||
|
target: "iris::frame",
|
||||||
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||||
elapsed.as_secs_f32(),
|
elapsed.as_secs_f32(),
|
||||||
delta,
|
delta,
|
||||||
@@ -549,6 +591,7 @@ impl List {
|
|||||||
velocity,
|
velocity,
|
||||||
f.calc.distance(velocity) - target,
|
f.calc.distance(velocity) - target,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
self.scroll(delta);
|
self.scroll(delta);
|
||||||
|
|
||||||
// Clamp: a fling moving toward the start that has already reached
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
@@ -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) {
|
fn update_snap_end(&mut self) {
|
||||||
self.snap_end = match self.anchor {
|
self.snap_end = match self.anchor {
|
||||||
Some(a) => {
|
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 {
|
fn abs_region(axis: Axis, start: f32, end: f32) -> UiRegion {
|
||||||
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
|
let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end));
|
||||||
UiRegion::from_axis(axis, span, UiSpan::FULL)
|
UiRegion::from_axis(axis, span, UiSpan::FULL)
|
||||||
@@ -847,7 +1015,10 @@ impl List {
|
|||||||
// sites, not by this function, which would otherwise fail with a
|
// sites, not by this function, which would otherwise fail with a
|
||||||
// bare "index out of bounds" and no context (docs/
|
// bare "index out of bounds" and no context (docs/
|
||||||
// REVIEW-2026-09-06.md finding 2). `slot_widget`, called from
|
// 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!(
|
debug_assert!(
|
||||||
self.slot_exists(slot),
|
self.slot_exists(slot),
|
||||||
"place() called with a slot that doesn't exist: {slot:?}"
|
"place() called with a slot that doesn't exist: {slot:?}"
|
||||||
@@ -865,6 +1036,26 @@ impl List {
|
|||||||
let key = self.slot_key(slot);
|
let key = self.slot_key(slot);
|
||||||
let cached = key.and_then(|k| self.heights.get(&k).copied());
|
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) {
|
let (top, bottom, height) = match (placement, cached) {
|
||||||
(Placement::Top(top), Some(h)) => {
|
(Placement::Top(top), Some(h)) => {
|
||||||
// Offered a box sized to the *cached* height (cheap to
|
// Offered a box sized to the *cached* height (cheap to
|
||||||
@@ -928,8 +1119,14 @@ impl List {
|
|||||||
};
|
};
|
||||||
if let Some(k) = key {
|
if let Some(k) = key {
|
||||||
self.heights.insert(k, height);
|
self.heights.insert(k, height);
|
||||||
|
// `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 });
|
self.extents.insert(k, RowExtent { slot, top, bottom });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
(top, bottom)
|
(top, bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -961,6 +1158,26 @@ impl Widget for List {
|
|||||||
// density, and `draw` is where this widget meets the only thing
|
// density, and `draw` is where this widget meets the only thing
|
||||||
// that knows it. See `fling`.
|
// that knows it. See `fling`.
|
||||||
self.density = painter.density();
|
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);
|
let output_len = painter.output_size().axis(axis);
|
||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
@@ -1013,6 +1230,26 @@ impl Widget for List {
|
|||||||
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
||||||
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
self.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();
|
self.update_snap_end();
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
@@ -1068,9 +1305,60 @@ mod tests {
|
|||||||
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
|
/// calling `List`'s own methods through `Widgets::get`/`get_mut`, which
|
||||||
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
|
/// need a `Sized` widget type) and the erased root `UiRenderState::update`
|
||||||
/// draws.
|
/// 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) {
|
fn add_list(rsc: &mut TestRsc, list: List) -> (WeakWidget<List>, StrongWidget) {
|
||||||
let strong = rsc.ui.widgets.add_strong(list);
|
let strong = rsc.ui.widgets.add_strong(list);
|
||||||
(strong.weak(), strong.any())
|
let weak = strong.weak();
|
||||||
|
let root = rsc.ui.widgets.add_strong(Masked {
|
||||||
|
shape: None,
|
||||||
|
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]
|
#[test]
|
||||||
@@ -1142,7 +1430,7 @@ mod tests {
|
|||||||
bg_ids.push(bg_id);
|
bg_ids.push(bg_id);
|
||||||
list.push_back(ListRow::new(key, row));
|
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();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((100.0, 100.0));
|
render.resize((100.0, 100.0));
|
||||||
@@ -1323,7 +1611,12 @@ mod tests {
|
|||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
render.take_counters();
|
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);
|
render.update(&root, &mut rsc);
|
||||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
|
||||||
@@ -1616,9 +1909,28 @@ mod tests {
|
|||||||
/// Enough rows, tall enough, that a fling toward the start has real
|
/// Enough rows, tall enough, that a fling toward the start has real
|
||||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||||
/// tests below.
|
/// tests below.
|
||||||
|
/// 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) {
|
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
||||||
let mut list = List::new(Axis::Y);
|
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 (list_weak, root) = add_list(rsc, list);
|
||||||
let mut render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
render.resize((100.0, 600.0));
|
render.resize((100.0, 600.0));
|
||||||
@@ -1764,32 +2076,22 @@ mod tests {
|
|||||||
|
|
||||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||||
let start = Instant::now();
|
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();
|
let mut deltas = Vec::new();
|
||||||
for step in 1..600 {
|
for step in 1..600 {
|
||||||
let now = start + std::time::Duration::from_millis(step * 16);
|
let now = start + std::time::Duration::from_millis(step * 16);
|
||||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||||
render.update(&root, &mut rsc);
|
render.update(&root, &mut rsc);
|
||||||
let Some(top) = rsc
|
let at = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap());
|
||||||
.ui
|
deltas.push((at - prev).abs());
|
||||||
.widgets
|
prev = at;
|
||||||
.get(&list_weak)
|
|
||||||
.unwrap()
|
|
||||||
.extents
|
|
||||||
.get(&0)
|
|
||||||
.map(|e| e.top)
|
|
||||||
else {
|
|
||||||
break; // row 0 scrolled out of the loaded extents
|
|
||||||
};
|
|
||||||
deltas.push((prev_top - top).abs());
|
|
||||||
prev_top = top;
|
|
||||||
if !still {
|
if !still {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
deltas.len() >= 3,
|
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
|
// Skip the first tick (the slop-transition jump the arbiter
|
||||||
// applies is a `List::fling`-adjacent concern, not this curve,
|
// applies is a `List::fling`-adjacent concern, not this curve,
|
||||||
@@ -1871,15 +2173,26 @@ mod tests {
|
|||||||
break;
|
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();
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
list_ref.at_start,
|
list_ref.at_start,
|
||||||
"fling should have clamped at the first row"
|
"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];
|
let first = list_ref.extents[&0];
|
||||||
assert!(
|
assert!(
|
||||||
first.top >= -0.5,
|
first.top.abs() < 0.5,
|
||||||
"clamped fling overshot the first row's top: {}",
|
"a fling stopped at the start must leave the first row flush with the top, not {}px \
|
||||||
|
from it",
|
||||||
first.top
|
first.top
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -1,12 +1,36 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
/// Clips `inner` -- and everything below it -- to a shape.
|
||||||
|
///
|
||||||
|
/// The shape is a **primitive**, never a rectangle or a radius stored
|
||||||
|
/// here: with `shape`, the widget named there is drawn behind `inner`
|
||||||
|
/// filling the same box and the clip is its first primitive, so a rounded
|
||||||
|
/// container's corner and the corner its content is cut to are the same
|
||||||
|
/// arithmetic and cannot fall out of step. Without one, this writes an
|
||||||
|
/// undrawn rect at its own region, which is the plain "clip to my box"
|
||||||
|
/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a
|
||||||
|
/// shape".
|
||||||
pub struct Masked {
|
pub struct Masked {
|
||||||
|
/// The widget whose first primitive is the clip, drawn behind
|
||||||
|
/// `inner`, or `None` for this widget's own box.
|
||||||
|
pub shape: Option<StrongWidget>,
|
||||||
pub inner: StrongWidget,
|
pub inner: StrongWidget,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Masked {
|
impl Widget for Masked {
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.set_mask(painter.region());
|
match &self.shape {
|
||||||
|
// Layered the way `Stack` layers a background under its
|
||||||
|
// content, and for the same reason: within one layer the draw
|
||||||
|
// order is undefined once anything has been freed.
|
||||||
|
Some(shape) => {
|
||||||
|
painter.child_layer();
|
||||||
|
painter.widget(shape);
|
||||||
|
painter.set_mask_to_widget(shape);
|
||||||
|
painter.next_layer();
|
||||||
|
}
|
||||||
|
None => painter.set_mask(painter.region()),
|
||||||
|
}
|
||||||
painter.widget(&self.inner)
|
painter.widget(&self.inner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::sense::{DragGesture, GestureOutcome};
|
use crate::sense::{DragGesture, GestureOutcome, PressState};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
pub struct Scroll {
|
pub struct Scroll {
|
||||||
@@ -119,14 +119,16 @@ impl Scroll {
|
|||||||
pos_window: Vec2,
|
pos_window: Vec2,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
) {
|
) {
|
||||||
// `already_selected: false` -- a scroll area has no selection of
|
// A default `PressState`: a scroll area has no selection of its
|
||||||
// its own to extend, so a horizontal drag stays `Undecided` and a
|
// own to extend, so a horizontal drag stays `Undecided` and a
|
||||||
// vertical one past the slop pans, which is the whole contract
|
// vertical one past the slop pans, which is the whole contract
|
||||||
// here. A caller that *does* own a selection (the transcript's
|
// here; and it never flings (see this method's doc), so there is
|
||||||
// `Selection`) drives `DragGesture` itself instead.
|
// 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
|
match self
|
||||||
.gesture
|
.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
|
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||||
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
||||||
|
|||||||
@@ -78,12 +78,20 @@ impl TextView {
|
|||||||
}
|
}
|
||||||
self.width = width;
|
self.width = width;
|
||||||
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||||
|
// 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!(
|
log::debug!(
|
||||||
|
target: "iris::frame",
|
||||||
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
||||||
self.buf.text().chars().count(),
|
self.buf.text().chars().count(),
|
||||||
tex.glyphs.len(),
|
tex.glyphs.len(),
|
||||||
tex.size,
|
tex.size,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
self.tex = Some(tex.clone());
|
self.tex = Some(tex.clone());
|
||||||
self.attrs.changed = false;
|
self.attrs.changed = false;
|
||||||
self.buf.changed = false;
|
self.buf.changed = false;
|
||||||
|
|||||||
@@ -87,11 +87,10 @@ widget_trait! {
|
|||||||
self.scrollable_on(Axis::Y)
|
self.scrollable_on(Axis::Y)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `scrollable` along `axis`. A code fence pans across its own long
|
/// `scrollable` along `axis`. A code fence pans across its own long
|
||||||
// lines exactly the way a transcript pans down its rows, so the two
|
/// lines exactly the way a transcript pans down its rows, so the two
|
||||||
// are one function with the axis passed in rather than a second copy
|
/// are one function with the axis passed in rather than a second copy
|
||||||
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
|
/// -- `DragArbiter::on` is the other half.
|
||||||
// is not accepted by `widget_trait!`, which parses its body itself.)
|
|
||||||
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||||
move |state| {
|
move |state| {
|
||||||
Scroll::new(self.add_strong(state), axis)
|
Scroll::new(self.add_strong(state), axis)
|
||||||
@@ -119,6 +118,20 @@ widget_trait! {
|
|||||||
|
|
||||||
fn masked(self) -> impl WidgetFn<Rsc, Masked> {
|
fn masked(self) -> impl WidgetFn<Rsc, Masked> {
|
||||||
move |state| Masked {
|
move |state| Masked {
|
||||||
|
shape: None,
|
||||||
|
inner: self.add_strong(state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clip to `shape` rather than to a plain box: `shape` is drawn
|
||||||
|
/// behind this widget, filling the same region, and what clips is the
|
||||||
|
/// primitive it drew -- so a rounded background and the corner its
|
||||||
|
/// content is cut to are one rect, with no radius passed twice.
|
||||||
|
/// Replaces `.masked().background(w)`, which drew the two but clipped
|
||||||
|
/// to the box.
|
||||||
|
fn masked_by<T>(self, shape: impl WidgetLike<Rsc, T>) -> impl WidgetFn<Rsc, Masked> {
|
||||||
|
move |state| Masked {
|
||||||
|
shape: Some(shape.add_strong(state)),
|
||||||
inner: self.add_strong(state),
|
inner: self.add_strong(state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! The CPU rounded-rect SDF and the shader's own must agree.
|
||||||
|
//!
|
||||||
|
//! LAYOUT.md's "Masks with a shape" turns on it: the fragment stage clips
|
||||||
|
//! a masked subtree with `shader.wgsl`'s `rounded_rect_coverage`, and the
|
||||||
|
//! hit test (`UiRenderState::mask_admits`) clips the *same* subtree with
|
||||||
|
//! `iris_core::rounded_rect_coverage`, so a corner that cannot be tapped
|
||||||
|
//! and a corner that is not drawn are the same corner only while the two
|
||||||
|
//! functions answer the same. Nothing else checks that: both sides are
|
||||||
|
//! individually plausible and drift shows up as a control that is a pixel
|
||||||
|
//! or two off, which is exactly what nobody notices.
|
||||||
|
//!
|
||||||
|
//! So this runs **the real shader text**, lifted out of
|
||||||
|
//! `iris_core::SHAPE_SHADER` by name rather than copied here, in a compute
|
||||||
|
//! pass over a grid of points, and compares what came back with the Rust
|
||||||
|
//! function at the same points. This is the only test in the workspace
|
||||||
|
//! that needs a GPU; everything else about masks is layer 1 (docs/RUST.md's
|
||||||
|
//! "Three test layers"). It fails rather than skips when there is no
|
||||||
|
//! adapter, because a check that quietly did not run reads exactly like a
|
||||||
|
//! check that passed.
|
||||||
|
|
||||||
|
use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2};
|
||||||
|
use pollster::FutureExt;
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
/// The rect the grid is sampled against, in window pixels. Deliberately
|
||||||
|
/// off the whole-pixel grid: the shader floors a primitive's corners, but
|
||||||
|
/// `rounded_rect_coverage` is handed pixels either side of that and has to
|
||||||
|
/// agree at fractional positions too -- the phone's 2.55 density puts
|
||||||
|
/// nothing on a whole pixel.
|
||||||
|
const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25);
|
||||||
|
const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0);
|
||||||
|
|
||||||
|
/// Radii spanning what the widgets actually ask for, plus the two edges of
|
||||||
|
/// the function's own domain: a square corner, and one large enough that
|
||||||
|
/// `min(edge, radius)` stops mattering.
|
||||||
|
const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0];
|
||||||
|
|
||||||
|
/// f32 arithmetic in two compilers, not one: `length`/`sqrt` and
|
||||||
|
/// `smoothstep` are each allowed a unit or two in the last place, and the
|
||||||
|
/// GPU may contract a multiply-add the CPU does not. A coverage is in
|
||||||
|
/// [0, 1], so this is about six decimal digits -- four orders of magnitude
|
||||||
|
/// tighter than the half-pixel feather the hit test reads, which is what
|
||||||
|
/// the agreement is actually for.
|
||||||
|
const TOLERANCE: f32 = 1e-5;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_sdf_matches_the_shader() {
|
||||||
|
let points = grid();
|
||||||
|
let gpu = run_shader(&points);
|
||||||
|
let mut worst = 0.0f32;
|
||||||
|
let mut worst_at = (Vec2::new(0.0, 0.0), 0.0f32, 0.0f32, 0.0f32);
|
||||||
|
for (&(pos, radius), &got) in points.iter().zip(&gpu) {
|
||||||
|
let want = rounded_rect_coverage(pos, TOP_LEFT, BOT_RIGHT, radius);
|
||||||
|
let diff = (got - want).abs();
|
||||||
|
if diff > worst {
|
||||||
|
worst = diff;
|
||||||
|
worst_at = (pos, radius, want, got);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (pos, radius, want, got) = worst_at;
|
||||||
|
assert!(
|
||||||
|
worst <= TOLERANCE,
|
||||||
|
"shader.wgsl's rounded_rect_coverage and iris_core's disagree by {worst} at {pos:?} \
|
||||||
|
(radius {radius}): the CPU says {want}, the GPU {got}. One of the two was edited \
|
||||||
|
without the other -- they are transliterations and have to stay so, or a masked \
|
||||||
|
corner stops being tappable where it is drawn.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The half that would pass on a function returning a constant.
|
||||||
|
let inside = gpu.iter().filter(|c| **c > 0.999).count();
|
||||||
|
let feather = gpu.iter().filter(|c| **c > 0.001 && **c < 0.999).count();
|
||||||
|
let outside = gpu.iter().filter(|c| **c < 0.001).count();
|
||||||
|
assert!(
|
||||||
|
inside > 0 && feather > 0 && outside > 0,
|
||||||
|
"the grid never crossed an edge ({inside} in, {feather} on the feather, {outside} out), \
|
||||||
|
so agreeing proved nothing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points spanning the rect and a margin outside it, at half-pixel steps
|
||||||
|
/// so the feather is sampled rather than stepped over, paired with each
|
||||||
|
/// radius.
|
||||||
|
fn grid() -> Vec<(Vec2, f32)> {
|
||||||
|
let mut points = Vec::new();
|
||||||
|
for radius in RADII {
|
||||||
|
let mut y = TOP_LEFT.y - 4.0;
|
||||||
|
while y <= BOT_RIGHT.y + 4.0 {
|
||||||
|
let mut x = TOP_LEFT.x - 4.0;
|
||||||
|
while x <= BOT_RIGHT.x + 4.0 {
|
||||||
|
points.push((Vec2::new(x, y), radius));
|
||||||
|
x += 0.5;
|
||||||
|
}
|
||||||
|
y += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `shader.wgsl`'s own `rounded_rect_coverage`, evaluated at every point.
|
||||||
|
fn run_shader(points: &[(Vec2, f32)]) -> Vec<f32> {
|
||||||
|
let instance = wgpu::Instance::default();
|
||||||
|
let adapter = instance
|
||||||
|
.request_adapter(&wgpu::RequestAdapterOptions::default())
|
||||||
|
.block_on()
|
||||||
|
.expect(
|
||||||
|
"no wgpu adapter on this machine, so the CPU/shader SDF agreement went unchecked. \
|
||||||
|
This VM has a virtio-gpu render node (see iris/run-headless.sh); if that is gone, \
|
||||||
|
fix it rather than deleting this test.",
|
||||||
|
);
|
||||||
|
let (device, queue) = adapter
|
||||||
|
.request_device(&wgpu::DeviceDescriptor {
|
||||||
|
// The adapter's own, not `iris_core::device_limits()`: those
|
||||||
|
// are what the *app* asks for, chosen down to what a phone
|
||||||
|
// GPU has, and they carry no compute at all
|
||||||
|
// (`max_compute_invocations_per_workgroup` is 0). This probe
|
||||||
|
// draws nothing and shares no pipeline with the app -- it
|
||||||
|
// runs one function to see what it returns.
|
||||||
|
required_limits: adapter.limits(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.block_on()
|
||||||
|
.expect("could not get a device from the adapter");
|
||||||
|
|
||||||
|
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("mask sdf probe"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(probe_source().into()),
|
||||||
|
});
|
||||||
|
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||||
|
label: Some("mask sdf probe"),
|
||||||
|
layout: None,
|
||||||
|
module: &module,
|
||||||
|
entry_point: Some("probe"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// (x, y, radius, unused) -- one vec4 per point, so the buffer needs no
|
||||||
|
// stride arithmetic and no alignment rule of its own.
|
||||||
|
let input: Vec<[f32; 4]> = points
|
||||||
|
.iter()
|
||||||
|
.map(|(pos, radius)| [pos.x, pos.y, *radius, 0.0])
|
||||||
|
.collect();
|
||||||
|
let in_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("mask sdf points"),
|
||||||
|
contents: bytemuck::cast_slice(&input),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE,
|
||||||
|
});
|
||||||
|
let out_size = (points.len() * std::mem::size_of::<f32>()) as u64;
|
||||||
|
let out_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("mask sdf coverage"),
|
||||||
|
size: out_size,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let read_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("mask sdf readback"),
|
||||||
|
size: out_size,
|
||||||
|
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("mask sdf probe"),
|
||||||
|
layout: &pipeline.get_bind_group_layout(0),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: in_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: out_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut enc = device.create_command_encoder(&Default::default());
|
||||||
|
{
|
||||||
|
let mut pass = enc.begin_compute_pass(&Default::default());
|
||||||
|
pass.set_pipeline(&pipeline);
|
||||||
|
pass.set_bind_group(0, &bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups(points.len().div_ceil(64) as u32, 1, 1);
|
||||||
|
}
|
||||||
|
enc.copy_buffer_to_buffer(&out_buf, 0, &read_buf, 0, out_size);
|
||||||
|
queue.submit([enc.finish()]);
|
||||||
|
|
||||||
|
let slice = read_buf.slice(..);
|
||||||
|
slice.map_async(wgpu::MapMode::Read, |r| r.expect("mapping the readback"));
|
||||||
|
device
|
||||||
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
|
.expect("waiting for the probe");
|
||||||
|
let coverage = bytemuck::cast_slice::<u8, f32>(&slice.get_mapped_range()).to_vec();
|
||||||
|
read_buf.unmap();
|
||||||
|
coverage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The probe module: the two functions **lifted from `shader.wgsl`
|
||||||
|
/// itself**, plus an entry point that calls the outer one. Lifted rather
|
||||||
|
/// than copied so there is nothing to keep in step -- an edit to the
|
||||||
|
/// shader is what this test is for, and a copy here would be edited along
|
||||||
|
/// with it.
|
||||||
|
fn probe_source() -> String {
|
||||||
|
format!(
|
||||||
|
"{}\n{}\n\
|
||||||
|
@group(0) @binding(0) var<storage, read> probe_in: array<vec4<f32>>;\n\
|
||||||
|
@group(0) @binding(1) var<storage, read_write> probe_out: array<f32>;\n\
|
||||||
|
@compute @workgroup_size(64)\n\
|
||||||
|
fn probe(@builtin(global_invocation_id) gid: vec3<u32>) {{\n\
|
||||||
|
let i = gid.x;\n\
|
||||||
|
if i >= arrayLength(&probe_out) {{ return; }}\n\
|
||||||
|
let p = probe_in[i];\n\
|
||||||
|
probe_out[i] = rounded_rect_coverage(p.xy, vec2({}, {}), vec2({}, {}), p.z);\n\
|
||||||
|
}}\n",
|
||||||
|
wgsl_fn("distance_from_rect"),
|
||||||
|
wgsl_fn("rounded_rect_coverage"),
|
||||||
|
TOP_LEFT.x,
|
||||||
|
TOP_LEFT.y,
|
||||||
|
BOT_RIGHT.x,
|
||||||
|
BOT_RIGHT.y,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One WGSL function's whole text, from its `fn` keyword to the `}` that
|
||||||
|
/// closes its body, found by matching braces. Panics by name when the
|
||||||
|
/// function is not there, which is what a rename looks like from here.
|
||||||
|
fn wgsl_fn(name: &str) -> &'static str {
|
||||||
|
let start = SHAPE_SHADER
|
||||||
|
.find(&format!("fn {name}("))
|
||||||
|
.unwrap_or_else(|| panic!("shader.wgsl has no `fn {name}(` -- renamed, or gone"));
|
||||||
|
let body = SHAPE_SHADER[start..]
|
||||||
|
.find('{')
|
||||||
|
.expect("a wgsl fn signature is followed by its body");
|
||||||
|
let mut depth = 0usize;
|
||||||
|
for (i, c) in SHAPE_SHADER[start + body..].char_indices() {
|
||||||
|
match c {
|
||||||
|
'{' => depth += 1,
|
||||||
|
'}' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
return &SHAPE_SHADER[start..start + body + i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("`fn {name}`'s body in shader.wgsl is never closed");
|
||||||
|
}
|
||||||
@@ -22,3 +22,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
winit = { workspace = true }
|
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)
|
let velocity = (screen.list)(&mut h.rsc)
|
||||||
.fling_velocity()
|
.fling_velocity()
|
||||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
.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!(
|
assert!(
|
||||||
velocity.abs() > 1_000.0,
|
(velocity + 15_250.0).abs() < 20.0,
|
||||||
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
|
"expected ~-15250px/s from velocity_reference.py, got {velocity}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Android's own spline says how long a fling at this speed runs. The
|
// `iris/benches/fling_spline_reference.py`'s own line for this exact
|
||||||
// list learns its density from the painter, so this is the same
|
// case -- `density=2.55 v=15250.0: distance=11057.424px
|
||||||
// curve it is using.
|
// duration=2.0716s`. **Not** `FlingCalculator::new(PHONE_SCALE)`,
|
||||||
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
|
// which is the calculator under test: bounding a fling with the thing
|
||||||
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
|
// 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 settled_at = None;
|
||||||
let mut t = flick.end_ms();
|
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 {
|
while t <= end {
|
||||||
h.frame(t);
|
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);
|
settled_at = Some(t);
|
||||||
}
|
}
|
||||||
t += PHONE_FRAME_MS;
|
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 settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||||
let ran_for = settled_at - flick.end_ms();
|
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!(
|
assert!(
|
||||||
ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
|
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
|
||||||
"the fling ran {ran_for}ms against the spline's own {}ms",
|
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||||
expected.as_millis()
|
);
|
||||||
|
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.render.mask_region(active.mask, &h.rsc);
|
||||||
|
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.
|
// where the bar actually was.
|
||||||
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
|
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
|
||||||
// clip that keeps six lines' worth of a longer message inside 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,
|
// bar. The mask is the caller's job rather than `Scroll`'s own:
|
||||||
// because `Painter::set_mask` allows exactly one mask per widget and
|
// `.masked()` is the one mechanism for clipping and this is one more
|
||||||
// a `Scroll` nested under another masked area would abort on the
|
// use of it (tabs-ui's message area is the other). A `Scroll` nested
|
||||||
// second -- `.masked()` is the one mechanism for clipping and this is
|
// under another masked area used to abort here; since 2026-09-07 the
|
||||||
// one more use of it (tabs-ui's message area is the other).
|
// 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
|
// Without it the overflow paints *above* the bar, over the
|
||||||
// transcript: measured before this change at 58px of stray text for a
|
// transcript: measured before this change at 58px of stray text for a
|
||||||
// 475px message in a 417px box.
|
// 475px message in a 417px box.
|
||||||
|
|||||||
@@ -422,7 +422,13 @@ where
|
|||||||
|
|
||||||
let (composer, composer_bar) = composer::build_composer(rsc);
|
let (composer, composer_bar) = composer::build_composer(rsc);
|
||||||
|
|
||||||
let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
|
// `.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)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
|
|||||||
@@ -266,11 +266,14 @@ where
|
|||||||
// delta path knowing anything about frames.
|
// delta path knowing anything about frames.
|
||||||
let framed = match frame {
|
let framed = match frame {
|
||||||
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||||
|
// Masked *by the panel*, not inside it: the fence's own rounded
|
||||||
|
// rect is the clip, so content scrolled sideways is cut on the
|
||||||
|
// curve instead of leaving square pixels in the corners
|
||||||
|
// (Iris, 2026-09-07).
|
||||||
BlockFrame::Verbatim { fill } => field
|
BlockFrame::Verbatim { fill } => field
|
||||||
.scrollable_on(Axis::X)
|
.scrollable_on(Axis::X)
|
||||||
.masked()
|
|
||||||
.pad(dp(FRAME_PAD_DP))
|
.pad(dp(FRAME_PAD_DP))
|
||||||
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any(),
|
.any(),
|
||||||
|
|||||||
@@ -259,16 +259,26 @@ impl Selection {
|
|||||||
now: Instant,
|
now: Instant,
|
||||||
render: &UiRenderState,
|
render: &UiRenderState,
|
||||||
) -> GestureOutcome {
|
) -> GestureOutcome {
|
||||||
if matches!(sense, CursorSense::PressStart(_)) {
|
// A fresh touch-down cancels any fling still coasting from the
|
||||||
// A fresh touch-down cancels any fling still coasting from
|
// previous gesture -- `List::fling`'s own doc, and Android's
|
||||||
// the previous gesture -- `List::fling`'s own doc, and
|
// `Scroller::abortAnimation` for the same reason -- and, since
|
||||||
// Android's `Scroller::abortAnimation` for the same reason.
|
// 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();
|
list(ui).cancel_fling();
|
||||||
}
|
}
|
||||||
let already_selected = self.has_selection(ui);
|
press.already_selected = self.has_selection(ui);
|
||||||
let outcome =
|
let outcome = self
|
||||||
self.gesture
|
.gesture
|
||||||
.handle(render, list.id(), sense, pos_window, now, already_selected);
|
.handle(render, list.id(), sense, pos_window, now, press);
|
||||||
match outcome {
|
match outcome {
|
||||||
GestureOutcome::Undecided => {}
|
GestureOutcome::Undecided => {}
|
||||||
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
|
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||||
@@ -299,9 +309,17 @@ impl Selection {
|
|||||||
// The half that actually makes it move -- see
|
// The half that actually makes it move -- see
|
||||||
// `List::fling`'s doc. Without it the velocity is
|
// `List::fling`'s doc. Without it the velocity is
|
||||||
// computed, stored, and never advanced by anything.
|
// computed, stored, and never advanced by anything.
|
||||||
|
//
|
||||||
|
// 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();
|
let id = list.id();
|
||||||
ui.ui_mut().animate(id);
|
ui.ui_mut().animate(id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// A tap is nobody's business here -- `row.rs` reads it from
|
// A tap is nobody's business here -- `row.rs` reads it from
|
||||||
// the returned outcome and follows a link if one was under
|
// the returned outcome and follows a link if one was under
|
||||||
// the finger.
|
// the finger.
|
||||||
|
|||||||
@@ -110,13 +110,19 @@ const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0);
|
|||||||
/// The mark that says a card opens, always drawn from the **monospace**
|
/// The mark that says a card opens, always drawn from the **monospace**
|
||||||
/// face.
|
/// face.
|
||||||
///
|
///
|
||||||
/// Not a style choice: `NotoSans-Regular.ttf`, which every other string
|
/// Not a style choice, historically: with the bundled Noto Sans this crate
|
||||||
/// here is set in, has no glyph at U+25B8/U+25BE/U+25B4 at all, while
|
/// used to embed, the sans face every other string here is set in had no
|
||||||
/// `NotoSansMono-Regular.ttf` does -- read out of both bundled `cmap`s on
|
/// glyph at U+25B8/U+25BE/U+25B4 at all, while the bundled monospace face
|
||||||
/// 2026-09-06. A missing glyph is the failure nobody who wrote the code
|
/// did. As of 2026-09-07 iris takes both faces from the platform's own
|
||||||
/// ever sees, so the face that has the glyph is named at the one place the
|
/// collection instead (DECISIONS.md's 2026-09-07 entry, matching what
|
||||||
/// character is written. IRIS_TODO's "a drawn chevron" has the real fix,
|
/// Compose ships), so this is no longer a checked fact about a specific
|
||||||
/// which needs a line primitive iris does not have.
|
/// 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 CLOSED_MARK: &str = "\u{25b8}";
|
||||||
const OPEN_MARK: &str = "\u{25be}";
|
const OPEN_MARK: &str = "\u{25be}";
|
||||||
const UP_MARK: &str = "\u{25b4}";
|
const UP_MARK: &str = "\u{25b4}";
|
||||||
|
|||||||
+44
-5
@@ -120,6 +120,35 @@ struct Args {
|
|||||||
throwaway_sessions: bool,
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Both rustls crypto providers are in the dependency graph (ureq brings
|
// Both rustls crypto providers are in the dependency graph (ureq brings
|
||||||
@@ -160,7 +189,13 @@ async fn main() -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
println!(
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -216,9 +251,7 @@ async fn main() -> Result<()> {
|
|||||||
// Before the interface check below, deliberately: the certificates are also
|
// 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
|
// 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.
|
// a machine whose tunnel isn't up yet. The leaf is reissued on every start.
|
||||||
let certs_dir = args
|
let certs_dir = certs_dir(&args.certs);
|
||||||
.certs
|
|
||||||
.unwrap_or_else(|| config_home("ai-app").join("certs"));
|
|
||||||
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
|
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()))?;
|
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
||||||
if certificates.ca_is_new {
|
if certificates.ca_is_new {
|
||||||
@@ -252,7 +285,13 @@ async fn main() -> Result<()> {
|
|||||||
if rotating {
|
if rotating {
|
||||||
tracing::info!("rotated the enrolled token; the previous one is now invalid");
|
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(
|
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