Iris's phone came back "now THAT is smooth", and reading that run against the bench's own timings found three things the report was getting wrong -- two of them shipped yesterday in the fix for the last three. `missed vsyncs` counted idleness. Every gap between frames was treated as cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms rests between flings, 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms stream gaps -- each within a few percent of the arithmetic. A gap now measures anything only if the frame before it had asked for another one. `late` counted the swapchain wait as cost. A well-paced loop spends each frame blocked in the acquire, so its total sits at exactly one refresh period and every frame lands on the budget boundary -- 0.4ms of work and 5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`. And the refresh rate is the larger of what the platform claims and what the run sustained, because each can only be wrong one way. `Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in 33.1s, since a phone that varies its rate answers with whatever mode it is in when asked. The first attempt at measuring it instead took the fastest tenth of the gaps and reported 88Hz for this repo's 60Hz emulator, whose app manages 54 -- a budget no frame there could meet, invented out of the app's best moments, and caught only by running the corrected report on the emulator before shipping it. A sustained rate is a floor and cannot do that. Both are printed when they disagree. Also corrected in the docs: "103fps on a 120Hz screen" divided the fling phase by its whole duration, rests included. Both runs sustained ~120.3fps through the motion, so the callback ordering was never costing frames -- what changed is the clock, which moves no frame count at all, which is exactly why nothing in a report could show it. `fling_profile.rs` is `frame_profile.rs` and gained a stream run, which says where the frame time now is: folding an arriving event is 0.35ms and applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the phone. 401 events move the item count 652 -> 654, so nearly every one is a delta into the same row -- the cost is re-shaping one growing message, not `fold_event`'s per-event clone, which was the hypothesis and is what measuring it ruled out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1133 lines
51 KiB
Rust
1133 lines
51 KiB
Rust
use crate::prelude::*;
|
|
use crate::task::RequestRedraw;
|
|
use accesskit_android::Adapter as AccessAdapter;
|
|
use android_view::{
|
|
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
|
|
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer,
|
|
jni::{
|
|
JNIEnv, JavaVM,
|
|
objects::{GlobalRef, JValue},
|
|
sys::jint,
|
|
},
|
|
ndk::event::{Axis, Keycode, MotionAction},
|
|
};
|
|
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
|
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
|
// glob import shadows the language prelude -- `default/mod.rs` has the same
|
|
// explicit import for the same reason.
|
|
use std::{
|
|
cell::RefCell,
|
|
marker::{PhantomData, Sized},
|
|
rc::Rc,
|
|
sync::Arc,
|
|
time::Instant,
|
|
};
|
|
|
|
use super::{
|
|
access::{AndroidAccessSource, NullActionHandler, raise_if_enabled},
|
|
insets::{Insets, Shared},
|
|
render::{AndroidRedrawHandle, AndroidRenderer},
|
|
};
|
|
|
|
/// The android-view analogue of `default::DefaultUiState`. `renderer` is an
|
|
/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding
|
|
/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can
|
|
/// 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 root: Option<StrongWidget>,
|
|
pub renderer: Option<AndroidRenderer>,
|
|
pub focus: Option<WeakWidget<TextEdit>>,
|
|
pub cursor: CursorState,
|
|
pub last_click: Instant,
|
|
/// The IME preedit's previous length, in `char`s -- the same
|
|
/// re-send-the-whole-composition bookkeeping `default::DefaultUiState`
|
|
/// keeps for winit's `Ime::Preedit`, since android-view's
|
|
/// `setComposingText` has the identical shape (see `android/ime.rs`).
|
|
pub compose_len: usize,
|
|
/// Set by `attr::FocusHost::focus_gained` when a `TextEdit` is focused;
|
|
/// consumed by the touch handler after the sensor pass finishes, since
|
|
/// showing the keyboard is a JNI call and `focus_gained` runs deep
|
|
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
|
|
/// in reach.
|
|
pub pending_show_keyboard: bool,
|
|
/// A URL a tapped link asked the platform to open, for the same
|
|
/// reason `pending_show_keyboard` is a flag rather than a call --
|
|
/// see `android/platform.rs`.
|
|
pub pending_open_url: Option<String>,
|
|
/// Window insets, filled in from outside the normal `ViewPeer` callback
|
|
/// path -- see `android/insets.rs` for why they need a registry of
|
|
/// their own.
|
|
shared: Rc<RefCell<Shared>>,
|
|
/// I4 (RUST.md): pushed from `IrisViewPeer::render` and consulted by
|
|
/// the `AccessibilityNodeProvider` impl below; see `android/access.rs`
|
|
/// for the abort mitigation every `raise` on it goes through.
|
|
pub access_adapter: AccessAdapter,
|
|
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
|
|
/// comment.
|
|
pub access: AccessTree,
|
|
/// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
|
/// taken" (b)) -- `render()` below records into it once per frame,
|
|
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
|
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
|
pub frame_report: FrameReport,
|
|
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
|
|
/// per dp on this device, read once at view construction and carried
|
|
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
|
|
/// then on -- every `Len::dp` in the widget tree resolves against it at
|
|
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
|
|
/// "density-independent length unit" item, 2026-09-06).
|
|
///
|
|
/// **Everything else in this module is physical pixels, matching the
|
|
/// real wgpu surface/swapchain resolution** -- window size, touch
|
|
/// coordinates, insets. That is a correction from an earlier version
|
|
/// of this comment, which had `window_size`/`surface_changed`'s
|
|
/// `UiRenderState::resize` call divide by `content_scale` into a
|
|
/// *logical* coordinate space instead, as a global stopgap for
|
|
/// RUST.md's P0 box's phone report ("text is far too small"). That
|
|
/// stopgap fixed the size but not the *sharpness*: dividing to logical
|
|
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
|
|
/// then implicitly upscaled ~3x by the NDC mapping onto the real
|
|
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
|
|
/// logical size and stretched by the scale" Iris reported next.
|
|
/// Resolving `dp` at layout time replaces it: a widget author writes
|
|
/// `dp(16)` for a size that should look the same physical size on any
|
|
/// density, and everything downstream (layout, hit-testing, the window
|
|
/// uniform, and the font size handed to the text shaper) works in the
|
|
/// display's own physical pixels throughout, so nothing is
|
|
/// rasterised at one resolution and displayed at another.
|
|
pub content_scale: f32,
|
|
/// The last insets `render()` saw -- compared each frame so
|
|
/// `AndroidAppState::on_insets_changed` fires only when they actually
|
|
/// change (once at startup for the status bar, again if the device
|
|
/// rotates), not every frame.
|
|
last_insets: Insets,
|
|
}
|
|
|
|
impl AndroidUiState {
|
|
fn new(shared: Rc<RefCell<Shared>>, content_scale: f32) -> Self {
|
|
Self {
|
|
root: None,
|
|
renderer: None,
|
|
focus: None,
|
|
cursor: Default::default(),
|
|
last_click: Instant::now(),
|
|
compose_len: 0,
|
|
pending_show_keyboard: false,
|
|
pending_open_url: None,
|
|
shared,
|
|
access_adapter: Default::default(),
|
|
access: AccessTree::new(),
|
|
frame_report: FrameReport::new(),
|
|
content_scale,
|
|
last_insets: Insets::default(),
|
|
}
|
|
}
|
|
|
|
pub fn insets(&self) -> Insets {
|
|
self.shared.borrow().insets
|
|
}
|
|
|
|
/// The insets state as one line for a diagnostics pane, including how
|
|
/// many times the platform has delivered any -- see
|
|
/// `insets::Shared::updates` for why the count is the load-bearing
|
|
/// part. `dispatches=0` says the listener has never run and the
|
|
/// numbers beside it are defaults rather than measurements, which is
|
|
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
|
|
/// "design the unknown state first").
|
|
pub fn insets_report(&self) -> String {
|
|
let shared = self.shared.borrow();
|
|
let i = shared.insets;
|
|
if shared.updates == 0 {
|
|
return "insets: dispatches=0 -- the platform has never called \
|
|
onApplyWindowInsets, so nothing below was measured"
|
|
.to_string();
|
|
}
|
|
format!(
|
|
"insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \
|
|
ime_visible={}",
|
|
shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl HasRoot for AndroidUiState {
|
|
fn set_root(&mut self, root: StrongWidget) {
|
|
self.root = Some(root);
|
|
}
|
|
}
|
|
|
|
pub trait HasAndroidUiState: Sized + 'static {
|
|
fn android_state(&self) -> &AndroidUiState;
|
|
fn android_state_mut(&mut self) -> &mut AndroidUiState;
|
|
}
|
|
|
|
pub trait AndroidAppState: HasAndroidUiState {
|
|
fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self;
|
|
/// The system back gesture/button. `true` means handled -- nothing
|
|
/// further happens; `false` lets the activity finish as it would with
|
|
/// no view at all. The default declines, since most screens have
|
|
/// nothing to intercept it for.
|
|
#[allow(unused_variables)]
|
|
fn back_pressed(&mut self, rsc: &mut AndroidRsc<Self>, render: &mut UiRenderState) -> bool {
|
|
false
|
|
}
|
|
/// Called once, right after `new`, with a fresh `JavaVM` handle and a
|
|
/// global reference to this app's own `View` -- for a caller that
|
|
/// needs to call into Java itself beyond what a [`RequestRedraw`]
|
|
/// handle already covers (P0's bench build calling
|
|
/// `BatteryManager`/`ClipboardManager` through the view's `Context`,
|
|
/// docs/RUST.md). Not folded into `new` itself: most implementors need
|
|
/// nothing here, and `new`'s job is building the widget tree, not
|
|
/// holding a platform handle -- the default does nothing. `vm`/`view`
|
|
/// are independent handles from the ones `new_peer` keeps for its own
|
|
/// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so
|
|
/// storing them has no effect on that mechanism.
|
|
#[allow(unused_variables)]
|
|
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 physical-pixel units everything else in the
|
|
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
|
|
/// a widget can add it to a layout size directly -- `dp(...) +
|
|
/// abs(insets.top)` if the widget wants a density-independent size
|
|
/// plus the system bar's own (already-physical) height. The default
|
|
/// does nothing -- most screens have no chrome that sits under a
|
|
/// system bar.
|
|
#[allow(unused_variables)]
|
|
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
|
|
}
|
|
|
|
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
|
|
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
|
|
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
|
|
/// Both are physical pixels; this used to divide by `content_scale` into a
|
|
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
|
|
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
|
|
/// field comment for why that stopgap is gone.
|
|
#[derive(Clone, Copy, Default, Debug, PartialEq)]
|
|
pub struct WindowInsets {
|
|
pub left: f32,
|
|
pub top: f32,
|
|
pub right: f32,
|
|
pub bottom: f32,
|
|
/// How much of the window the keyboard covers, in physical pixels --
|
|
/// what a layout pads by. See `insets::Insets::ime_visible` for why
|
|
/// "is the keyboard up" is a separate field rather than this one
|
|
/// compared against zero.
|
|
pub ime_bottom: f32,
|
|
pub ime_visible: bool,
|
|
}
|
|
|
|
impl WindowInsets {
|
|
fn from_physical(insets: Insets) -> Self {
|
|
Self {
|
|
left: insets.left as f32,
|
|
top: insets.top as f32,
|
|
right: insets.right as f32,
|
|
bottom: insets.bottom as f32,
|
|
ime_bottom: insets.ime_bottom as f32,
|
|
ime_visible: insets.ime_visible,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The android-view analogue of `default::DefaultRsc` -- identical in
|
|
/// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState`
|
|
/// mention winit. Kept as a separate type rather than shared code because
|
|
/// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold
|
|
/// their harness state differently (see RUST.md's I2).
|
|
pub struct AndroidRsc<State: 'static> {
|
|
pub ui: UiData,
|
|
pub events: EventManager<Self>,
|
|
pub tasks: Tasks<Self>,
|
|
pub state: WidgetState,
|
|
_state: PhantomData<State>,
|
|
}
|
|
|
|
impl<State> AndroidRsc<State> {
|
|
pub fn create_state<T: 'static>(&mut self, id: impl IdLike, data: T) -> WeakState<T> {
|
|
self.state.add(id.id(), data)
|
|
}
|
|
}
|
|
|
|
impl<State> UiRsc for AndroidRsc<State> {
|
|
fn ui(&self) -> &UiData {
|
|
&self.ui
|
|
}
|
|
fn ui_mut(&mut self) -> &mut UiData {
|
|
&mut self.ui
|
|
}
|
|
fn on_draw(&mut self, active: &ActiveData) {
|
|
self.events.draw(active);
|
|
}
|
|
fn on_undraw(&mut self, active: &ActiveData) {
|
|
self.events.undraw(active);
|
|
}
|
|
fn on_remove(&mut self, id: WidgetId) {
|
|
self.events.remove(id);
|
|
self.state.remove(id);
|
|
}
|
|
}
|
|
|
|
impl<State: 'static> HasState for AndroidRsc<State> {
|
|
type State = State;
|
|
}
|
|
|
|
impl<State: 'static> HasEvents for AndroidRsc<State> {
|
|
fn events(&self) -> &EventManager<Self> {
|
|
&self.events
|
|
}
|
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
|
&mut self.events
|
|
}
|
|
}
|
|
|
|
impl<State: 'static> HasTasks for AndroidRsc<State> {
|
|
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
|
&mut self.tasks
|
|
}
|
|
}
|
|
|
|
impl<State: 'static> HasWidgetState for AndroidRsc<State> {
|
|
fn widget_state(&self) -> &WidgetState {
|
|
&self.state
|
|
}
|
|
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
|
&mut self.state
|
|
}
|
|
}
|
|
|
|
/// The `ViewPeer` android-view dispatches every callback to. One per
|
|
/// `RustView` instance; `new_peer` (below) builds it and hands the id to
|
|
/// Java the same way android-view's own demo does.
|
|
pub struct IrisViewPeer<State: AndroidAppState> {
|
|
pub(super) rsc: AndroidRsc<State>,
|
|
pub(super) render: UiRenderState,
|
|
pub(super) state: State,
|
|
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
|
/// The one ruler this view dates everything on: touch samples in
|
|
/// `on_touch_event` and the `Choreographer` frame time in `do_frame`.
|
|
/// Anchored by whichever of the two arrives first and never
|
|
/// re-anchored after, which is what lets a fling be advanced on the
|
|
/// same clock the gesture that launched it was measured on. Its path
|
|
/// out is the peer's own drop: it holds nothing but three numbers and
|
|
/// is meaningless to any other view.
|
|
device_clock: Option<DeviceClock>,
|
|
}
|
|
|
|
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
|
type Output = I::Output;
|
|
|
|
fn index(&self, index: I) -> &Self::Output {
|
|
index.get(self)
|
|
}
|
|
}
|
|
|
|
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::IndexMut<I> for AndroidRsc<State> {
|
|
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
|
index.get_mut(self)
|
|
}
|
|
}
|
|
|
|
impl<State: AndroidAppState> IrisViewPeer<State> {
|
|
fn drain_tasks(&mut self) {
|
|
while let Ok(update) = self.task_recv.try_recv() {
|
|
update(&mut self.state, &mut self.rsc);
|
|
}
|
|
}
|
|
|
|
/// One pointer sample through the sensors, plus the platform calls a
|
|
/// handler can only ask for by raising a flag. Split out of
|
|
/// [`Self::after_input`] because a batched `MotionEvent` carries
|
|
/// several samples that all belong to the same *frame*
|
|
/// (`on_touch_event`): each one is a real input frame the widgets must
|
|
/// see, but only the last one ends the frame and asks for a redraw.
|
|
fn run_input_frame(&mut self, ctx: &mut CallbackCtx) {
|
|
let window_size = self.window_size();
|
|
let ui_state = self.state.android_state_mut();
|
|
let cursor = ui_state.cursor.clone();
|
|
let old_focus = ui_state.focus;
|
|
self.render
|
|
.run_sensors(&mut self.rsc, &mut self.state, cursor, window_size);
|
|
|
|
let ui_state = self.state.android_state_mut();
|
|
if old_focus != ui_state.focus
|
|
&& let Some(old) = old_focus
|
|
{
|
|
old.edit(&mut self.rsc).deselect();
|
|
}
|
|
if std::mem::take(&mut ui_state.pending_show_keyboard) {
|
|
show_soft_input(&mut ctx.env, &ctx.view);
|
|
}
|
|
if let Some(url) = ui_state.pending_open_url.take() {
|
|
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
|
}
|
|
}
|
|
|
|
/// Common tail for every callback that might have changed the cursor,
|
|
/// the text focus, or the widget tree: run the sensors that touch
|
|
/// input feeds, then ask for a frame if the result needs drawing.
|
|
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
|
/// android-view's several entry points instead of winit's one.
|
|
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
|
self.run_input_frame(ctx);
|
|
|
|
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
|
// doesn't move cursor forward": Gboard needs `updateSelection`
|
|
// after every edit to keep its own model of the field in sync, or
|
|
// it holds keystrokes back rather than trusting a screen it
|
|
// believes is stale. See `update_ime_selection`'s own doc.
|
|
self.update_ime_selection(ctx);
|
|
|
|
let ui_state = self.state.android_state_mut();
|
|
ui_state.cursor.end_frame();
|
|
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
|
|
ctx.view.post_frame_callback(&mut ctx.env);
|
|
}
|
|
}
|
|
|
|
/// The view's one clock, anchoring it on `nanos` if nothing has yet
|
|
/// -- see the `device_clock` field. Either source may be the first to
|
|
/// arrive: a frame callback fires before any touch on an app that
|
|
/// animates at startup, and a touch arrives first on one that does
|
|
/// not.
|
|
///
|
|
/// `oldest` is the earliest sample the anchoring event carries, which
|
|
/// matters only when this is the call that anchors -- see
|
|
/// `DeviceClock::anchored`. A frame time carries no batch, so it
|
|
/// passes its own time for both.
|
|
fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock {
|
|
*self
|
|
.device_clock
|
|
.get_or_insert_with(|| DeviceClock::anchored(Instant::now(), event_time, oldest))
|
|
}
|
|
|
|
fn window_size(&self) -> Vec2 {
|
|
let ui_state = self.state.android_state();
|
|
match &ui_state.renderer {
|
|
Some(r) => r.size(),
|
|
None => Vec2::ZERO,
|
|
}
|
|
}
|
|
|
|
/// The `log::debug!` calls here are a live diagnostic for a still-open
|
|
/// finding (RUST.md's I2): layout runs and reports the right pixel
|
|
/// region for the root (confirmed via `window_region`, logged below),
|
|
/// and the clear colour reaches the screen (confirmed by swapping it to
|
|
/// magenta and screenshotting), but no primitive ever appears on top of
|
|
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
|
|
/// these in until that is root-caused; removing them loses the exact
|
|
/// evidence a `logcat` capture needs to reproduce the state. 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, now: Instant) {
|
|
if self.state.android_state().renderer.is_none() {
|
|
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 physical = WindowInsets::from_physical(current_insets);
|
|
// One line per real insets change. Iris's phone is the only
|
|
// place several of these bugs reproduce and `adb logcat` is
|
|
// the only instrument there (this-machine-android: system
|
|
// tracing is broken on that device), so the numbers a layout
|
|
// is actually fed have to reach the log -- "the composer
|
|
// floats at launch" is unanswerable from a screenshot alone.
|
|
log::info!(
|
|
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
|
|
ime_visible={} window={:?}",
|
|
physical.left,
|
|
physical.top,
|
|
physical.right,
|
|
physical.bottom,
|
|
physical.ime_bottom,
|
|
physical.ime_visible,
|
|
self.window_size(),
|
|
);
|
|
self.state.android_state_mut().last_insets = current_insets;
|
|
self.state.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();
|
|
log::debug!(
|
|
target: "iris::frame",
|
|
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
|
|
ui_state.root.is_some(),
|
|
self.rsc.widgets().len(),
|
|
self.render.active_widgets(),
|
|
ui_state
|
|
.root
|
|
.as_ref()
|
|
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
|
self.window_size(),
|
|
);
|
|
}
|
|
// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
|
// taken" (b)): started here, at the same point a redraw request
|
|
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
|
|
// `present()` -- the span Compose's render report and `gfxinfo`
|
|
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
|
// what this does and does not measure.
|
|
let frame_start = Instant::now();
|
|
// Anything moving on its own -- today a `LazySpan` coasting
|
|
// through a fling -- is advanced here, before the draw. **On
|
|
// `now`, not on `frame_start`**: `now` is the vsync the
|
|
// `Choreographer` handed this callback, which is evenly spaced,
|
|
// while `frame_start` is whenever the callback actually got to
|
|
// run. The frames are *presented* on the even cadence either way,
|
|
// so sampling the animation on the uneven one moves the content by
|
|
// an uneven distance per frame -- a fling that shimmers with no
|
|
// frame late enough to show up in a report. See
|
|
// `UiData::tick_animations` and `sense::DeviceClock`;
|
|
// `default/mod.rs`'s `RedrawRequested` arm is the winit half.
|
|
let animating = self.rsc.ui.tick_animations(now);
|
|
// **Asked for before the work, not after it.** A frame callback is
|
|
// one-shot, so an animation that wants another frame has to say so
|
|
// every frame -- and `Choreographer.postFrameCallback` schedules
|
|
// for the next vsync *after the call*. Asking at the end of this
|
|
// function meant any frame whose work ran past the vsync boundary
|
|
// (the swapchain acquire below alone can sit most of a frame)
|
|
// registered too late for the next one and got the one after --
|
|
// so one frame over budget silently cost a second frame as well.
|
|
// Unlike `after_input`, which only has to ask when input dirtied
|
|
// something.
|
|
if animating {
|
|
ctx.view.post_frame_callback(&mut ctx.env);
|
|
}
|
|
let ui_state = self.state.android_state_mut();
|
|
self.render.update(&ui_state.root, &mut self.rsc);
|
|
let ui_state = self.state.android_state_mut();
|
|
let Some(renderer) = &mut ui_state.renderer else {
|
|
return;
|
|
};
|
|
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 mut parts = renderer.draw();
|
|
parts.total = frame_start.elapsed();
|
|
// Dated on `now` -- the vsync this frame was for -- so the gap
|
|
// between consecutive frames is the display's own cadence and
|
|
// `PhaseStats::missed` counts vsyncs nothing was drawn for.
|
|
self.state
|
|
.android_state_mut()
|
|
.frame_report
|
|
.record(now, parts, animating);
|
|
crate::diagnostics::log_frame(&self.render, now, parts, animating);
|
|
if crate::diagnostics::trace_enabled() {
|
|
let ui_state = self.state.android_state();
|
|
log::debug!(
|
|
target: "iris::frame",
|
|
"render(): after update active={} root_px={:?}",
|
|
self.render.active_widgets(),
|
|
ui_state
|
|
.root
|
|
.as_ref()
|
|
.and_then(|r| self.render.window_region(r, &self.rsc)),
|
|
);
|
|
}
|
|
|
|
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
|
|
// anything to raise -- when the named set actually changed this
|
|
// frame; see `AccessTree`'s doc comment. Deferred rather than
|
|
// raised inline so it runs after this callback releases whatever
|
|
// it's holding, matching android-view's own demo and `raise`'s own
|
|
// contract.
|
|
let ui_state = self.state.android_state_mut();
|
|
if let Some(tree_update) =
|
|
ui_state
|
|
.access
|
|
.update(self.rsc.widgets(), &self.render, &self.rsc)
|
|
{
|
|
let ui_state = self.state.android_state_mut();
|
|
if let Some(events) = ui_state.access_adapter.update_if_active(|| tree_update) {
|
|
ctx.push_dynamic_deferred_callback(move |env, view| {
|
|
raise_if_enabled(env, view, events);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
|
|
let imm = view.input_method_manager(env);
|
|
imm.show_soft_input(env, view, 0);
|
|
}
|
|
|
|
/// Replaces the activity's content with a plain, selectable, scrollable
|
|
/// text view holding `report` -- the on-screen half of `surface_changed`'s
|
|
/// renderer-failure path (UI_RULES.md: "a failure is reported where it
|
|
/// happened, and says what to do next," here "copy this and send it").
|
|
/// Goes through an ordinary instance method on the Java side
|
|
/// (`IrisView.showRendererError`) rather than a new `native` method: this
|
|
/// call is Rust reaching *into* Java, the opposite direction from every
|
|
/// `native fn` android-view/`IrisView` declare, and an ordinary virtual
|
|
/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the
|
|
/// same way any other JNI method call here does. Silently does nothing on
|
|
/// any JNI failure -- there is no more-fallback screen to fall back to,
|
|
/// and the `log::error!` in `surface_changed` already reached logcat
|
|
/// first.
|
|
fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) {
|
|
let Ok(message) = env.new_string(report) else {
|
|
return;
|
|
};
|
|
let _ = env.call_method(
|
|
&view.0,
|
|
"showRendererError",
|
|
"(Ljava/lang/String;)V",
|
|
&[JValue::Object(message.as_ref())],
|
|
);
|
|
}
|
|
|
|
impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|
fn on_key_down<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
key_code: Keycode,
|
|
event: &KeyEvent<'local>,
|
|
) -> bool {
|
|
self.drain_tasks();
|
|
// With no `OnBackPressedCallback` registered on the Java side, the
|
|
// system still delivers the back gesture as a synthetic
|
|
// `KEYCODE_BACK` through this same path -- the legacy behaviour
|
|
// every view-based app gets by default, and enough for "the back
|
|
// gesture as an event" without a second JNI registry. See
|
|
// `android/insets.rs`'s doc comment for why insets could not take
|
|
// the same shortcut.
|
|
if key_code == Keycode::Back {
|
|
let handled = self.state.back_pressed(&mut self.rsc, &mut self.render);
|
|
if handled {
|
|
self.after_input(ctx);
|
|
}
|
|
return handled;
|
|
}
|
|
let handled = super::input::on_key(
|
|
&mut self.rsc,
|
|
&mut self.state,
|
|
&mut ctx.env,
|
|
key_code,
|
|
event,
|
|
);
|
|
if handled {
|
|
self.after_input(ctx);
|
|
}
|
|
handled
|
|
}
|
|
|
|
fn on_touch_event<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
event: &MotionEvent<'local>,
|
|
) -> bool {
|
|
self.drain_tasks();
|
|
let action = event.action_masked(&mut ctx.env);
|
|
// Device (physical) pixels, same space layout now uses throughout
|
|
// -- see `AndroidUiState::content_scale`'s field comment.
|
|
let x = event.x(&mut ctx.env);
|
|
let y = event.y(&mut ctx.env);
|
|
// The event's own clock, converted through the view's one anchor
|
|
// -- taken on whichever of a touch or a frame callback came first.
|
|
// Android reports sample times in the `SystemClock.uptimeMillis()`
|
|
// base, which is the same `CLOCK_MONOTONIC` an `Instant` reads, so
|
|
// a single `(Instant, nanos)` pair converts every later sample
|
|
// exactly. Anchoring **once** rather than per event is what keeps
|
|
// the times ordered, and anchoring on the first event's *oldest*
|
|
// sample rather than on its own time is what keeps that event's
|
|
// batch from collapsing onto one instant -- `sense::DeviceClock`'s
|
|
// doc has both, and owns the arithmetic so it can be unit-tested
|
|
// off a device (`sense_tests.rs`). See `CursorState::time`.
|
|
let event_time = event.event_time_nanos(&mut ctx.env);
|
|
let history = event.history_size(&mut ctx.env);
|
|
let mut clock = match self.device_clock {
|
|
Some(clock) => clock,
|
|
// Only the call that anchors needs the batch's oldest sample,
|
|
// so the JNI read for it stays off the per-event path.
|
|
None => {
|
|
let oldest = if history > 0 {
|
|
event.historical_event_time_nanos(&mut ctx.env, 0)
|
|
} else {
|
|
event_time
|
|
};
|
|
self.device_clock(event_time, oldest)
|
|
}
|
|
};
|
|
// `iris::input`'s own doc (`sense::log_input_event`): collected
|
|
// only when tracing is on, since this is otherwise a `Vec` per
|
|
// `MotionEvent` for a line nobody is reading -- the JNI reads
|
|
// themselves (`historical_axis`/`historical_event_time_nanos`
|
|
// below) already happen unconditionally, for the replay this
|
|
// function does regardless of tracing.
|
|
let trace_input = crate::diagnostics::trace_enabled();
|
|
let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new();
|
|
|
|
// **Historical samples first.** A flick on a 120Hz screen is
|
|
// delivered as one or two `MotionEvent`s with the intermediate
|
|
// positions batched inside them, so reading only `x()`/`y()` threw
|
|
// away every sample but the last: the velocity tracker saw one
|
|
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
|
|
// 0.0 below two samples, and the release therefore flung at zero --
|
|
// Iris's phone, twice ("fling still doesn't work"), while a
|
|
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
|
|
// Replayed one at a time through the sensors rather than summarised,
|
|
// so the arbiter, the tracker and any other sensor all see the same
|
|
// motion the finger actually made; only the last sample ends the
|
|
// frame (`after_input`).
|
|
if matches!(action, MotionAction::Move) {
|
|
// Android documents the historical samples as oldest first and
|
|
// the event's own sample as the newest of the batch; everything
|
|
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
|
// clock) assumes it, so say so here rather than at each reader.
|
|
// `DeviceClock::sample` is what asserts it, and it carries the
|
|
// last sample seen *across* events, so the first sample of
|
|
// every event is checked against the previous event's last one
|
|
// rather than against the anchor.
|
|
for pos in 0..history {
|
|
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
|
|
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
|
|
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
|
let sample_at = clock.sample(ht);
|
|
if trace_input {
|
|
historical_ms.push((clock.ms_since_anchor(ht), hx, hy));
|
|
}
|
|
let ui_state = self.state.android_state_mut();
|
|
ui_state.cursor.pos = vec2(hx, hy);
|
|
ui_state.cursor.time = sample_at;
|
|
self.run_input_frame(ctx);
|
|
}
|
|
}
|
|
|
|
let event_at = clock.sample(event_time);
|
|
let event_ms = clock.ms_since_anchor(event_time);
|
|
self.device_clock = Some(clock);
|
|
let ui_state = self.state.android_state_mut();
|
|
ui_state.cursor.time = event_at;
|
|
match action {
|
|
MotionAction::Down => {
|
|
ui_state.cursor.pos = vec2(x, y);
|
|
ui_state.cursor.exists = true;
|
|
ui_state.cursor.buttons.left.update(true);
|
|
}
|
|
MotionAction::Move => {
|
|
ui_state.cursor.pos = vec2(x, y);
|
|
}
|
|
MotionAction::Up => {
|
|
ui_state.cursor.pos = vec2(x, y);
|
|
ui_state.cursor.buttons.left.update(false);
|
|
}
|
|
// A cancel ends the press -- a release that never arrives
|
|
// leaves whichever widget took pointer capture holding it
|
|
// forever -- but it is **not** a release, and saying so is
|
|
// `CursorState::cancelled`. It used to take the `Up` arm, so
|
|
// the system's own swipe up from the bottom edge to leave the
|
|
// app (moves, then `ACTION_CANCEL`) reached iris as a flick
|
|
// released at speed, and the transcript flung while the app
|
|
// was in the background: Iris's 2026-09-08 "leaving and
|
|
// reopening the app also randomly moved the vertical scroll".
|
|
MotionAction::Cancel => {
|
|
ui_state.cursor.pos = vec2(x, y);
|
|
ui_state.cursor.buttons.left.update(false);
|
|
ui_state.cursor.cancelled = true;
|
|
}
|
|
_ => return false,
|
|
}
|
|
if trace_input {
|
|
let action_word = match action {
|
|
MotionAction::Down => "down",
|
|
MotionAction::Move => "move",
|
|
MotionAction::Up => "up",
|
|
MotionAction::Cancel => "cancel",
|
|
_ => "other",
|
|
};
|
|
crate::sense::log_input_event(action_word, x, y, event_ms, &historical_ms);
|
|
}
|
|
self.after_input(ctx);
|
|
true
|
|
}
|
|
|
|
fn on_focus_changed<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
gain_focus: bool,
|
|
_direction: i32,
|
|
_previously_focused_rect: Option<&Rect<'local>>,
|
|
) {
|
|
self.drain_tasks();
|
|
if !gain_focus {
|
|
let ui_state = self.state.android_state_mut();
|
|
if let Some(focus) = ui_state.focus.take() {
|
|
focus.edit(&mut self.rsc).deselect();
|
|
}
|
|
}
|
|
self.after_input(ctx);
|
|
}
|
|
|
|
fn on_attached_to_window(&mut self, _ctx: &mut CallbackCtx) {
|
|
self.drain_tasks();
|
|
}
|
|
|
|
fn surface_changed<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
holder: &android_view::SurfaceHolder<'local>,
|
|
_format: i32,
|
|
width: i32,
|
|
height: i32,
|
|
) {
|
|
self.drain_tasks();
|
|
// The layout engine's own notion of the canvas size is separate
|
|
// from the wgpu surface's -- winit's backend sets it from
|
|
// `WindowEvent::Resized`, and there is no equivalent automatic
|
|
// trigger here, so this is the one place android-view's surface
|
|
// size has to be told to `UiRenderState` too. Missing this drew
|
|
// nothing but the clear colour: the widget tree laid out against
|
|
// whatever size `UiRenderState::new` starts at instead of the
|
|
// surface's real one.
|
|
//
|
|
// **Physical pixels, matching `AndroidRenderer`'s own
|
|
// `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
|
|
// field comment. This call sets `UiRenderState::output_size`, which
|
|
// every `rel`/`rest` length resolves against and every `abs`
|
|
// pixel-region compares to directly; a `dp(56)` height now folds
|
|
// in the density at `Len::apply_rest` time instead of this call
|
|
// dividing the whole window into a separate logical space, which
|
|
// is what used to make every `abs`-unit size (a fixed `.height(56)`
|
|
// in particular) mean something different from a `rest`-based one.
|
|
self.render.resize((width as f32, height as f32));
|
|
|
|
// **Reuse the existing renderer (device, atlas, buffers, bind
|
|
// groups) when one is already live -- only reconfigure the
|
|
// surface.** `surfaceChanged` fires on *every* size or format
|
|
// change, not only on a genuinely new `Surface`/window: showing
|
|
// the IME under `adjustResize` resizes the same `SurfaceView` and
|
|
// is reported through this exact callback. Rebuilding the whole
|
|
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
|
|
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
|
|
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
|
|
// atlas coordinates it had already handed out against the *old*
|
|
// atlas. Every glyph then drew from a UV rectangle that pointed
|
|
// into a texture that had just been recreated empty, so text
|
|
// vanished on the first keyboard open while rects (which never go
|
|
// through the atlas) kept drawing -- exactly the "rectangles stay,
|
|
// glyphs disappear" Iris reported. Confirmed by reading this path
|
|
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
|
|
// only in `AndroidRenderer::new`) before changing anything, per
|
|
// AGENTS.md's "verify before finishing".
|
|
//
|
|
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
|
|
// rewrites the window uniform -- device, atlas, buffers and bind
|
|
// groups are untouched, so the glyph cache's coordinates stay
|
|
// valid. A genuinely new surface (after `surface_destroyed`, e.g.
|
|
// backgrounding) still goes through `AndroidRenderer::new` below,
|
|
// since `renderer` is `None` in that case.
|
|
let already_live = self.state.android_state().renderer.is_some();
|
|
log::info!(
|
|
"iris surface: surface_changed {width}x{height} already_live={already_live} \
|
|
glyphs_cached={} atlas_pages={}",
|
|
self.rsc.ui.text.atlas.glyph_count(),
|
|
self.rsc.ui.text.atlas.page_count(),
|
|
);
|
|
if already_live {
|
|
let ui_state = self.state.android_state_mut();
|
|
ui_state
|
|
.renderer
|
|
.as_mut()
|
|
.expect("checked Some above")
|
|
.resize(width as u32, height as u32);
|
|
self.render(ctx, Instant::now());
|
|
return;
|
|
}
|
|
|
|
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
|
// `AndroidRenderer::new` used to panic here through wgpu's own
|
|
// default uncaptured-error handler on a bind-group-layout
|
|
// validation failure -- exactly what aborted the P0 bench APK on
|
|
// Iris's phone with the message truncated to "wgpu error:
|
|
// Validation Error" and nothing else recoverable from the crash
|
|
// report (RUST.md's P0 box, "iris bench crash on the phone,
|
|
// 2026-09-06"). It now returns the full diagnostic instead; this is
|
|
// the one place in the app that can turn it into something a
|
|
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
|
|
// Java side) are only in scope inside a `ViewPeer` callback.
|
|
//
|
|
// `content_scale` reaches `AndroidRenderer` only for the
|
|
// Diagnostics page's report text now -- window size and the
|
|
// shader's window uniform are physical pixels throughout (see the
|
|
// `resize` call above), not divided by it.
|
|
let content_scale = self.state.android_state().content_scale;
|
|
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
|
|
Ok(renderer) => {
|
|
// A genuinely new renderer means a genuinely new GPU
|
|
// device, holding none of the textures the old one did --
|
|
// while the CPU side of them (`UiData::textures`, and the
|
|
// glyph atlas built on it) lives on `self.rsc` and
|
|
// survives. So every slot has to be uploaded again, and
|
|
// `Textures::reupload` queues exactly that, in slot order.
|
|
//
|
|
// It replaces clearing them, which threw away the *slot
|
|
// numbering* as well as the pixels: every `TextureHandle`
|
|
// a live widget still held -- one per icon or image on
|
|
// screen, and one per folded card at the time -- then
|
|
// named a slot nothing recognised, and the next frame
|
|
// panicked in `image_bind_group` ("texture slot 89 is not
|
|
// a live standalone image: None"). Re-uploading also keeps
|
|
// the glyph atlas, so an app switch no longer re-rasterises
|
|
// every glyph on screen. This only runs on the branch that
|
|
// actually builds a new renderer, never on the reuse
|
|
// branch above, where the textures are still on the device
|
|
// that holds them.
|
|
log::info!(
|
|
"iris surface: new renderer built ({:?}), re-uploading textures: \
|
|
glyphs={} pages={}",
|
|
renderer.adapter_backend,
|
|
self.rsc.ui.text.atlas.glyph_count(),
|
|
self.rsc.ui.text.atlas.page_count(),
|
|
);
|
|
self.rsc.ui.textures.reupload();
|
|
self.state.android_state_mut().renderer = Some(renderer);
|
|
self.render(ctx, Instant::now());
|
|
}
|
|
Err(report) => {
|
|
// One line for logcat (UI_RULES.md: "the full text for
|
|
// whoever can read the log" lives here), the multi-line
|
|
// original on screen -- `show_renderer_error` below.
|
|
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
|
|
// Deferred, not called directly: `Activity::setContentView`
|
|
// tears the old view hierarchy down synchronously, which
|
|
// fires `IrisView`'s own `onFocusChanged` before
|
|
// `setContentView` returns -- straight back into this same
|
|
// `IrisViewPeer` through `on_focus_changed` while
|
|
// `with_peer` (android-view's dispatch, `view.rs` upstream)
|
|
// still holds this peer's `RefCell` borrow for the
|
|
// `surface_changed` call in progress. Found by inducing a
|
|
// validation error and hitting `RefCell already borrowed`
|
|
// at exactly that reentrant call (RUST.md's P0 box).
|
|
// `push_dynamic_deferred_callback` runs after `with_peer`
|
|
// drops the borrow, which is what every other callback in
|
|
// this file that reaches into Java already relies on
|
|
// (`raise_if_enabled`, above).
|
|
ctx.push_dynamic_deferred_callback(move |env, view| {
|
|
show_renderer_error(env, view, &report);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn surface_destroyed<'local>(
|
|
&mut self,
|
|
_ctx: &mut CallbackCtx<'local>,
|
|
_holder: &android_view::SurfaceHolder<'local>,
|
|
) {
|
|
log::info!(
|
|
"iris surface: surface_destroyed, tearing the renderer down \
|
|
(glyphs_cached={} atlas_pages={})",
|
|
self.rsc.ui.text.atlas.glyph_count(),
|
|
self.rsc.ui.text.atlas.page_count(),
|
|
);
|
|
self.state.android_state_mut().renderer = None;
|
|
}
|
|
|
|
fn do_frame(&mut self, ctx: &mut CallbackCtx, frame_time_nanos: i64) {
|
|
self.drain_tasks();
|
|
// The vsync this frame is for, dated on the same ruler touch
|
|
// samples are (`DeviceClock`), rather than `Instant::now()` here:
|
|
// this callback runs some variable distance after that vsync --
|
|
// behind `drain_tasks`, behind whatever else the UI thread was
|
|
// doing -- and anything advanced by that variable amount moves
|
|
// unevenly between frames the display shows evenly. `at` rather
|
|
// than `sample`, since a frame time is not part of the touch
|
|
// samples' own ordering.
|
|
let now = self
|
|
.device_clock(frame_time_nanos, frame_time_nanos)
|
|
.at(frame_time_nanos);
|
|
self.render(ctx, now);
|
|
}
|
|
|
|
/// Where `AndroidRedrawHandle::request_redraw` (`android/render.rs`)
|
|
/// actually lands: `View.postDelayed`'s Runnable resolves to this, on
|
|
/// the UI thread, which is what makes it safe to call from a background
|
|
/// task's own thread when `post_frame_callback`'s `Choreographer`
|
|
/// requirement (a `Looper` on the *calling* thread) is not. Same body
|
|
/// as `do_frame` -- draining tasks and rendering immediately is a
|
|
/// perfectly good answer to "a background fetch has new state," and
|
|
/// avoids a second frame-scheduling path to keep in sync with the real
|
|
/// one.
|
|
fn delayed_callback(&mut self, ctx: &mut CallbackCtx) {
|
|
self.drain_tasks();
|
|
// No vsync to date this one on -- it is a background task's
|
|
// "there is new state", not a frame the display asked for.
|
|
self.render(ctx, Instant::now());
|
|
}
|
|
|
|
fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> {
|
|
Some(self)
|
|
}
|
|
|
|
fn as_accessibility_node_provider(&mut self) -> Option<&mut dyn AccessibilityNodeProvider> {
|
|
Some(self)
|
|
}
|
|
}
|
|
|
|
impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
|
|
fn create_accessibility_node_info<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
virtual_view_id: jint,
|
|
) -> AccessibilityNodeInfo<'local> {
|
|
let mut source = AndroidAccessSource {
|
|
widgets: self.rsc.widgets(),
|
|
render: &self.render,
|
|
rsc: &self.rsc,
|
|
};
|
|
let ui_state = self.state.android_state_mut();
|
|
AccessibilityNodeInfo(ui_state.access_adapter.create_accessibility_node_info(
|
|
&mut source,
|
|
&mut ctx.env,
|
|
&ctx.view.0,
|
|
virtual_view_id,
|
|
))
|
|
}
|
|
|
|
fn find_focus<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
focus_type: jint,
|
|
) -> AccessibilityNodeInfo<'local> {
|
|
let mut source = AndroidAccessSource {
|
|
widgets: self.rsc.widgets(),
|
|
render: &self.render,
|
|
rsc: &self.rsc,
|
|
};
|
|
let ui_state = self.state.android_state_mut();
|
|
AccessibilityNodeInfo(ui_state.access_adapter.find_focus(
|
|
&mut source,
|
|
&mut ctx.env,
|
|
&ctx.view.0,
|
|
focus_type,
|
|
))
|
|
}
|
|
|
|
fn perform_action<'local>(
|
|
&mut self,
|
|
ctx: &mut CallbackCtx<'local>,
|
|
virtual_view_id: jint,
|
|
action: jint,
|
|
arguments: &Bundle<'local>,
|
|
) -> bool {
|
|
let Some(action) =
|
|
accesskit_android::PlatformAction::from_java(&mut ctx.env, action, &arguments.0)
|
|
else {
|
|
return false;
|
|
};
|
|
let ui_state = self.state.android_state_mut();
|
|
let Some(events) = ui_state.access_adapter.perform_action(
|
|
&mut NullActionHandler,
|
|
virtual_view_id,
|
|
&action,
|
|
) else {
|
|
return false;
|
|
};
|
|
ctx.push_dynamic_deferred_callback(move |env, view| {
|
|
raise_if_enabled(env, view, events);
|
|
});
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Registers `IrisViewPeer<State>`'s native methods and builds one on every
|
|
/// `newViewPeer` call from Java. `State`'s app crate wraps this in a
|
|
/// concrete `extern "system" fn` (a generic function cannot be handed to
|
|
/// `register_view_class`, which wants a plain function pointer) -- see
|
|
/// `iris/android-app/src/lib.rs`.
|
|
pub fn new_peer<'local, State: AndroidAppState>(
|
|
mut env: JNIEnv<'local>,
|
|
view: View<'local>,
|
|
context: Context<'local>,
|
|
) -> 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);
|
|
log::info!("iris: new_peer content_scale={content_scale}");
|
|
let vm = env.get_java_vm().unwrap();
|
|
let global_view = env.new_global_ref(&view.0).unwrap();
|
|
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
|
|
let (tasks, task_recv) = Tasks::init(redraw);
|
|
let mut rsc = AndroidRsc {
|
|
ui: Default::default(),
|
|
events: Default::default(),
|
|
tasks,
|
|
state: Default::default(),
|
|
_state: PhantomData,
|
|
};
|
|
// See `TextData::density`'s field doc for why this is set alongside
|
|
// `render.set_density` below rather than read from there.
|
|
rsc.ui.text.density = content_scale;
|
|
let shared = Rc::new(RefCell::new(Shared::default()));
|
|
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
|
|
let mut state = State::new(ui_state, &mut rsc);
|
|
let platform_vm = env.get_java_vm().unwrap();
|
|
let platform_view = env.new_global_ref(&view.0).unwrap();
|
|
state.platform_ready(&mut rsc, platform_vm, platform_view);
|
|
let mut render = UiRenderState::new();
|
|
// Every `Len::dp` in the tree resolves against this from now on -- see
|
|
// `UiRenderState::density`'s field doc and `Len::dp`'s.
|
|
render.set_density(content_scale);
|
|
let peer = IrisViewPeer {
|
|
rsc,
|
|
render,
|
|
state,
|
|
task_recv,
|
|
device_clock: None,
|
|
};
|
|
let id = android_view::register_view_peer(peer);
|
|
super::insets::register(id, shared);
|
|
id
|
|
}
|