Simplify Iris app initialization and task updates

This commit is contained in:
iris committed 2026-09-11 12:28:33 -04:00
1 parent b5666cdef9
commit 166bac2a93
25 files changed
+330 -282

No files matched your search

+8 -8
View File
@@ -1,4 +1,4 @@
use crate::task::RequestRedraw;
use crate::task::WakeTaskQueue;
use android_view::{
View,
jni::{JavaVM, objects::GlobalRef},
@@ -429,23 +429,23 @@ impl AndroidRenderer {
}
}
/// `Tasks`' redraw handle on Android: a background task finishes on the
/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so
/// asking for a frame means attaching first. The global ref is what
/// `Tasks`' UI-thread wake on Android. Updates can be submitted from the
/// tokio runtime or an application-owned thread that is not attached to the
/// JVM, so posting the callback means attaching first. The global ref is what
/// survives past the JNI call that handed the `View` to us.
pub struct AndroidRedrawHandle {
pub struct AndroidTaskWake {
vm: JavaVM,
view: GlobalRef,
}
impl AndroidRedrawHandle {
impl AndroidTaskWake {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
}
impl RequestRedraw for AndroidRedrawHandle {
fn request_redraw(&self) {
impl WakeTaskQueue for AndroidTaskWake {
fn wake(&self) {
let Ok(mut env) = self.vm.attach_current_thread() else {
return;
};
+31 -19
View File
@@ -1,5 +1,5 @@
use crate::prelude::*;
use crate::task::RequestRedraw;
use crate::task::WakeTaskQueue;
use accesskit_android::Adapter as AccessAdapter;
use android_view::{
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
@@ -16,7 +16,7 @@ use std::{cell::RefCell, marker::Sized, rc::Rc, sync::Arc, time::Instant};
use super::{
access::{AndroidAccessSource, NullActionHandler, raise_if_enabled},
insets::{Insets, Shared},
render::{AndroidRedrawHandle, AndroidRenderer},
render::{AndroidRenderer, AndroidTaskWake},
};
/// Android host state. The renderer follows the `SurfaceView` lifecycle.
@@ -100,6 +100,16 @@ pub trait HasAndroidUiState: Sized + 'static {
fn android_state_mut(&mut self) -> &mut AndroidUiState;
}
impl HasAndroidUiState for AndroidUiState {
fn android_state(&self) -> &AndroidUiState {
self
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
self
}
}
/// Application state retained for the lifetime of one Android `View`.
///
/// [`StdRsc`] is the usual [`AndroidResources`] implementation, but the host only
@@ -118,6 +128,10 @@ pub trait AndroidAppState: HasAndroidUiState {
fn on_insets_changed(&mut self, rsc: &mut Self::Resources, insets: WindowInsets) {}
}
impl AndroidAppState for AndroidUiState {
type Resources = StdRsc<Self>;
}
/// Resources the Android host needs to draw and dispatch application events.
///
/// This deliberately names capabilities rather than storage. Custom bundles
@@ -128,12 +142,12 @@ pub trait AndroidResources<State>:
where
State: 'static,
{
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>);
fn new(wake: Arc<dyn WakeTaskQueue>) -> (Self, TaskMsgReceiver<Self>);
}
impl<State: 'static> AndroidResources<State> for StdRsc<State> {
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>) {
StdRsc::new(redraw)
fn new(wake: Arc<dyn WakeTaskQueue>) -> (Self, TaskMsgReceiver<Self>) {
StdRsc::new(wake)
}
}
@@ -223,8 +237,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
self.update_ime_selection(ctx);
let ui_state = self.state.android_state_mut();
ui_state.cursor.end_frame();
self.state.android_state_mut().cursor.end_frame();
self.request_frame_if_needed(ctx);
}
fn request_frame_if_needed(&self, ctx: &mut CallbackCtx) {
let ui_state = self.state.android_state();
let render_state = self.rsc.ui().render_state();
if render_state
.get()
@@ -720,18 +738,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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.
/// Where `AndroidTaskWake::wake` lands on the UI thread. Applying an
/// update and drawing it are deliberately separate: retained widget
/// invalidation decides whether this wake needs a frame.
fn delayed_callback(&mut self, ctx: &mut CallbackCtx) {
self.drain_tasks();
self.render(ctx, Instant::now());
self.request_frame_if_needed(ctx);
}
fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> {
@@ -835,8 +847,8 @@ pub fn new_peer<'local, State: AndroidAppState>(
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 (mut rsc, task_recv) = State::Resources::new(redraw);
let wake: Arc<dyn WakeTaskQueue> = Arc::new(AndroidTaskWake::new(vm, global_view));
let (mut rsc, task_recv) = State::Resources::new(wake);
rsc.ui_mut().set_density(content_scale);
let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
+18 -19
View File
@@ -5,41 +5,40 @@ use winit::{
window::WindowId,
};
pub trait AppState {
type Event: 'static;
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self;
pub trait AppState: 'static {
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop);
fn event(&mut self, event: Self::Event, event_loop: &ActiveEventLoop);
fn tasks_ready(&mut self);
fn exit(&mut self);
fn run()
where
Self: Sized,
{
App::<Self>::run();
}
}
pub struct App<State: AppState> {
state: Option<State>,
proxy: EventLoopProxy<State::Event>,
proxy: EventLoopProxy<()>,
init: Option<Box<Init<State>>>,
}
impl<State: AppState> App<State> {
pub fn run() {
type Init<State> = dyn FnOnce(&ActiveEventLoop, EventLoopProxy<()>) -> State;
impl<State: AppState + 'static> App<State> {
pub fn run_with(init: impl FnOnce(&ActiveEventLoop, EventLoopProxy<()>) -> State + 'static) {
super::logging::install(log::LevelFilter::Info);
let event_loop = EventLoop::with_user_event().build().unwrap();
let proxy = event_loop.create_proxy();
event_loop
.run_app(&mut App::<State> { state: None, proxy })
.run_app(&mut App::<State> {
state: None,
proxy,
init: Some(Box::new(init)),
})
.unwrap();
}
}
impl<State: AppState> ApplicationHandler<State::Event> for App<State> {
impl<State: AppState> ApplicationHandler<()> for App<State> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_none() {
let state = State::new(event_loop, self.proxy.clone());
let init = self.init.take().unwrap();
let state = init(event_loop, self.proxy.clone());
self.state = Some(state);
}
}
@@ -49,9 +48,9 @@ impl<State: AppState> ApplicationHandler<State::Event> for App<State> {
state.window_event(event, event_loop);
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: State::Event) {
fn user_event(&mut self, _: &ActiveEventLoop, (): ()) {
let state = self.state.as_mut().unwrap();
state.event(event, event_loop);
state.tasks_ready();
}
fn exiting(&mut self, _: &ActiveEventLoop) {
+76 -14
View File
@@ -16,11 +16,15 @@ mod platform;
mod render;
pub use access::*;
pub use app::*;
use app::{App, AppState};
pub use input::*;
pub use render::*;
pub type Proxy<Event> = EventLoopProxy<Event>;
impl WakeTaskQueue for EventLoopProxy<()> {
fn wake(&self) {
let _ = self.send_event(());
}
}
/// Physical pixels per dp. Layout and input stay in physical pixels; only
/// `dp(...)` resolves through this scale.
@@ -79,11 +83,18 @@ pub trait HasDesktopUiState: Sized + 'static {
fn desktop_state_mut(&mut self) -> &mut DesktopUiState;
}
impl HasDesktopUiState for DesktopUiState {
fn desktop_state(&self) -> &DesktopUiState {
self
}
fn desktop_state_mut(&mut self) -> &mut DesktopUiState {
self
}
}
pub trait DesktopAppState: HasDesktopUiState {
type Event = ();
fn new(ui_state: DesktopUiState, rsc: &mut StdRsc<Self>, proxy: Proxy<Self::Event>) -> Self;
#[allow(unused_variables)]
fn event(&mut self, event: Self::Event, rsc: &mut StdRsc<Self>) {}
fn new(ui_state: DesktopUiState, rsc: &mut StdRsc<Self>) -> Self;
#[allow(unused_variables)]
fn exit(&mut self, rsc: &mut StdRsc<Self>) {}
#[allow(unused_variables)]
@@ -93,18 +104,33 @@ pub trait DesktopAppState: HasDesktopUiState {
}
}
impl DesktopAppState for DesktopUiState {
fn new(ui_state: DesktopUiState, _: &mut StdRsc<Self>) -> Self {
ui_state
}
}
pub struct DesktopApp<State: DesktopAppState> {
rsc: StdRsc<State>,
state: State,
task_recv: TaskMsgReceiver<StdRsc<State>>,
}
impl<State: DesktopAppState> AppState for DesktopApp<State> {
type Event = State::Event;
impl<State: DesktopAppState> DesktopApp<State> {
pub fn run() {
App::<Self>::run_with(|event_loop, proxy| {
Self::new_with(event_loop, proxy, State::window_attributes(), State::new)
});
}
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
fn new_with(
event_loop: &ActiveEventLoop,
proxy: EventLoopProxy<()>,
attributes: WindowAttributes,
init: impl FnOnce(DesktopUiState, &mut StdRsc<State>) -> State,
) -> Self {
let window = event_loop
.create_window(State::window_attributes().with_visible(false))
.create_window(attributes.with_visible(false))
.unwrap();
let access_adapter = accesskit_winit::Adapter::with_direct_handlers(
event_loop,
@@ -115,20 +141,56 @@ impl<State: DesktopAppState> AppState for DesktopApp<State> {
);
window.set_visible(true);
let desktop_state = DesktopUiState::new(window, access_adapter);
let (mut rsc, task_recv) = StdRsc::new(desktop_state.window.clone());
let (mut rsc, task_recv) = StdRsc::new(Arc::new(proxy));
// Set before building widgets so the first text shape uses the right density.
let scale = content_scale(desktop_state.window.as_ref());
rsc.ui.set_density(scale);
let state = State::new(desktop_state, &mut rsc, proxy);
let state = init(desktop_state, &mut rsc);
Self {
rsc,
state,
task_recv,
}
}
}
fn event(&mut self, event: Self::Event, _: &ActiveEventLoop) {
self.state.event(event, &mut self.rsc);
impl DesktopApp<DesktopUiState> {
/// Runs an application whose only state is Iris's desktop UI state.
pub fn run_with(init: impl FnOnce(&mut DesktopUiState, &mut StdRsc<DesktopUiState>) + 'static) {
Self::run_with_attributes(DesktopUiState::window_attributes(), init);
}
pub fn run_with_attributes(
attributes: WindowAttributes,
init: impl FnOnce(&mut DesktopUiState, &mut StdRsc<DesktopUiState>) + 'static,
) {
App::<Self>::run_with(move |event_loop, proxy| {
Self::new_with(event_loop, proxy, attributes, move |mut ui_state, rsc| {
init(&mut ui_state, rsc);
ui_state
})
});
}
}
impl<State: DesktopAppState> AppState for DesktopApp<State> {
fn tasks_ready(&mut self) {
let Self {
rsc,
state,
task_recv,
} = self;
for update in task_recv.try_iter() {
update(state, rsc);
}
let ui_state = state.desktop_state();
let render_state = rsc.ui.render_state();
if render_state
.get()
.needs_redraw(&ui_state.root, rsc.widgets())
{
ui_state.renderer.window().request_redraw();
}
}
fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) {
-7
View File
@@ -1,4 +1,3 @@
use crate::task::RequestRedraw;
use iris_core::{FrameParts, LinearRgba, Ui, UiRenderNode, util::Vec2};
use pollster::FutureExt;
use std::sync::Arc;
@@ -8,12 +7,6 @@ use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK;
impl RequestRedraw for Window {
fn request_redraw(&self) {
Window::request_redraw(self);
}
}
pub struct UiRenderer {
window: Arc<Window>,
surface: Surface<'static>,
+2 -2
View File
@@ -103,8 +103,8 @@ impl RedrawCounter {
}
}
impl RequestRedraw for RedrawCounter {
fn request_redraw(&self) {
impl WakeTaskQueue for RedrawCounter {
fn wake(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
-3
View File
@@ -1,8 +1,5 @@
#![feature(unboxed_closures)]
#![feature(fn_traits)]
// Only `desktop::DesktopAppState::Event`'s default uses this; unused (and
// warned about) on the android target, which has no such default.
#![cfg_attr(not(target_os = "android"), feature(associated_type_defaults))]
#![feature(unsize)]
#![feature(option_into_flat_iter)]
#![feature(async_fn_traits)]
+2 -2
View File
@@ -33,8 +33,8 @@ pub struct StdRsc<State: 'static> {
}
impl<State> StdRsc<State> {
pub(crate) fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>) {
let (tasks, receiver) = Tasks::init(redraw);
pub(crate) fn new(wake: Arc<dyn WakeTaskQueue>) -> (Self, TaskMsgReceiver<Self>) {
let (tasks, receiver) = Tasks::init(wake);
(
Self {
ui: Ui::default(),
+72 -28
View File
@@ -19,15 +19,13 @@ pub async fn sleep(duration: std::time::Duration) {
tokio::time::sleep(duration).await;
}
/// What a completed task nudges when it wants its result drawn. Shared
/// between backends rather than typed as `winit::window::Window` directly:
/// android-view has no `Window` at all, and the redraw request there is a
/// JNI call (`View::post_frame_callback`) rather than a method call on a
/// value this crate owns. Each backend supplies its own implementation --
/// `desktop/render.rs` for winit, `android/render.rs` for android-view --
/// and this module never needs to know which one it is holding.
pub trait RequestRedraw: Send + Sync + 'static {
fn request_redraw(&self);
/// Wakes the platform UI thread so it can apply queued task updates.
///
/// Waking does not itself mean drawing. Once the updates have run, the host
/// asks the retained UI tree whether anything visible became dirty and only
/// then schedules a frame.
pub trait WakeTaskQueue: Send + Sync + 'static {
fn wake(&self);
}
pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
@@ -38,29 +36,41 @@ impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc>
pub struct Tasks<Rsc: HasState> {
start: AsyncSender<BoxTask>,
redraw: Arc<dyn RequestRedraw>,
wake: Arc<dyn WakeTaskQueue>,
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
}
pub struct TaskCtx<Rsc: HasState> {
send: TaskMsgSender<Rsc>,
wake: Arc<dyn WakeTaskQueue>,
}
impl<Rsc: HasState> Clone for TaskCtx<Rsc> {
fn clone(&self) -> Self {
Self {
send: self.send.clone(),
wake: self.wake.clone(),
}
}
}
impl<Rsc: HasState> TaskCtx<Rsc> {
pub fn update(&mut self, f: impl TaskUpdate<Rsc> + 'static) {
let _ = self.send.send(Box::new(f));
if self.send.send(Box::new(f)).is_ok() {
self.wake.wake();
}
}
}
impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
fn new(send: TaskMsgSender<Rsc>) -> Self {
Self { send }
fn new(send: TaskMsgSender<Rsc>, wake: Arc<dyn WakeTaskQueue>) -> Self {
Self { send, wake }
}
}
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
impl<Rsc: HasState> Tasks<Rsc> {
pub fn init(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Rsc>) {
pub fn init(wake: Arc<dyn WakeTaskQueue>) -> (Self, TaskMsgReceiver<Rsc>) {
let (start, start_recv) = async_channel();
let (msgs, msgs_recv) = sync_channel();
std::thread::spawn(|| {
@@ -71,22 +81,16 @@ impl<Rsc: HasState> Tasks<Rsc> {
Self {
start,
msg_send: msgs,
redraw,
wake,
},
msgs_recv,
)
}
/// The same redraw handle `spawn`'s wrapper calls once, after a whole
/// task's future completes -- exposed so a caller running its own
/// longer-lived loop *inside* a spawned task (a live SSE follow, here)
/// can ask for a frame after each `TaskCtx::update`, not just at the
/// end. Without this a caller has no way to get a redraw mid-stream,
/// which is exactly the gap `iris/desktop-app`'s `app.rs` module doc
/// names for why it uses winit's `Proxy` instead of `Tasks` -- Android
/// has no `Proxy`, so this is what closes the same gap there.
pub fn redraw_handle(&self) -> Arc<dyn RequestRedraw> {
self.redraw.clone()
/// A cloneable, platform-neutral way for an application-owned thread to
/// submit work to the UI thread.
pub fn context(&self) -> TaskCtx<Rsc> {
TaskCtx::new(self.msg_send.clone(), self.wake.clone())
}
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
@@ -94,10 +98,9 @@ impl<Rsc: HasState> Tasks<Rsc> {
F::CallOnceFuture: Send,
{
let send = self.msg_send.clone();
let redraw = self.redraw.clone();
let wake = self.wake.clone();
let _ = self.start.send(Box::pin(async move {
task(TaskCtx::new(send)).await;
redraw.request_redraw();
task(TaskCtx::new(send, wake)).await;
}));
}
}
@@ -107,3 +110,44 @@ async fn listen(mut recv: AsyncReceiver<BoxTask>) {
tokio::spawn(task);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
struct TestRsc;
impl HasState for TestRsc {
type State = usize;
}
#[derive(Default)]
struct WakeCounter(AtomicUsize);
impl WakeTaskQueue for WakeCounter {
fn wake(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn every_update_wakes_the_ui_queue() {
let wakes = Arc::new(WakeCounter::default());
let (tasks, updates) = Tasks::<TestRsc>::init(wakes.clone());
let mut ctx = tasks.context();
ctx.update(|state: &mut usize, _| *state += 1);
ctx.update(|state: &mut usize, _| *state += 2);
assert_eq!(wakes.0.load(Ordering::Relaxed), 2);
let mut state = 0;
let mut rsc = TestRsc;
updates.recv_timeout(Duration::from_secs(1)).unwrap()(&mut state, &mut rsc);
updates.recv_timeout(Duration::from_secs(1)).unwrap()(&mut state, &mut rsc);
assert_eq!(state, 3);
}
}