iris/android: content_scale (density), per-frame diagnostics, insets hook

Threads DisplayMetrics.density (read once in new_peer, via the Context
android-view already hands the JNI entry point) through AndroidUiState
as content_scale, and divides by it everywhere a raw device-pixel number
used to reach layout unscaled: AndroidRenderer::size()/resize()/new() now
report logical (physical / density) dimensions to UiRenderNode and to
UiRenderState's own root-layout size, and on_touch_event divides the
incoming MotionEvent coordinates the same way, so touch and layout agree
on units again. This is the fix for RUST.md's P0 box, "text is far too
small" -- a font_size: 16.0 was 16 raw device pixels on a ~3x-density
phone, identical to the desktop fix in the previous commit.

Installs Device::on_uncaptured_error on the Android device (wgpu's
default handler is an unconditional panic outside UiRenderNode::new's
own error scopes) into a new iris_core::WgpuErrorLog, and adds
AndroidRenderer::diagnostics_report() combining adapter identity, font
resolution, atlas view count and the error log into one string for a
future Diagnostics screen. render() now logs a one-line diagnostic
(masks/moves resized, atlas pages grown, image bind-group creates, wgpu
error count) for the first 10 frames after each surface_changed -- the
window RUST.md's P0 box says the glyph-wipe-on-first-touch happens in.

Adds AndroidAppState::on_insets_changed(rsc, LogicalInsets), called from
render() exactly when AndroidUiState::insets() changes (once at startup
for the status bar, again on rotation/IME) -- nothing previously read
insets().top at all, which is why RUST.md's P0 box found the bench
screen's top buttons sitting under the status bar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 23:40:30 -04:00
1 parent d8e6bc6e9b
commit 3b80a88f3b
2 files changed
+290 -16

No files matched your search

