diff --git a/app/examples/phone.rs b/app/examples/phone.rs index a13f91a..f1c85fe 100644 --- a/app/examples/phone.rs +++ b/app/examples/phone.rs @@ -52,7 +52,7 @@ impl DesktopAppState for Client { )) } - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { + fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) { Ok(opened) => { if let Some(message) = message_argv() { @@ -60,7 +60,6 @@ impl DesktopAppState for Client { } if let Some(text) = typed_argv() { let field = opened.screen.composer.field; - let redraw = rsc.tasks.redraw_handle(); rsc.spawn_task(async move |mut ctx| { for ch in text.chars() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -73,7 +72,6 @@ impl DesktopAppState for Client { } edit.insert(&ch.to_string()); }); - redraw.request_redraw(); } }); } diff --git a/app/examples/transcript.rs b/app/examples/transcript.rs index 91801ae..1bfdf8b 100644 --- a/app/examples/transcript.rs +++ b/app/examples/transcript.rs @@ -158,7 +158,7 @@ fn fold_event(items: Vec, seq: u64) -> Vec { "; impl DesktopAppState for Client { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, _: Proxy) -> Self { + fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { let mut screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows()); screen.push_row( rsc, diff --git a/app/src/android/bench_client.rs b/app/src/android/bench_client.rs index 07be916..3d55003 100644 --- a/app/src/android/bench_client.rs +++ b/app/src/android/bench_client.rs @@ -235,13 +235,11 @@ impl AndroidAppState for BenchClient { if ime_visible && !self.keyboard_was_visible { self.keyboard_was_visible = true; - let redraw = rsc.tasks.redraw_handle(); rsc.spawn_task(async move |mut ctx| { tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await; ctx.update(|state: &mut BenchClient, rsc| { state.capture_keyboard_diagnostics(rsc); }); - redraw.request_redraw(); }); } else if !ime_visible { self.keyboard_was_visible = false; @@ -485,7 +483,6 @@ impl BenchClient { self.android_state_mut().frame_report.reset(); self.report_display.edit(rsc).set("Running benchmark..."); - let redraw = rsc.tasks.redraw_handle(); let platform = self.platform.clone(); let stream_tail = self.stream_tail.clone(); let ime_state = self.ime_state.clone(); @@ -518,9 +515,9 @@ impl BenchClient { }) }); - let travel = run_fling_phase(&mut ctx, &redraw).await; - let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await; - run_type_phase(&mut ctx, &redraw, &platform).await; + let travel = run_fling_phase(&mut ctx).await; + let (sent, total) = run_stream_phase(&mut ctx, stream_tail).await; + run_type_phase(&mut ctx, &platform).await; let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await; sampler_done.store(true, Ordering::Relaxed); @@ -626,32 +623,18 @@ impl BenchClient { state.report_display.edit(rsc).set(&report); state.last_report = Some(report); }); - redraw.request_redraw(); }); } } /// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the /// same `ctx.update` every other mutation here goes through) and returns -/// its result to the caller's async task -- `ctx.update` alone has no way -/// to hand a value back, since the closure only actually runs once the -/// next frame callback drains `IrisViewPeer`'s task channel -/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right -/// after enqueueing** -- `ctx.update` only ever pushes onto a channel; -/// nothing drains it until something schedules the frame callback that -/// calls `drain_tasks`, and a caller relying on some *earlier*, -/// already-in-flight `request_redraw()` to cover a *later* `ctx.update` -/// deadlocks the moment that earlier callback has already fired and -/// drained everything queued before this call existed. Cost a real hang -/// in this file's first version of the fling phase: every loop iteration -/// after the first sat forever with nothing scheduled to drain it. +/// its result to the caller's async task. `ctx.update` wakes the UI thread, +/// whose task callback drains Iris's update queue before checking whether the +/// retained widget tree needs another frame. /// Polls rather than assuming one `POLL_MS` sleep is enough, since a /// slow device's frame callback can lag further than that. -async fn read_from_state( - ctx: &mut iris::task::TaskCtx, - redraw: &Arc, - f: F, -) -> T +async fn read_from_state(ctx: &mut iris::task::TaskCtx, f: F) -> T where T: Send + 'static, F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static, @@ -660,7 +643,6 @@ where ctx.update(move |state: &mut BenchClient, rsc| { let _ = tx.send(f(state, rsc)); }); - redraw.request_redraw(); loop { if let Ok(value) = rx.try_recv() { return value; @@ -669,10 +651,7 @@ where } } -async fn run_fling_phase( - ctx: &mut iris::task::TaskCtx, - redraw: &Arc, -) -> String { +async fn run_fling_phase(ctx: &mut iris::task::TaskCtx) -> String { ctx.update(|state: &mut BenchClient, _rsc| { state.android_state_mut().frame_report.mark_phase("fling"); }); @@ -681,11 +660,10 @@ async fn run_fling_phase( (screen.list)(rsc).jump_to_end(); } }); - redraw.request_redraw(); // Lets the next frame's `repair_anchor` resolve `jump_to_end`'s // `anchor = None` into a real slot before `start` is read. tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await; - let start = read_anchor_position(ctx, redraw).await; + let start = read_anchor_position(ctx).await; for _ in 0..FLING_COUNT { ctx.update(|state: &mut BenchClient, rsc| { @@ -694,11 +672,10 @@ async fn run_fling_phase( animate_scroll(screen.list, rsc); } }); - redraw.request_redraw(); - wait_for_fling_settle(ctx, redraw).await; + wait_for_fling_settle(ctx).await; tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await; } - let outward = read_anchor_position(ctx, redraw).await; + let outward = read_anchor_position(ctx).await; for _ in 0..FLING_COUNT { ctx.update(|state: &mut BenchClient, rsc| { @@ -707,20 +684,16 @@ async fn run_fling_phase( animate_scroll(screen.list, rsc); } }); - redraw.request_redraw(); - wait_for_fling_settle(ctx, redraw).await; + wait_for_fling_settle(ctx).await; tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await; } - let end = read_anchor_position(ctx, redraw).await; + let end = read_anchor_position(ctx).await; format!("start={start} outward={outward} end={end} ticked=frame-loop") } -async fn read_anchor_position( - ctx: &mut iris::task::TaskCtx, - redraw: &Arc, -) -> String { - read_from_state(ctx, redraw, |state, rsc| match &state.screen { +async fn read_anchor_position(ctx: &mut iris::task::TaskCtx) -> String { + read_from_state(ctx, |state, rsc| match &state.screen { Some(screen) => (screen.list)(rsc).anchor_position_display(), None => "idx=none".to_string(), }) @@ -732,14 +705,11 @@ fn animate_scroll(scroll: iris::prelude::WeakWidget, rs rsc.ui_mut().animate(id); } -async fn wait_for_fling_settle( - ctx: &mut iris::task::TaskCtx, - redraw: &Arc, -) { +async fn wait_for_fling_settle(ctx: &mut iris::task::TaskCtx) { let cap = Duration::from_millis(FLING_SETTLE_CAP_MS); let started = Instant::now(); while started.elapsed() < cap { - let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen { + let still_scrolling = read_from_state(ctx, |state, rsc| match &state.screen { Some(screen) => (screen.list)(rsc).is_scrolling(), None => false, }) @@ -753,7 +723,6 @@ async fn wait_for_fling_settle( async fn run_stream_phase( ctx: &mut iris::task::TaskCtx, - redraw: &Arc, stream_tail: Vec, ) -> (usize, usize) { ctx.update(|state: &mut BenchClient, _rsc| { @@ -764,7 +733,6 @@ async fn run_stream_phase( (screen.list)(rsc).jump_to_end(); } }); - redraw.request_redraw(); let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize; let mut sent = 0usize; @@ -777,7 +745,6 @@ async fn run_stream_phase( None => state.rebuild_transcript(rsc), } }); - redraw.request_redraw(); sent += 1; tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await; } @@ -787,7 +754,6 @@ async fn run_stream_phase( async fn run_type_phase( ctx: &mut iris::task::TaskCtx, - redraw: &Arc, platform: &Option>, ) { ctx.update(|state: &mut BenchClient, _rsc| { @@ -799,7 +765,6 @@ async fn run_type_phase( state.set_focus(Some(screen.composer.field)); } }); - redraw.request_redraw(); if let Some(p) = platform { p.show_ime(); } @@ -814,7 +779,6 @@ async fn run_type_phase( screen.composer.field.edit(rsc).set(&text); } }); - redraw.request_redraw(); tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await; } tokio::time::sleep(Duration::from_millis(200)).await; @@ -826,7 +790,6 @@ async fn run_type_phase( screen.composer.field.edit(rsc).set(&text); } }); - redraw.request_redraw(); tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await; } } diff --git a/app/src/android/transcript_client.rs b/app/src/android/transcript_client.rs index 7e1e5a2..88e1cb2 100644 --- a/app/src/android/transcript_client.rs +++ b/app/src/android/transcript_client.rs @@ -132,7 +132,6 @@ impl TranscriptClient { } fn spawn_fetch_sessions(&mut self, rsc: &mut StdRsc) { - let redraw = rsc.tasks.redraw_handle(); let my_generation = self.generation.load(Ordering::SeqCst); let generation = self.generation.clone(); rsc.spawn_task(async move |mut ctx| { @@ -156,7 +155,6 @@ impl TranscriptClient { } } }); - redraw.request_redraw(); }); } @@ -166,7 +164,6 @@ impl TranscriptClient { self.session_id = Some(session_id.clone()); self.show_message(rsc, "Loading transcript..."); - let redraw = rsc.tasks.redraw_handle(); let live_generation = self.generation.clone(); rsc.spawn_task(async move |mut ctx| { let transports = @@ -180,7 +177,6 @@ impl TranscriptClient { state.show_message(rsc, &message); } }); - redraw.request_redraw(); return; } }; @@ -214,8 +210,6 @@ impl TranscriptClient { } }); } - redraw.request_redraw(); - if live_generation.load(Ordering::SeqCst) != my_generation { return; } @@ -239,7 +233,6 @@ impl TranscriptClient { } state.apply_event(rsc, &event); }); - redraw.request_redraw(); true } }, diff --git a/app/src/desktop/app.rs b/app/src/desktop/app.rs index 721c3ef..0fd5a3e 100644 --- a/app/src/desktop/app.rs +++ b/app/src/desktop/app.rs @@ -45,7 +45,7 @@ struct Client { ui_state: DesktopUiState, api: Arc>, stream_transport: Arc, - proxy: Proxy, + updates: TaskCtx>, sessions: Vec, selected: Option, items: Vec, @@ -56,9 +56,7 @@ struct Client { } impl DesktopAppState for Client { - type Event = AppEvent; - - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc, proxy: Proxy) -> Self { + fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| { eprintln!("desktop-app: {e}"); process::exit(2); @@ -90,7 +88,7 @@ impl DesktopAppState for Client { ui_state, api, stream_transport, - proxy, + updates: rsc.tasks.context(), sessions: Vec::new(), selected: None, items: Vec::new(), @@ -102,8 +100,10 @@ impl DesktopAppState for Client { client.spawn_fetch_sessions(); client } +} - fn event(&mut self, event: AppEvent, rsc: &mut StdRsc) { +impl Client { + fn apply_event(&mut self, event: AppEvent, rsc: &mut StdRsc) { match event { AppEvent::Sessions(Ok(sessions)) => { self.sessions = sessions; @@ -163,11 +163,8 @@ impl DesktopAppState for Client { eprintln!("desktop-app: couldn't send: {message}"); } } - self.ui_state.window.request_redraw(); } -} -impl Client { fn current(&self, session_id: &str, generation: u64) -> bool { self.selected.as_deref() == Some(session_id) && self.generation.load(Ordering::SeqCst) == generation @@ -180,10 +177,12 @@ impl Client { fn spawn_fetch_sessions(&self) { let api = self.api.clone(); - let proxy = self.proxy.clone(); + let mut updates = self.updates.clone(); thread::spawn(move || { let result = api.fetch_sessions().map_err(|e| e.to_string()); - let _ = proxy.send_event(AppEvent::Sessions(result)); + updates.update(move |state: &mut Client, rsc| { + state.apply_event(AppEvent::Sessions(result), rsc); + }); }); } @@ -211,7 +210,7 @@ impl Client { let api = self.api.clone(); let stream_transport = self.stream_transport.clone(); - let proxy = self.proxy.clone(); + let mut updates = self.updates.clone(); let live_generation = self.generation.clone(); thread::spawn(move || { let page: Result, String> = api @@ -223,10 +222,16 @@ impl Client { .and_then(|values| raw_seq(values.last()?)) .unwrap_or(0); let result = page.and_then(|values| fold_page(&values)); - let _ = proxy.send_event(AppEvent::TranscriptLoaded { - session_id: session_id.clone(), - generation, - result, + let loaded_session_id = session_id.clone(); + updates.update(move |state: &mut Client, rsc| { + state.apply_event( + AppEvent::TranscriptLoaded { + session_id: loaded_session_id, + generation, + result, + }, + rsc, + ); }); let stop = || live_generation.load(Ordering::SeqCst) != generation; @@ -240,28 +245,42 @@ impl Client { if stop() { return false; } - let _ = proxy.send_event(AppEvent::StreamEvent { - session_id: session_id.clone(), - generation, - event, + let event_session_id = session_id.clone(); + updates.update(move |state: &mut Client, rsc| { + state.apply_event( + AppEvent::StreamEvent { + session_id: event_session_id, + generation, + event, + }, + rsc, + ); }); true } }); - let _ = proxy.send_event(AppEvent::StreamEnded { - session_id, - generation, - message: outcome.err().map(|e| e.to_string()), + updates.update(move |state: &mut Client, rsc| { + state.apply_event( + AppEvent::StreamEnded { + session_id, + generation, + message: outcome.err().map(|e| e.to_string()), + }, + rsc, + ); }); }); } fn send_message(&mut self, session_id: String, text: String) { let api = self.api.clone(); - let proxy = self.proxy.clone(); + let mut updates = self.updates.clone(); thread::spawn(move || { if let Err(e) = api.send_message(&session_id, &text, &[]) { - let _ = proxy.send_event(AppEvent::SendFailed(e.to_string())); + let message = e.to_string(); + updates.update(move |state: &mut Client, rsc| { + state.apply_event(AppEvent::SendFailed(message), rsc); + }); } }); } diff --git a/docs/PLAN.md b/docs/PLAN.md index ef490a2..d8b1069 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -794,13 +794,24 @@ ssh case are one implementation. **An Iris Android application supplies an initialization function and its own state/resources; Iris supplies the JNI host and APK packager.** -`#[iris::app_init]` marks the factory Android calls when it creates the Iris -view. The macro target-gates the factory and generates the single exported +`#[iris::app_init]` marks the initializer Android calls when it creates the Iris +view. The macro target-gates the initializer and generates the single exported `JNI_OnLoad` plus the concrete `android-view` registration callback. The -function returns the application state, whose `AndroidAppState::Resources` +simple form mutates `AndroidUiState` through a reference. An application with +custom state returns that state instead, and its `AndroidAppState::Resources` associated type selects the resource bundle. `StdRsc` is the default, never a host requirement; a custom bundle works by implementing the narrow `AndroidResources` capabilities. +`AndroidUiState` and `DesktopUiState` implement their own host-state traits, +so an application with no additional fields uses them directly. Desktop takes +the widget-building initializer through `DesktopApp::run_with`; custom state +types retain the lifecycle trait hooks and derive their host-state accessors. +Background work crosses back to either UI thread through `TaskCtx::update`. +Each update wakes the host, Iris applies all queued closures, and the retained +tree's existing dirty-state check requests a frame only when a root, layout, or +widget changed. Winit's user-event proxy and Android's posted callback are +private implementations of that wake; applications do not define platform +event types or manually request redraws. `cargo-iris` is an installable Cargo subcommand, rather than a script callers must find inside an Iris checkout. `cargo iris apk` builds the Rust `cdylib` diff --git a/docs/SCROLL.md b/docs/SCROLL.md index 3aa2c3c..37a8f2a 100644 --- a/docs/SCROLL.md +++ b/docs/SCROLL.md @@ -27,7 +27,7 @@ Two widgets have one, and they differ only in how they spend a delta: `.scrollable()` registers the same two senses against the controller it already has. -Do not give a widget its own fling, scroll amount, or `RequestRedraw` +Do not give a widget its own fling, scroll amount, or platform wake handle, and do not add scrolling methods to the general `Widget` trait. ## One convention for a delta diff --git a/iris/cargo-iris/tests/fixture/src/lib.rs b/iris/cargo-iris/tests/fixture/src/lib.rs index 5706b7a..bd0e0a0 100644 --- a/iris/cargo-iris/tests/fixture/src/lib.rs +++ b/iris/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/iris/examples/bench_images/android.rs b/iris/examples/bench_images/android.rs index fd0c88b..cfccdad 100644 --- a/iris/examples/bench_images/android.rs +++ b/iris/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/iris/examples/bench_images/desktop.rs b/iris/examples/bench_images/desktop.rs index 57fcfc6..e139028 100644 --- a/iris/examples/bench_images/desktop.rs +++ b/iris/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/iris/examples/message_list/android.rs b/iris/examples/message_list/android.rs index 63b7e7b..773a3ac 100644 --- a/iris/examples/message_list/android.rs +++ b/iris/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/iris/examples/message_list/desktop.rs b/iris/examples/message_list/desktop.rs index 4657509..e84ff8b 100644 --- a/iris/examples/message_list/desktop.rs +++ b/iris/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/iris/examples/minimal/android.rs b/iris/examples/minimal/android.rs index 63b7e7b..773a3ac 100644 --- a/iris/examples/minimal/android.rs +++ b/iris/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/iris/examples/minimal/desktop.rs b/iris/examples/minimal/desktop.rs index 2a2dd10..d7d0199 100644 --- a/iris/examples/minimal/desktop.rs +++ b/iris/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/iris/examples/tabs/android.rs b/iris/examples/tabs/android.rs index 39a839e..c5a46e5 100644 --- a/iris/examples/tabs/android.rs +++ b/iris/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/iris/examples/tabs/desktop.rs b/iris/examples/tabs/desktop.rs index 8379d6f..be2b18c 100644 --- a/iris/examples/tabs/desktop.rs +++ b/iris/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/iris/examples/task/android.rs b/iris/examples/task/android.rs index 63b7e7b..773a3ac 100644 --- a/iris/examples/task/android.rs +++ b/iris/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/iris/examples/task/desktop.rs b/iris/examples/task/desktop.rs index 2a2dd10..d7d0199 100644 --- a/iris/examples/task/desktop.rs +++ b/iris/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/iris/examples/task/lib.rs b/iris/examples/task/lib.rs index d4dbe0a..1942f8d 100644 --- a/iris/examples/task/lib.rs +++ b/iris/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/iris/examples/view/android.rs b/iris/examples/view/android.rs index 63b7e7b..773a3ac 100644 --- a/iris/examples/view/android.rs +++ b/iris/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/iris/examples/view/desktop.rs b/iris/examples/view/desktop.rs index 2a2dd10..d7d0199 100644 --- a/iris/examples/view/desktop.rs +++ b/iris/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/iris/macro/src/lib.rs b/iris/macro/src/lib.rs index 3810791..c06fa6c 100644 --- a/iris/macro/src/lib.rs +++ b/iris/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/iris/readme.md b/iris/readme.md index 76a771b..4043cec 100644 --- a/iris/readme.md +++ b/iris/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/iris/src/android/render.rs b/iris/src/android/render.rs index 6632df6..7dcda75 100644 --- a/iris/src/android/render.rs +++ b/iris/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/iris/src/android/view.rs b/iris/src/android/view.rs index f149ca9..75e4500 100644 --- a/iris/src/android/view.rs +++ b/iris/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/iris/src/desktop/app.rs b/iris/src/desktop/app.rs index 4d9bc9b..94c6e01 100644 --- a/iris/src/desktop/app.rs +++ b/iris/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/iris/src/desktop/mod.rs b/iris/src/desktop/mod.rs index de6b239..25b1e2b 100644 --- a/iris/src/desktop/mod.rs +++ b/iris/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/iris/src/desktop/render.rs b/iris/src/desktop/render.rs index ad83302..4bab57f 100644 --- a/iris/src/desktop/render.rs +++ b/iris/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/iris/src/harness.rs b/iris/src/harness.rs index a4f2937..0e54b3b 100644 --- a/iris/src/harness.rs +++ b/iris/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/iris/src/lib.rs b/iris/src/lib.rs index b840090..24de0e1 100644 --- a/iris/src/lib.rs +++ b/iris/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/iris/src/rsc/mod.rs b/iris/src/rsc/mod.rs index f6442ce..80d5117 100644 --- a/iris/src/rsc/mod.rs +++ b/iris/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/iris/src/rsc/task.rs b/iris/src/rsc/task.rs index 1cd6a29..71b293f 100644 --- a/iris/src/rsc/task.rs +++ b/iris/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); + } +}