Clean up shared UI runtime state

This commit is contained in:
iris committed 2026-09-11 00:55:33 -04:00
1 parent 9b4c690916
commit 5ca244528f
27 files changed
+355 -499

No files matched your search

+85 -176
View File
@@ -11,13 +11,7 @@ use android_view::{
},
ndk::event::{Axis, Keycode, MotionAction},
};
use std::{
cell::RefCell,
marker::{PhantomData, Sized},
rc::Rc,
sync::Arc,
time::Instant,
};
use std::{cell::RefCell, marker::Sized, rc::Rc, sync::Arc, time::Instant};
use super::{
access::{AndroidAccessSource, NullActionHandler, raise_if_enabled},
@@ -25,10 +19,7 @@ use super::{
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`.
/// Android host state. The renderer follows the `SurfaceView` lifecycle.
/// 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;
@@ -39,24 +30,13 @@ pub struct AndroidUiState {
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`).
/// Previous IME preedit length, in characters.
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.
/// Deferred until the input callback regains access to JNI.
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`.
/// Also deferred until a JNI callback is available.
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.
/// Filled by the native insets callback registered in `android/insets.rs`.
shared: Rc<RefCell<Shared>>,
pub access_adapter: AccessAdapter,
pub access: AccessTree,
@@ -132,13 +112,7 @@ pub trait AndroidAppState: HasAndroidUiState {
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.
/// Widget-facing insets in physical pixels, decoupled from JNI's integer shape.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct WindowInsets {
pub left: f32,
@@ -166,66 +140,7 @@ impl WindowInsets {
}
}
pub struct AndroidRsc<State: 'static> {
pub ui: Ui,
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) -> &Ui {
&self.ui
}
fn ui_mut(&mut self) -> &mut Ui {
&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
}
}
pub type AndroidRsc<State> = AppRsc<State>;
/// The `ViewPeer` android-view dispatches every callback to. One per
/// `RustView` instance; `new_peer` (below) builds it and hands the id to
@@ -234,30 +149,10 @@ pub struct IrisViewPeer<State: AndroidAppState> {
pub(super) rsc: AndroidRsc<State>,
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.
/// Converts input and Choreographer timestamps onto one monotonic clock.
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() {
@@ -298,7 +193,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
/// 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
/// Mirrors `desktop::DesktopApp::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);
@@ -322,6 +217,42 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.get_or_insert_with(|| DeviceClock::anchored(Instant::now(), event_time, oldest))
}
fn generic_motion(&mut self, ctx: &mut CallbackCtx, event: &MotionEvent<'_>) -> bool {
let action = event.action_masked(&mut ctx.env);
let event_time = event.event_time_nanos(&mut ctx.env);
let mut clock = self.device_clock(event_time, event_time);
let at = clock.sample(event_time);
self.device_clock = Some(clock);
let ui = self.state.android_state_mut();
ui.cursor.time = at;
ui.cursor.pos = vec2(event.x(&mut ctx.env), event.y(&mut ctx.env));
ui.cursor.exists = !matches!(action, MotionAction::HoverExit);
let buttons = event.button_state(&mut ctx.env);
ui.cursor.buttons.left.update(buttons.primary());
ui.cursor.buttons.right.update(buttons.secondary());
ui.cursor.buttons.middle.update(buttons.teriary());
match action {
MotionAction::HoverEnter
| MotionAction::HoverMove
| MotionAction::HoverExit
| MotionAction::ButtonPress
| MotionAction::ButtonRelease => {}
MotionAction::Scroll => {
ui.cursor.scroll_delta = vec2(
event.axis(&mut ctx.env, Axis::Hscroll, 0),
event.axis(&mut ctx.env, Axis::Vscroll, 0),
);
}
_ => return false,
}
self.after_input(ctx);
true
}
fn window_size(&self) -> Vec2 {
let ui_state = self.state.android_state();
match &ui_state.renderer {
@@ -514,27 +445,14 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) -> 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.
// MotionEvent and layout both use physical pixels.
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`.
// Use the event clock so batched movement keeps its real timing.
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)
@@ -544,24 +462,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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.
// Avoid allocating trace history when input tracing is disabled.
let trace_input = crate::diagnostics::trace_enabled();
let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new();
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.
// Android orders history oldest-first; `sample` checks monotonicity.
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);
@@ -616,6 +522,38 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
true
}
fn on_generic_motion_event<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
event: &MotionEvent<'local>,
) -> bool {
self.drain_tasks();
self.generic_motion(ctx, event)
}
fn on_hover_event<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
event: &MotionEvent<'local>,
) -> bool {
let action = event.action(&mut ctx.env);
let x = event.x(&mut ctx.env);
let y = event.y(&mut ctx.env);
let ui = self.state.android_state_mut();
if let Some(events) = ui
.access_adapter
.on_hover_event(&mut NullActionHandler, action, x, y)
{
ctx.push_dynamic_deferred_callback(move |env, view| {
raise_if_enabled(env, view, events);
});
true
} else {
self.drain_tasks();
self.generic_motion(ctx, event)
}
}
fn on_focus_changed<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
@@ -646,32 +584,10 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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.
// The layout canvas and wgpu surface are separate and both use physical pixels.
self.rsc.ui.resize((width as f32, height as f32));
// `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.
// Resizing preserves GPU resources; recreating a destroyed surface does not.
let already_live = self.state.android_state().renderer.is_some();
log::info!(
"iris surface: surface_changed {width}x{height} already_live={already_live} \
@@ -883,14 +799,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
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,
};
let (mut rsc, task_recv) = AppRsc::new(redraw);
rsc.ui.set_density(content_scale);
let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone(), content_scale);