+167 -6
View File
@@ -48,6 +48,43 @@ pub struct AndroidRenderer {
config: SurfaceConfiguration, config: SurfaceConfiguration,
encoder: CommandEncoder, encoder: CommandEncoder,
pub ui: UiRenderNode, pub ui: UiRenderNode,
/// The adapter identity, kept past `new()` for the Diagnostics page --
/// `Adapter` itself is not `Clone`, so the three fields the page shows
/// are copied out once here rather than holding the adapter.
pub adapter_name: String,
pub adapter_backend: Backend,
pub adapter_driver: String,
/// Every uncaptured wgpu error since this renderer was created -- see
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
/// `new()`, kept here so the Diagnostics page and the per-frame log in
/// `update()` can both read it without a global.
pub wgpu_errors: iris_core::WgpuErrorLog,
/// Frames drawn on this surface -- what gates the first-10-frames log
/// `update()` writes (RUST.md's P0 box, "the first input frame"
/// investigation): a fresh surface is exactly what Iris's own report
/// says renders correctly at first, so the frames that matter are the
/// first several after each `surface_changed`, not an arbitrary window
/// during a long-running session.
frame_count: u64,
/// Physical pixels per dp -- see `android::view::AndroidUiState::
/// content_scale`'s field comment for what this feeds.
content_scale: f32,
}
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
/// `take_atlas_pages_grown` describe -- assembled here because the three
/// live on two different calling conventions (`FrameUpdateStats` from this
/// exact `update()` call; the other two describe the *previous* frame,
/// same as `bench_images`' existing use of them) and a diagnostic reader
/// should not have to know that split.
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameDiagnostics {
pub masks_resized: bool,
pub moves_resized: bool,
/// From the previous frame's `update()` -- see the struct doc.
pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64,
} }
impl AndroidRenderer { impl AndroidRenderer {
@@ -64,7 +101,12 @@ impl AndroidRenderer {
/// never exercised. The caller (`android::view::IrisViewPeer:: /// never exercised. The caller (`android::view::IrisViewPeer::
/// surface_changed`) logs this one-line-flattened and shows it on /// surface_changed`) logs this one-line-flattened and shows it on
/// screen instead of aborting the process. /// screen instead of aborting the process.
pub fn new(window: NativeWindow, width: u32, height: u32) -> Result<Self, String> { pub fn new(
window: NativeWindow,
width: u32,
height: u32,
content_scale: f32,
) -> 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") swaps
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same // the software-Vulkan (SwiftShader) path for GLES/virgl on the same
// build, to isolate whether the backend itself explains the frame // build, to isolate whether the backend itself explains the frame
@@ -121,6 +163,30 @@ impl AndroidRenderer {
.block_on() .block_on()
.expect("Could not get device!"); .expect("Could not get device!");
// wgpu's default handler for an error raised outside `UiRenderNode::
// new`'s own error scopes (i.e. everything past device creation --
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
// with no caller able to intervene: the same mechanism that aborted
// the P0 bench APK once already, just at a different call site. Log
// and record instead of letting that default stand -- RUST.md's P0
// box, "every wgpu uncaptured error ... it must never panic in
// release".
let wgpu_errors = iris_core::WgpuErrorLog::default();
let wgpu_errors_for_handler = wgpu_errors.clone();
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
log::error!("iris wgpu uncaptured error: {error}");
wgpu_errors_for_handler.record(error);
}));
let info = adapter.get_info();
let adapter_name = info.name.clone();
let adapter_backend = info.backend;
let adapter_driver = if info.driver_info.is_empty() {
info.driver.clone()
} else {
format!("{} {}", info.driver, info.driver_info)
};
let surface_caps = surface.get_capabilities(&adapter); let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
@@ -142,7 +208,16 @@ impl AndroidRenderer {
surface.configure(&device, &config); surface.configure(&device, &config);
let encoder = Self::create_encoder(&device); let encoder = Self::create_encoder(&device);
let ui = match UiRenderNode::new(&device, &queue, &config) { // Logical size (physical / `content_scale`) -- see
// `android::view::AndroidUiState::content_scale`'s field comment
// for why this crate now divides at all (RUST.md's P0 box, "text
// is far too small"). The swapchain above stays at the real
// physical `width`/`height` for a sharp framebuffer.
let logical_size = iris_core::util::Vec2::new(
width as f32 / content_scale,
height as f32 / content_scale,
);
let ui = match UiRenderNode::new(&device, &queue, &config, logical_size) {
Ok(ui) => ui, Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)), Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
}; };
@@ -154,6 +229,12 @@ impl AndroidRenderer {
config, config,
encoder, encoder,
ui, ui,
adapter_name,
adapter_backend,
adapter_driver,
wgpu_errors,
frame_count: 0,
content_scale,
}) })
} }
@@ -199,14 +280,84 @@ impl AndroidRenderer {
) )
} }
/// The Diagnostics page's whole report: adapter identity, font
/// resolution, the atlas's own view count, every uncaptured wgpu error
/// so far, and the frame report -- RUST.md's P0 box, "a named
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
/// format/pages, wgpu errors so far, frame report". One string rather
/// than a struct the caller formats, since the only consumer is a
/// plain `TextView` with a "copy this and send it to Iris" affordance,
/// the same shape `surface_changed`'s crash report already uses
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
/// carries enough to act on where it's shown).
pub fn diagnostics_report(
&self,
font: &iris_core::FontDiagnostics,
frame_report: &str,
) -> String {
let errors = self.wgpu_errors.snapshot();
let errors_text = if errors.is_empty() {
"none".to_string()
} else {
errors.join("\n ")
};
format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\
content_scale: {content_scale}\n\
atlas format: Rgba8Unorm, views live: {views}\n\
fonts: {families_found} families found, default={default_family:?} \
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
mono={mono:?}\n\
wgpu errors since surface creation:\n {errors_text}\n\n\
{frame_report}",
name = self.adapter_name,
backend = self.adapter_backend,
driver = self.adapter_driver,
content_scale = self.content_scale,
views = self.ui.view_count(),
families_found = font.families_found,
default_family = font.default_family,
default_mono_family = font.default_mono_family,
regular = font.regular_resolved,
bold = font.bold_resolved,
italic = font.italic_resolved,
mono = font.mono_resolved,
)
}
fn create_encoder(device: &Device) -> CommandEncoder { fn create_encoder(device: &Device) -> CommandEncoder {
device.create_command_encoder(&CommandEncoderDescriptor { device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render Encoder"), label: Some("Render Encoder"),
}) })
} }
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) { /// Returns what changed this frame -- see `FrameDiagnostics`'s doc
self.ui.update(&self.device, &self.queue, ui, render); /// comment for why two of its four fields describe the *previous*
/// frame rather than this one. `IrisViewPeer::render` logs this for
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
/// per RUST.md's P0 box ("the first input frame" investigation): the
/// glyph-wipe Iris reported happens on the first tap or scroll after a
/// fresh surface, so that is exactly the window a report needs to
/// cover, not an arbitrary slice of a long session.
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
let stats = self.ui.update(&self.device, &self.queue, ui, render);
self.frame_count += 1;
FrameDiagnostics {
masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized,
atlas_pages_grown_prev,
image_bind_group_creates_prev,
}
}
/// Frames drawn on this surface so far -- see `frame_count`'s field
/// comment.
pub fn frame_count(&self) -> u64 {
self.frame_count
} }
/// Draws and presents one frame, returning the time spent in /// Draws and presents one frame, returning the time spent in
@@ -249,15 +400,25 @@ impl AndroidRenderer {
submit_start.elapsed() submit_start.elapsed()
} }
/// Logical size (physical / `content_scale`) -- the unit layout and
/// hit-testing use, matching the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 { pub fn size(&self) -> iris_core::util::Vec2 {
(self.config.width, self.config.height).into() iris_core::util::Vec2::new(
self.config.width as f32 / self.content_scale,
self.config.height as f32 / self.content_scale,
)
} }
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width; self.config.width = width;
self.config.height = height; self.config.height = height;
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
self.ui.resize((width, height), &self.queue); let logical = iris_core::util::Vec2::new(
width as f32 / self.content_scale,
height as f32 / self.content_scale,
);
self.ui.resize(logical, &self.queue);
} }
} }
+123 -10
View File
@@ -33,6 +33,10 @@ use super::{
/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding /// `Option` because a `SurfaceView`'s surface does not outlive backgrounding
/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can /// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can
/// happen any number of times over the life of one `IrisViewPeer`. /// happen any number of times over the life of one `IrisViewPeer`.
/// How many frames after each `surface_changed` `render()` logs a full
/// diagnostic line for -- see the log site's own comment.
const DIAGNOSTIC_FRAMES: u64 = 10;
pub struct AndroidUiState { pub struct AndroidUiState {
pub root: Option<StrongWidget>, pub root: Option<StrongWidget>,
pub renderer: Option<AndroidRenderer>, pub renderer: Option<AndroidRenderer>,
@@ -66,10 +70,29 @@ pub struct AndroidUiState {
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own /// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc. /// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
pub frame_report: FrameReport, pub frame_report: FrameReport,
/// `DisplayMetrics.density` (`new_peer`'s doc comment), read once at
/// view construction: physical pixels per dp on this device. Neither
/// this crate nor `default::` had ever divided by it before RUST.md's
/// P0 box's phone report ("text is far too small") -- `window_size`
/// below and `surface_changed`'s call into `UiRenderState::resize` both
/// report *logical* (physical / `content_scale`) dimensions now, which
/// is what makes a `font_size: 16.0` 16 dp rather than 16 raw device
/// pixels on a ~3x-density phone. The actual wgpu surface/swapchain
/// stays at the real physical resolution (`AndroidRenderer`'s own
/// `config.width/height`) for a sharp framebuffer; only the *logical*
/// coordinate system layout, hit-testing and the window uniform agree
/// on is scaled. Touch coordinates (`on_touch_event`) are divided by
/// this too, so they land in the same space layout is using.
pub content_scale: f32,
/// The last insets `render()` saw -- compared each frame so
/// `AndroidAppState::on_insets_changed` fires only when they actually
/// change (once at startup for the status bar, again if the device
/// rotates), not every frame.
last_insets: Insets,
} }
impl AndroidUiState { impl AndroidUiState {
fn new(shared: Rc<RefCell<Shared>>) -> Self { fn new(shared: Rc<RefCell<Shared>>, content_scale: f32) -> Self {
Self { Self {
root: None, root: None,
renderer: None, renderer: None,
@@ -82,6 +105,8 @@ impl AndroidUiState {
access_adapter: Default::default(), access_adapter: Default::default(),
access: AccessTree::new(), access: AccessTree::new(),
frame_report: FrameReport::new(), frame_report: FrameReport::new(),
content_scale,
last_insets: Insets::default(),
} }
} }
@@ -124,6 +149,43 @@ pub trait AndroidAppState: HasAndroidUiState {
/// storing them has no effect on that mechanism. /// storing them has no effect on that mechanism.
#[allow(unused_variables)] #[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {} fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
/// Called from `render()` whenever `AndroidUiState::insets()` differs
/// from what it was last frame -- once at startup for the status bar
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same *logical* units `content_scale` converts
/// everything else to (physical / `content_scale`), so a widget can add
/// it to a layout size directly. The default does nothing -- most
/// screens have no chrome that sits under a system bar.
#[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: LogicalInsets) {}
}
/// `insets::Insets`, converted from physical to logical units -- see
/// `AndroidUiState::content_scale`'s field comment. A distinct type from
/// `insets::Insets` (rather than dividing in place) so a reader at the call
/// site can tell which unit a value is already in without checking where it
/// came from.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct LogicalInsets {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
pub ime_bottom: f32,
}
impl LogicalInsets {
fn from_physical(insets: Insets, content_scale: f32) -> Self {
Self {
left: insets.left as f32 / content_scale,
top: insets.top as f32 / content_scale,
right: insets.right as f32 / content_scale,
bottom: insets.bottom as f32 / content_scale,
ime_bottom: insets.ime_bottom as f32 / content_scale,
}
}
} }
/// The android-view analogue of `default::DefaultRsc` -- identical in /// The android-view analogue of `default::DefaultRsc` -- identical in
@@ -270,10 +332,24 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
/// 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.
fn render(&mut self, ctx: &mut CallbackCtx) { fn render(&mut self, ctx: &mut CallbackCtx) {
let ui_state = self.state.android_state(); if self.state.android_state().renderer.is_none() {
if ui_state.renderer.is_none() {
return; return;
} }
// See `AndroidAppState::on_insets_changed`'s doc comment: fires
// exactly when insets actually differ from last frame, not every
// frame -- most frames this is one `Insets` equality check against
// a `Copy` struct. Done before `ui_state` is bound below, since
// `on_insets_changed` needs `&mut self.state`/`&mut self.rsc` both.
let ui_state = self.state.android_state();
let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets {
let content_scale = ui_state.content_scale;
let logical = LogicalInsets::from_physical(current_insets, content_scale);
self.state.android_state_mut().last_insets = current_insets;
self.state.on_insets_changed(&mut self.rsc, logical);
}
let ui_state = self.state.android_state();
log::debug!( log::debug!(
"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(),
@@ -298,7 +374,27 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let Some(renderer) = &mut ui_state.renderer else { let Some(renderer) = &mut ui_state.renderer else {
return; return;
}; };
renderer.update(&mut self.rsc.ui, &mut self.render); let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render);
// First `DIAGNOSTIC_FRAMES` frames after each `surface_changed`
// only -- RUST.md's P0 box, "the first input frame" investigation:
// the glyph-wipe Iris reported happens on the first tap or scroll
// after a fresh surface, so a report from that window is what
// would show whether an atlas grow, a masks/move_offsets resize, or
// a fresh wgpu error coincided with it. `frame_count()` was just
// incremented inside `update()`, so `<=` counts frame 1 through
// `DIAGNOSTIC_FRAMES` inclusive.
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}",
renderer.frame_count(),
frame_diagnostics.masks_resized,
frame_diagnostics.moves_resized,
frame_diagnostics.atlas_pages_grown_prev,
frame_diagnostics.image_bind_group_creates_prev,
renderer.wgpu_errors.snapshot().len(),
);
}
let submit_to_present = renderer.draw(); let submit_to_present = renderer.draw();
self.state self.state
.android_state_mut() .android_state_mut()
@@ -408,8 +504,16 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) -> bool { ) -> bool {
self.drain_tasks(); self.drain_tasks();
let action = event.action_masked(&mut ctx.env); let action = event.action_masked(&mut ctx.env);
let x = event.x(&mut ctx.env); // Device (physical) pixels, same as every other Android coordinate
let y = event.y(&mut ctx.env); // -- divided so a touch lands in the same *logical* space layout
// now uses (`AndroidUiState::content_scale`'s field comment).
// Without this, `window_size()` reporting logical dims while touch
// stayed physical would land every tap off by exactly the density
// factor on any phone denser than 1x.
let ui_state = self.state.android_state();
let content_scale = ui_state.content_scale;
let x = event.x(&mut ctx.env) / content_scale;
let y = event.y(&mut ctx.env) / content_scale;
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
match action { match action {
MotionAction::Down => { MotionAction::Down => {
@@ -484,7 +588,8 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// the one place in the app that can turn it into something a // the one place in the app that can turn it into something a
// person can read, since `ctx.view`/`ctx.env` (needed to reach the // person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback. // Java side) are only in scope inside a `ViewPeer` callback.
match AndroidRenderer::new(window, width as u32, height as u32) { let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
Ok(renderer) => { Ok(renderer) => {
self.state.android_state_mut().renderer = Some(renderer); self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx); self.render(ctx);
@@ -623,10 +728,18 @@ impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
/// `register_view_class`, which wants a plain function pointer) -- see /// `register_view_class`, which wants a plain function pointer) -- see
/// `iris/android-app/src/lib.rs`. /// `iris/android-app/src/lib.rs`.
pub fn new_peer<'local, State: AndroidAppState>( pub fn new_peer<'local, State: AndroidAppState>(
env: JNIEnv<'local>, mut env: JNIEnv<'local>,
view: View<'local>, view: View<'local>,
_context: Context<'local>, context: Context<'local>,
) -> android_view::jni::sys::jlong { ) -> android_view::jni::sys::jlong {
// `DisplayMetrics.density` -- physical pixels per dp on this device.
// Read once here, at the one point in this file already handed a
// `Context`, and carried on `AndroidUiState` from then on (see
// `content_scale`'s field comment for what depends on it).
let content_scale = context
.resources(&mut env)
.display_metrics(&mut env)
.density(&mut env);
let vm = env.get_java_vm().unwrap(); let vm = env.get_java_vm().unwrap();
let global_view = env.new_global_ref(&view.0).unwrap(); let global_view = env.new_global_ref(&view.0).unwrap();
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view)); let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
@@ -639,7 +752,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
_state: PhantomData, _state: PhantomData,
}; };
let shared = Rc::new(RefCell::new(Shared::default())); let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone()); let ui_state = AndroidUiState::new(shared.clone(), content_scale);
let mut state = State::new(ui_state, &mut rsc); let mut state = State::new(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap(); let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap(); let platform_view = env.new_global_ref(&view.0).unwrap();