From 166bac2a93554108b85e448d5a21ffdc4abd5213 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 11 Sep 2026 12:28:33 -0400 Subject: [PATCH] Simplify Iris app initialization and task updates --- cargo-iris/tests/fixture/src/lib.rs | 4 +- examples/bench_images/android.rs | 18 ++--- examples/bench_images/desktop.rs | 5 +- examples/message_list/android.rs | 18 ++--- examples/message_list/desktop.rs | 22 ++---- examples/minimal/android.rs | 18 ++--- examples/minimal/desktop.rs | 15 +---- examples/tabs/android.rs | 7 +- examples/tabs/desktop.rs | 9 ++- examples/task/android.rs | 18 ++--- examples/task/desktop.rs | 15 +---- examples/task/lib.rs | 25 +++---- examples/view/android.rs | 18 ++--- examples/view/desktop.rs | 15 +---- macro/src/lib.rs | 45 +++++++++---- readme.md | 49 +++++++++----- src/android/render.rs | 16 ++--- src/android/view.rs | 50 ++++++++------ src/desktop/app.rs | 37 +++++----- src/desktop/mod.rs | 90 +++++++++++++++++++++---- src/desktop/render.rs | 7 -- src/harness.rs | 4 +- src/lib.rs | 3 - src/rsc/mod.rs | 4 +- src/rsc/task.rs | 100 ++++++++++++++++++++-------- 25 files changed, 330 insertions(+), 282 deletions(-) diff --git a/cargo-iris/tests/fixture/src/lib.rs b/cargo-iris/tests/fixture/src/lib.rs index 5706b7a..bd0e0a0 100644 --- a/cargo-iris/tests/fixture/src/lib.rs +++ b/cargo-iris/tests/fixture/src/lib.rs @@ -28,8 +28,8 @@ pub struct Resources { } impl AndroidResources for Resources { - fn new(redraw: Arc) -> (Self, TaskMsgReceiver) { - let (tasks, receiver) = Tasks::init(redraw); + fn new(wake: Arc) -> (Self, TaskMsgReceiver) { + let (tasks, receiver) = Tasks::init(wake); ( Self { ui: Ui::default(), diff --git a/examples/bench_images/android.rs b/examples/bench_images/android.rs index fd0c88b..cfccdad 100644 --- a/examples/bench_images/android.rs +++ b/examples/bench_images/android.rs @@ -2,19 +2,11 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(AndroidUiState)] -struct State { - ui_state: AndroidUiState, -} - -impl AndroidAppState for State { - type Resources = StdRsc; -} #[iris::app_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { - let _ = build(rsc, &mut ui_state); - State { ui_state } +fn create( + ui_state: &mut AndroidUiState, + rsc: &mut StdRsc, +) { + let _ = app::build(rsc, ui_state); } diff --git a/examples/bench_images/desktop.rs b/examples/bench_images/desktop.rs index 57fcfc6..e139028 100644 --- a/examples/bench_images/desktop.rs +++ b/examples/bench_images/desktop.rs @@ -3,7 +3,6 @@ use winit::event::WindowEvent; #[path = "lib.rs"] mod app; -use app::*; const SETTLE_FRAMES: usize = 4; const FRAMES: usize = 6; @@ -17,8 +16,8 @@ struct State { } impl DesktopAppState for State { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { - let span = build(rsc, &mut ui_state); + fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { + let span = app::build(rsc, &mut ui_state); Self { ui_state, span, diff --git a/examples/message_list/android.rs b/examples/message_list/android.rs index 63b7e7b..773a3ac 100644 --- a/examples/message_list/android.rs +++ b/examples/message_list/android.rs @@ -2,19 +2,11 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(AndroidUiState)] -struct State { - ui_state: AndroidUiState, -} - -impl AndroidAppState for State { - type Resources = StdRsc; -} #[iris::app_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { - build(rsc, &mut ui_state); - State { ui_state } +fn create( + ui_state: &mut AndroidUiState, + rsc: &mut StdRsc, +) { + app::build(rsc, ui_state); } diff --git a/examples/message_list/desktop.rs b/examples/message_list/desktop.rs index 4657509..e84ff8b 100644 --- a/examples/message_list/desktop.rs +++ b/examples/message_list/desktop.rs @@ -3,24 +3,10 @@ use winit::{dpi::LogicalSize, window::WindowAttributes}; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(DesktopUiState)] -struct State { - ui_state: DesktopUiState, -} - -impl DesktopAppState for State { - fn window_attributes() -> WindowAttributes { - WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)) - } - - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { - build(rsc, &mut ui_state); - Self { ui_state } - } -} fn main() { - DesktopApp::::run(); + let attributes = WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)); + DesktopApp::::run_with_attributes(attributes, |ui_state, rsc| { + app::build(rsc, ui_state) + }); } diff --git a/examples/minimal/android.rs b/examples/minimal/android.rs index 63b7e7b..773a3ac 100644 --- a/examples/minimal/android.rs +++ b/examples/minimal/android.rs @@ -2,19 +2,11 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(AndroidUiState)] -struct State { - ui_state: AndroidUiState, -} - -impl AndroidAppState for State { - type Resources = StdRsc; -} #[iris::app_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { - build(rsc, &mut ui_state); - State { ui_state } +fn create( + ui_state: &mut AndroidUiState, + rsc: &mut StdRsc, +) { + app::build(rsc, ui_state); } diff --git a/examples/minimal/desktop.rs b/examples/minimal/desktop.rs index 2a2dd10..d7d0199 100644 --- a/examples/minimal/desktop.rs +++ b/examples/minimal/desktop.rs @@ -2,20 +2,7 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(DesktopUiState)] -struct State { - ui_state: DesktopUiState, -} - -impl DesktopAppState for State { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { - build(rsc, &mut ui_state); - Self { ui_state } - } -} fn main() { - DesktopApp::::run(); + DesktopApp::::run_with(|ui_state, rsc| app::build(rsc, ui_state)); } diff --git a/examples/tabs/android.rs b/examples/tabs/android.rs index 39a839e..c5a46e5 100644 --- a/examples/tabs/android.rs +++ b/examples/tabs/android.rs @@ -2,7 +2,6 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; #[derive(AndroidUiState)] struct Client { @@ -19,14 +18,14 @@ impl AndroidAppState for Client { .renderer .as_ref() .map_or(0, |renderer| renderer.ui.view_count()); - update_info(rsc, self.info, views); + app::update_info(rsc, self.info, views); } } #[iris::app_init] fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Client { - let widgets = build(rsc, &mut ui_state); - update_info(rsc, widgets.info, 0); + let widgets = app::build(rsc, &mut ui_state); + app::update_info(rsc, widgets.info, 0); Client { ui_state, info: widgets.info, diff --git a/examples/tabs/desktop.rs b/examples/tabs/desktop.rs index 8379d6f..be2b18c 100644 --- a/examples/tabs/desktop.rs +++ b/examples/tabs/desktop.rs @@ -3,7 +3,6 @@ use winit::event::WindowEvent; #[path = "lib.rs"] mod app; -use app::*; #[derive(DesktopUiState)] struct Client { @@ -12,9 +11,9 @@ struct Client { } impl DesktopAppState for Client { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { - let widgets = build(rsc, &mut ui_state); - update_info(rsc, widgets.info, 0); + fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { + let widgets = app::build(rsc, &mut ui_state); + app::update_info(rsc, widgets.info, 0); Self { ui_state, info: widgets.info, @@ -22,7 +21,7 @@ impl DesktopAppState for Client { } fn window_event(&mut self, _: WindowEvent, rsc: &mut StdRsc) { - update_info(rsc, self.info, self.ui_state.renderer.ui.view_count()); + app::update_info(rsc, self.info, self.ui_state.renderer.ui.view_count()); } } diff --git a/examples/task/android.rs b/examples/task/android.rs index 63b7e7b..773a3ac 100644 --- a/examples/task/android.rs +++ b/examples/task/android.rs @@ -2,19 +2,11 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(AndroidUiState)] -struct State { - ui_state: AndroidUiState, -} - -impl AndroidAppState for State { - type Resources = StdRsc; -} #[iris::app_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { - build(rsc, &mut ui_state); - State { ui_state } +fn create( + ui_state: &mut AndroidUiState, + rsc: &mut StdRsc, +) { + app::build(rsc, ui_state); } diff --git a/examples/task/desktop.rs b/examples/task/desktop.rs index 2a2dd10..d7d0199 100644 --- a/examples/task/desktop.rs +++ b/examples/task/desktop.rs @@ -2,20 +2,7 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(DesktopUiState)] -struct State { - ui_state: DesktopUiState, -} - -impl DesktopAppState for State { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { - build(rsc, &mut ui_state); - Self { ui_state } - } -} fn main() { - DesktopApp::::run(); + DesktopApp::::run_with(|ui_state, rsc| app::build(rsc, ui_state)); } diff --git a/examples/task/lib.rs b/examples/task/lib.rs index d4dbe0a..1942f8d 100644 --- a/examples/task/lib.rs +++ b/examples/task/lib.rs @@ -6,16 +6,17 @@ where Rsc::State: FocusHost, { let rect = rect(PaintId::RED).add(rsc); - rect.task_on(CursorSense::click(), async move |mut ctx| { - iris::task::sleep(Duration::from_secs(1)).await; - ctx.update(move |_, rsc| { - let rect = rect(rsc); - if rect.is_paint(&PaintId::RED) { - rect.set_paint(PaintId::BLUE); - } else { - rect.set_paint(PaintId::RED); - } - }); - }) - .set_root(rsc, ui_state); + rect.label("Toggle color") + .task_on(CursorSense::click(), async move |mut ctx| { + iris::task::sleep(Duration::from_secs(1)).await; + ctx.update(move |_, rsc| { + let rect = rect(rsc); + if rect.is_paint(&PaintId::RED) { + rect.set_paint(PaintId::BLUE); + } else { + rect.set_paint(PaintId::RED); + } + }); + }) + .set_root(rsc, ui_state); } diff --git a/examples/view/android.rs b/examples/view/android.rs index 63b7e7b..773a3ac 100644 --- a/examples/view/android.rs +++ b/examples/view/android.rs @@ -2,19 +2,11 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(AndroidUiState)] -struct State { - ui_state: AndroidUiState, -} - -impl AndroidAppState for State { - type Resources = StdRsc; -} #[iris::app_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { - build(rsc, &mut ui_state); - State { ui_state } +fn create( + ui_state: &mut AndroidUiState, + rsc: &mut StdRsc, +) { + app::build(rsc, ui_state); } diff --git a/examples/view/desktop.rs b/examples/view/desktop.rs index 2a2dd10..d7d0199 100644 --- a/examples/view/desktop.rs +++ b/examples/view/desktop.rs @@ -2,20 +2,7 @@ use iris::prelude::*; #[path = "lib.rs"] mod app; -use app::*; - -#[derive(DesktopUiState)] -struct State { - ui_state: DesktopUiState, -} - -impl DesktopAppState for State { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { - build(rsc, &mut ui_state); - Self { ui_state } - } -} fn main() { - DesktopApp::::run(); + DesktopApp::::run_with(|ui_state, rsc| app::build(rsc, ui_state)); } diff --git a/macro/src/lib.rs b/macro/src/lib.rs index 3810791..c06fa6c 100644 --- a/macro/src/lib.rs +++ b/macro/src/lib.rs @@ -9,13 +9,15 @@ use syn::{ spanned::Spanned, }; -/// Marks the factory called when Android creates an Iris view. +/// Marks the initializer called when Android creates an Iris view. /// /// An attribute is necessary here because the Android loader requires one /// exported `JNI_OnLoad` symbol and `android-view` requires a plain function -/// pointer monomorphized for the returned application state. The generated -/// linker and JNI glue is Android-gated; the annotated function therefore -/// does not need its own `cfg` attribute. +/// pointer monomorphized for the application state. A function returning a +/// custom state remains its factory; a function with no return value receives +/// `&mut AndroidUiState` and uses that state directly. The generated linker and +/// JNI glue is Android-gated; the annotated function therefore does not need +/// its own `cfg` attribute. #[proc_macro_attribute] pub fn app_init(args: TokenStream, item: TokenStream) -> TokenStream { if !args.is_empty() { @@ -29,13 +31,9 @@ pub fn app_init(args: TokenStream, item: TokenStream) -> TokenStream { let function = parse_macro_input!(item as ItemFn); let name = &function.sig.ident; - let ReturnType::Type(_, state) = &function.sig.output else { - return Error::new( - function.sig.output.span(), - "an app_init function must return its application state", - ) - .into_compile_error() - .into(); + let (state, direct_initializer): (Type, bool) = match &function.sig.output { + ReturnType::Default => (parse_quote!(::iris::android::AndroidUiState), true), + ReturnType::Type(_, state) => ((**state).clone(), false), }; if function.sig.inputs.len() != 2 || function @@ -46,7 +44,7 @@ pub fn app_init(args: TokenStream, item: TokenStream) -> TokenStream { { return Error::new( function.sig.inputs.span(), - "an app_init function takes AndroidUiState and &mut State::Resources", + "an app_init function takes UI state and resources", ) .into_compile_error() .into(); @@ -64,6 +62,25 @@ pub fn app_init(args: TokenStream, item: TokenStream) -> TokenStream { .into(); } + let factory = if direct_initializer { + quote! { + fn init( + mut ui_state: ::iris::android::AndroidUiState, + rsc: &mut <#state as ::iris::android::AndroidAppState>::Resources, + ) -> #state { + super::#name(&mut ui_state, rsc); + ui_state + } + } + } else { + quote! {} + }; + let create = if direct_initializer { + quote! { init } + } else { + quote! { super::#name } + }; + quote! { #[cfg(target_os = "android")] #function @@ -72,12 +89,14 @@ pub fn app_init(args: TokenStream, item: TokenStream) -> TokenStream { mod __iris_android_app { use super::*; + #factory + extern "system" fn new_view_peer<'local>( env: ::iris::android::__private::JNIEnv<'local>, view: ::iris::android::__private::View<'local>, context: ::iris::android::__private::Context<'local>, ) -> ::iris::android::__private::JLong { - ::iris::android::new_peer::<#state>(env, view, context, super::#name) + ::iris::android::new_peer::<#state>(env, view, context, #create) } #[unsafe(no_mangle)] diff --git a/readme.md b/readme.md index 76a771b..4043cec 100644 --- a/readme.md +++ b/readme.md @@ -25,31 +25,48 @@ application-id = "com.example.myapp" label = "My app" ``` -`#[iris::app_init]` marks the factory called when Android creates the Iris +`#[iris::app_init]` marks the initializer called when Android creates the Iris view. The attribute supplies its own Android target gate and generates the JNI -loader glue. The returned state chooses its resources through -`AndroidAppState::Resources`; `StdRsc` is the standard bundle, not a -requirement. +loader glue. An application with no state beyond the UI state receives +`AndroidUiState` directly by mutable reference: ```rust use iris::prelude::*; -#[derive(AndroidUiState)] -struct State { - ui_state: AndroidUiState, -} - -impl AndroidAppState for State { - type Resources = StdRsc; -} - #[iris::app_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { - rect(PaintId::RED).set_root(rsc, &mut ui_state); - State { ui_state } +fn create( + ui_state: &mut AndroidUiState, + rsc: &mut StdRsc, +) { + rect(PaintId::RED).set_root(rsc, ui_state); } ``` +Desktop has the corresponding initializer form: + +```rust +DesktopApp::::run_with(|ui_state, rsc| { + rect(PaintId::RED).set_root(rsc, ui_state); +}); +``` + +Background work updates either host through the same task context. An update +wakes the UI thread; Iris schedules a frame automatically if the closure made +the retained widget tree dirty: + +```rust +rsc.spawn_task(async move |mut ctx| { + let text = load_text().await; + ctx.update(move |_, rsc| label.edit(rsc).set(&text)); +}); +``` + +Applications do not need a winit event proxy or an explicit redraw request. + +Applications with additional fields use their own state type. Its +`AndroidAppState::Resources` associated type can also replace `StdRsc` with a +custom resource bundle. + Install the Cargo subcommand from a checkout, then invoke it from the application's directory: diff --git a/src/android/render.rs b/src/android/render.rs index 6632df6..7dcda75 100644 --- a/src/android/render.rs +++ b/src/android/render.rs @@ -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; }; diff --git a/src/android/view.rs b/src/android/view.rs index f149ca9..75e4500 100644 --- a/src/android/view.rs +++ b/src/android/view.rs @@ -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; +} + /// 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: where State: 'static, { - fn new(redraw: Arc) -> (Self, TaskMsgReceiver); + fn new(wake: Arc) -> (Self, TaskMsgReceiver); } impl AndroidResources for StdRsc { - fn new(redraw: Arc) -> (Self, TaskMsgReceiver) { - StdRsc::new(redraw) + fn new(wake: Arc) -> (Self, TaskMsgReceiver) { + StdRsc::new(wake) } } @@ -223,8 +237,12 @@ impl IrisViewPeer { 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 ViewPeer for IrisViewPeer { 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 = Arc::new(AndroidRedrawHandle::new(vm, global_view)); - let (mut rsc, task_recv) = State::Resources::new(redraw); + let wake: Arc = 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); diff --git a/src/desktop/app.rs b/src/desktop/app.rs index 4d9bc9b..94c6e01 100644 --- a/src/desktop/app.rs +++ b/src/desktop/app.rs @@ -5,41 +5,40 @@ use winit::{ window::WindowId, }; -pub trait AppState { - type Event: 'static; - fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy) -> 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::::run(); - } } pub struct App { state: Option, - proxy: EventLoopProxy, + proxy: EventLoopProxy<()>, + init: Option>>, } -impl App { - pub fn run() { +type Init = dyn FnOnce(&ActiveEventLoop, EventLoopProxy<()>) -> State; + +impl App { + 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: None, proxy }) + .run_app(&mut App:: { + state: None, + proxy, + init: Some(Box::new(init)), + }) .unwrap(); } } -impl ApplicationHandler for App { +impl ApplicationHandler<()> for App { 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 ApplicationHandler for App { 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) { diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index de6b239..25b1e2b 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -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 = EventLoopProxy; +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, proxy: Proxy) -> Self; - #[allow(unused_variables)] - fn event(&mut self, event: Self::Event, rsc: &mut StdRsc) {} + fn new(ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self; #[allow(unused_variables)] fn exit(&mut self, rsc: &mut StdRsc) {} #[allow(unused_variables)] @@ -93,18 +104,33 @@ pub trait DesktopAppState: HasDesktopUiState { } } +impl DesktopAppState for DesktopUiState { + fn new(ui_state: DesktopUiState, _: &mut StdRsc) -> Self { + ui_state + } +} + pub struct DesktopApp { rsc: StdRsc, state: State, task_recv: TaskMsgReceiver>, } -impl AppState for DesktopApp { - type Event = State::Event; +impl DesktopApp { + pub fn run() { + App::::run_with(|event_loop, proxy| { + Self::new_with(event_loop, proxy, State::window_attributes(), State::new) + }); + } - fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy) -> Self { + fn new_with( + event_loop: &ActiveEventLoop, + proxy: EventLoopProxy<()>, + attributes: WindowAttributes, + init: impl FnOnce(DesktopUiState, &mut StdRsc) -> 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 AppState for DesktopApp { ); 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 { + /// Runs an application whose only state is Iris's desktop UI state. + pub fn run_with(init: impl FnOnce(&mut DesktopUiState, &mut StdRsc) + 'static) { + Self::run_with_attributes(DesktopUiState::window_attributes(), init); + } + + pub fn run_with_attributes( + attributes: WindowAttributes, + init: impl FnOnce(&mut DesktopUiState, &mut StdRsc) + 'static, + ) { + App::::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 AppState for DesktopApp { + 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) { diff --git a/src/desktop/render.rs b/src/desktop/render.rs index ad83302..4bab57f 100644 --- a/src/desktop/render.rs +++ b/src/desktop/render.rs @@ -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, surface: Surface<'static>, diff --git a/src/harness.rs b/src/harness.rs index a4f2937..0e54b3b 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -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); } } diff --git a/src/lib.rs b/src/lib.rs index b840090..24de0e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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)] diff --git a/src/rsc/mod.rs b/src/rsc/mod.rs index f6442ce..80d5117 100644 --- a/src/rsc/mod.rs +++ b/src/rsc/mod.rs @@ -33,8 +33,8 @@ pub struct StdRsc { } impl StdRsc { - pub(crate) fn new(redraw: Arc) -> (Self, TaskMsgReceiver) { - let (tasks, receiver) = Tasks::init(redraw); + pub(crate) fn new(wake: Arc) -> (Self, TaskMsgReceiver) { + let (tasks, receiver) = Tasks::init(wake); ( Self { ui: Ui::default(), diff --git a/src/rsc/task.rs b/src/rsc/task.rs index 1cd6a29..71b293f 100644 --- a/src/rsc/task.rs +++ b/src/rsc/task.rs @@ -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 = SyncSender>>; @@ -38,29 +36,41 @@ impl TaskUpdate pub struct Tasks { start: AsyncSender, - redraw: Arc, + wake: Arc, msg_send: SyncSender>>, } pub struct TaskCtx { send: TaskMsgSender, + wake: Arc, +} + +impl Clone for TaskCtx { + fn clone(&self) -> Self { + Self { + send: self.send.clone(), + wake: self.wake.clone(), + } + } } impl TaskCtx { pub fn update(&mut self, f: impl TaskUpdate + 'static) { - let _ = self.send.send(Box::new(f)); + if self.send.send(Box::new(f)).is_ok() { + self.wake.wake(); + } } } impl TaskCtx { - fn new(send: TaskMsgSender) -> Self { - Self { send } + fn new(send: TaskMsgSender, wake: Arc) -> Self { + Self { send, wake } } } type BoxTask = Pin + Send>>; impl Tasks { - pub fn init(redraw: Arc) -> (Self, TaskMsgReceiver) { + pub fn init(wake: Arc) -> (Self, TaskMsgReceiver) { let (start, start_recv) = async_channel(); let (msgs, msgs_recv) = sync_channel(); std::thread::spawn(|| { @@ -71,22 +81,16 @@ impl Tasks { 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 { - 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 { + TaskCtx::new(self.msg_send.clone(), self.wake.clone()) } pub fn spawn) + 'static + std::marker::Send>(&mut self, task: F) @@ -94,10 +98,9 @@ impl Tasks { 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) { 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::::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); + } +}