From cf53a9f86ddd524559678007b794e7eb01ad0610 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sun, 22 Mar 2026 02:30:11 -0400 Subject: [PATCH] Ported example 5 --- lib/reactivity/src/effect.rs | 34 +- lib/ruin_app/Cargo.toml | 4 + lib/ruin_app/example/05_async_runtime_io.rs | 639 ++++++++++++++++++++ lib/ui/src/runtime.rs | 68 +++ lib/ui_platform_wayland/src/lib.rs | 108 ++-- 5 files changed, 815 insertions(+), 38 deletions(-) create mode 100644 lib/ruin_app/example/05_async_runtime_io.rs diff --git a/lib/reactivity/src/effect.rs b/lib/reactivity/src/effect.rs index b2343c3..4e9ef53 100644 --- a/lib/reactivity/src/effect.rs +++ b/lib/reactivity/src/effect.rs @@ -154,7 +154,7 @@ mod tests { use std::cell::{Cell as Counter, RefCell}; use std::rc::Rc; - use ruin_runtime::{queue_task, run}; + use ruin_runtime::{queue_future, queue_task, run, yield_now}; use crate::{Reactor, cell_in}; @@ -213,4 +213,36 @@ mod tests { run(); assert_eq!(reruns.get(), 1); } + + #[test] + fn effects_rerun_after_async_future_updates_a_dependency() { + let seen = Rc::new(RefCell::new(Vec::new())); + let handle_slot = Rc::new(RefCell::new(None::)); + + queue_task({ + let seen = Rc::clone(&seen); + let handle_slot = Rc::clone(&handle_slot); + move || { + let reactor = Reactor::new(); + let source = cell_in(&reactor, 0usize); + let effect = reactor.effect({ + let seen = Rc::clone(&seen); + let source = source.clone(); + move || seen.borrow_mut().push(source.get()) + }); + *handle_slot.borrow_mut() = Some(effect); + + std::mem::drop(queue_future({ + let source = source.clone(); + async move { + yield_now().await; + let _ = source.set(1); + } + })); + } + }); + + run(); + assert_eq!(&*seen.borrow(), &[0, 1]); + } } diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 6126e17..7ec29af 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -33,3 +33,7 @@ path = "example/03_fine_grained_list.rs" [[example]] name = "04_composition_and_context" path = "example/04_composition_and_context.rs" + +[[example]] +name = "05_async_runtime_io" +path = "example/05_async_runtime_io.rs" diff --git a/lib/ruin_app/example/05_async_runtime_io.rs b/lib/ruin_app/example/05_async_runtime_io.rs new file mode 100644 index 0000000..17f1a01 --- /dev/null +++ b/lib/ruin_app/example/05_async_runtime_io.rs @@ -0,0 +1,639 @@ +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener as StdTcpListener}; +use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ruin_app::prelude::*; + +const SNAPSHOT_PATH: &str = "target/ruin-example05-manifest-snapshot.toml"; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + let demo_server_addr = spawn_demo_server()?; + App::new() + .window( + Window::new() + .title("RUIN Async Runtime I/O") + .app_id("dev.ruin.async-runtime-io") + .size(1280.0, 820.0), + ) + .mount(view! { + RuntimeIoExample(server_addr = demo_server_addr) {} + }) + .run() + .await +} + +#[component] +fn RuntimeIoExample(server_addr: SocketAddr) -> impl IntoView { + let manifest_path = use_signal(|| "Cargo.lock".to_string()); + let manifest_reload = use_signal(|| 0_u64); + let manifest_scroll = use_signal(|| 0.0_f32); + let endpoint = use_signal(|| DemoEndpoint::Release); + let endpoint_reload = use_signal(|| 0_u64); + let response_scroll = use_signal(|| 0.0_f32); + let save_in_flight = use_signal(|| false); + let save_status = use_signal(|| Some(format!("Snapshot target: {SNAPSHOT_PATH}"))); + + let manifest = use_resource({ + let manifest_path = manifest_path.clone(); + let manifest_reload = manifest_reload.clone(); + move || { + let path = manifest_path.get(); + let _ = manifest_reload.get(); + async move { + ruin_runtime::fs::read_to_string(&path) + .await + .map_err(|error| error.to_string()) + } + } + }); + + let response = use_resource({ + let endpoint = endpoint.clone(); + let endpoint_reload = endpoint_reload.clone(); + move || { + let route = endpoint.get(); + let _ = endpoint_reload.get(); + async move { fetch_demo_endpoint(server_addr, route).await } + } + }); + + use_effect({ + let manifest_path = manifest_path.clone(); + move || { + eprintln!( + "example05: manifest path changed -> {}", + manifest_path.get() + ); + } + }); + + use_effect({ + let endpoint = endpoint.clone(); + move || { + eprintln!( + "example05: demo endpoint changed -> {}", + endpoint.get().path() + ); + } + }); + + use_effect({ + let manifest_path = manifest_path.clone(); + let manifest_reload = manifest_reload.clone(); + let manifest_scroll = manifest_scroll.clone(); + move || { + let _ = manifest_path.get(); + let _ = manifest_reload.get(); + let _ = manifest_scroll.set(0.0); + } + }); + + use_effect({ + let endpoint = endpoint.clone(); + let endpoint_reload = endpoint_reload.clone(); + let response_scroll = response_scroll.clone(); + move || { + let _ = endpoint.get(); + let _ = endpoint_reload.get(); + let _ = response_scroll.set(0.0); + } + }); + + let manifest_state = manifest.read(); + let response_state = response.read(); + + view! { + row(gap = 20.0, padding = 20.0) { + block( + flex = 1.0, + padding = 18.0, + gap = 14.0, + background = surfaces::raised(), + border_radius = 16.0, + ) { + text(role = TextRole::Heading(1), size = 28.0, weight = FontWeight::Semibold) { + "Filesystem" + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "Real async file reads use `ruin_runtime::fs`, while the snapshot button writes \ + the current contents back through the runtime on a background future." + } + + row(gap = 8.0) { + button( + on_press = { + let manifest_path = manifest_path.clone(); + move |_| { + let _ = manifest_path.set("Cargo.toml".to_string()); + } + }, + ) { + "Cargo.toml" + } + + button( + on_press = { + let manifest_path = manifest_path.clone(); + move |_| { + let _ = manifest_path.set("Cargo.lock".to_string()); + } + }, + ) { + "Cargo.lock" + } + + button( + on_press = { + let manifest_path = manifest_path.clone(); + move |_| { + let _ = manifest_path.set("missing-file.toml".to_string()); + } + }, + ) { + "Missing file" + } + + button( + on_press = { + let manifest_reload = manifest_reload.clone(); + move |_| { + manifest_reload.update(|value| *value += 1); + } + }, + ) { + "Reload" + } + } + + text(size = 16.0, weight = FontWeight::Semibold) { + "Path: "; manifest_path.clone() + } + + match manifest_state { + Pending => view! { + block( + padding = 14.0, + background = surfaces::canvas(), + border_radius = 12.0, + ) { + text(color = colors::muted()) { "Reading file through the runtime..." } + } + }, + Ready(Ok(contents)) => view! { + column(gap = 12.0) { + row(gap = 8.0) { + button( + on_press = { + let manifest = manifest.clone(); + let save_in_flight = save_in_flight.clone(); + let save_status = save_status.clone(); + move |_| { + if save_in_flight.get() { + return; + } + + let current_manifest = match manifest.read() { + Ready(Ok(contents)) => contents, + Pending => { + let _ = save_status.set(Some( + "Manifest is still loading; try again in a moment." + .to_string(), + )); + return; + } + Ready(Err(error)) => { + let _ = save_status.set(Some(format!( + "Cannot snapshot the current manifest: {error}" + ))); + return; + } + }; + + let _ = save_in_flight.set(true); + let _ = save_status + .set(Some("Saving snapshot through ruin_runtime::fs...".to_string())); + std::mem::drop(ruin_runtime::queue_future({ + let save_in_flight = save_in_flight.clone(); + let save_status = save_status.clone(); + async move { + let result = ruin_runtime::fs::write( + SNAPSHOT_PATH, + current_manifest, + ) + .await; + let _ = save_in_flight.set(false); + let completed_at = timestamp_label(); + let message = match result { + Ok(()) => format!( + "Saved snapshot to {SNAPSHOT_PATH} at {completed_at}" + ), + Err(error) => { + format!( + "Snapshot write failed at {completed_at}: {error}" + ) + } + }; + eprintln!("example05: {message}"); + let _ = save_status.set(Some(message)); + } + })); + } + }, + ) { + if save_in_flight.get() { + "Saving snapshot..." + } else { + "Save snapshot" + } + } + } + + text(color = colors::muted()) { + "Loaded "; contents.len(); " bytes." + } + + if let Some(status) = save_status.get() { + view! { + text( + color = if status.contains("failed") { + colors::danger() + } else { + colors::muted() + }, + wrap = TextWrap::Word, + ) { + status + } + } + } else { + View::from_element(Element::column()) + } + + scroll_box( + height = 500.0, + offset_y = manifest_scroll.clone(), + padding = 12.0, + background = surfaces::canvas(), + border_radius = 12.0, + border = (2.0, colors::muted()), + ) { + text( + color = colors::muted(), + font_family = TextFontFamily::Monospace, + ) { + contents + } + } + } + }, + Ready(Err(error)) => view! { + block( + padding = 14.0, + gap = 8.0, + background = surfaces::canvas(), + border_radius = 12.0, + border = (2.0, colors::danger()), + ) { + text(size = 18.0, weight = FontWeight::Semibold, color = colors::danger()) { + "Failed to read file" + } + text(color = colors::muted(), wrap = TextWrap::Word) { error } + } + }, + } + } + + block( + flex = 1.0, + padding = 18.0, + gap = 14.0, + background = surfaces::raised(), + border_radius = 16.0, + ) { + text(role = TextRole::Heading(1), size = 28.0, weight = FontWeight::Semibold) { + "Network" + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "This panel talks to a tiny local HTTP server over `ruin_runtime::net::TcpStream`. \ + It keeps the example truthful without pretending TLS, a JSON client, or text \ + input already exist." + } + + row(gap = 8.0) { + button( + on_press = { + let endpoint = endpoint.clone(); + move |_| { + let _ = endpoint.set(DemoEndpoint::Release); + } + }, + ) { + "Release" + } + + button( + on_press = { + let endpoint = endpoint.clone(); + move |_| { + let _ = endpoint.set(DemoEndpoint::Health); + } + }, + ) { + "Health" + } + + button( + on_press = { + let endpoint = endpoint.clone(); + move |_| { + let _ = endpoint.set(DemoEndpoint::Notes); + } + }, + ) { + "Notes" + } + + button( + on_press = { + let endpoint = endpoint.clone(); + move |_| { + let _ = endpoint.set(DemoEndpoint::Missing); + } + }, + ) { + "404" + } + + button( + on_press = { + let endpoint_reload = endpoint_reload.clone(); + move |_| { + endpoint_reload.update(|value| *value += 1); + } + }, + ) { + "Reload" + } + } + + text(size = 16.0, weight = FontWeight::Semibold) { + "Endpoint: "; endpoint.get().path() + } + + match response_state { + Pending => view! { + block( + padding = 14.0, + background = surfaces::canvas(), + border_radius = 12.0, + ) { + text(color = colors::muted()) { + "Fetching a local HTTP response through the runtime..." + } + } + }, + Ready(Ok(response)) => view! { + column(gap = 12.0) { + text(size = 18.0, weight = FontWeight::Semibold) { + response.status_line.clone() + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "Received "; response.body.len(); " body bytes from "; + response.path.clone(); "." + } + + if let Some(content_type) = response.content_type.clone() { + view! { + text(color = colors::muted()) { + "content-type: "; content_type + } + } + } else { + View::from_element(Element::column()) + } + + scroll_box( + height = 500.0, + offset_y = response_scroll.clone(), + padding = 12.0, + background = surfaces::canvas(), + border_radius = 12.0, + border = (2.0, colors::muted()), + ) { + text( + color = colors::muted(), + font_family = TextFontFamily::Monospace, + wrap = TextWrap::Word, + ) { + response.body + } + } + } + }, + Ready(Err(error)) => view! { + block( + padding = 14.0, + gap = 8.0, + background = surfaces::canvas(), + border_radius = 12.0, + border = (2.0, colors::danger()), + ) { + text(size = 18.0, weight = FontWeight::Semibold, color = colors::danger()) { + "Request failed" + } + text(color = colors::muted(), wrap = TextWrap::Word) { error } + } + }, + } + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DemoEndpoint { + Release, + Health, + Notes, + Missing, +} + +impl DemoEndpoint { + fn path(self) -> &'static str { + match self { + Self::Release => "/release", + Self::Health => "/health", + Self::Notes => "/notes", + Self::Missing => "/missing", + } + } +} + +#[derive(Clone, Debug)] +struct DemoResponse { + path: String, + status_line: String, + content_type: Option, + body: String, +} + +fn spawn_demo_server() -> std::io::Result { + let listener = StdTcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; + thread::Builder::new() + .name("ruin-example05-demo-server".into()) + .spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { + break; + }; + let mut request = [0_u8; 4096]; + let read = match stream.read(&mut request) { + Ok(read) => read, + Err(_) => continue, + }; + let request = String::from_utf8_lossy(&request[..read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let (status, body) = demo_response_for_path(path); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + } + }) + .map_err(std::io::Error::other)?; + Ok(address) +} + +fn demo_response_for_path(path: &str) -> (&'static str, String) { + match path { + "/release" => ( + "200 OK", + "release = v0.1.0\npublished_at = 2026-03-22T00:00:00Z\nnotes = honest runtime IO demo backed by ruin_runtime".to_string(), + ), + "/health" => ( + "200 OK", + "status = ok\nreactor = draining microtasks\nio = idle".to_string(), + ), + "/notes" => ( + "200 OK", + "The network pane is intentionally local and plaintext.\nIt still exercises ruin_runtime::net::TcpStream end-to-end,\nbut it does not pretend that TLS, a JSON client, or text inputs are finished yet.".to_string(), + ), + _ => ( + "404 Not Found", + format!("No demo route is registered for {path}. Try /release, /health, or /notes."), + ), + } +} + +fn timestamp_label() -> String { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => { + let seconds = duration.as_secs(); + let millis = duration.subsec_millis(); + format!("{seconds}.{millis:03}s since unix epoch") + } + Err(_) => "time went backwards".to_string(), + } +} + +async fn fetch_demo_endpoint( + address: SocketAddr, + endpoint: DemoEndpoint, +) -> std::result::Result { + let path = endpoint.path().to_string(); + let mut stream = ruin_runtime::net::TcpStream::connect(address) + .await + .map_err(|error| error.to_string())?; + let request = format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .map_err(|error| error.to_string())?; + + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let read = stream + .read(&mut chunk) + .await + .map_err(|error| error.to_string())?; + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + } + + parse_http_response(&path, &bytes) +} + +fn parse_http_response(path: &str, bytes: &[u8]) -> std::result::Result { + let text = String::from_utf8(bytes.to_vec()).map_err(|error| error.to_string())?; + let Some((head, body)) = text.split_once("\r\n\r\n") else { + return Err("demo server returned a malformed HTTP response".to_string()); + }; + + let mut lines = head.lines(); + let status_line = lines + .next() + .ok_or_else(|| "demo server returned an empty status line".to_string())? + .to_string(); + let content_type = lines.find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-type") + .then(|| value.trim().to_string()) + }) + }); + + if !status_line.contains("200") { + return Err(format!("{status_line}\n\n{body}")); + } + + Ok(DemoResponse { + path: path.to_string(), + status_line, + content_type, + body: body.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::{DemoEndpoint, parse_http_response}; + + #[test] + fn parse_http_response_extracts_status_body_and_content_type() { + let parsed = parse_http_response( + DemoEndpoint::Release.path(), + b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nhello", + ) + .expect("response should parse"); + + assert_eq!(parsed.status_line, "HTTP/1.1 200 OK"); + assert_eq!(parsed.content_type.as_deref(), Some("text/plain")); + assert_eq!(parsed.body, "hello"); + } + + #[test] + fn parse_http_response_surfaces_non_success_statuses_as_errors() { + let error = parse_http_response( + DemoEndpoint::Missing.path(), + b"HTTP/1.1 404 Not Found\r\ncontent-type: text/plain\r\n\r\nmissing", + ) + .expect_err("non-200 responses should surface as errors"); + + assert!(error.contains("404 Not Found")); + assert!(error.contains("missing")); + } +} diff --git a/lib/ui/src/runtime.rs b/lib/ui/src/runtime.rs index 45e3e6f..b823eaf 100644 --- a/lib/ui/src/runtime.rs +++ b/lib/ui/src/runtime.rs @@ -186,6 +186,7 @@ mod tests { KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PointerEvent, PointerEventKind, }; + use ruin_reactivity::cell; use ruin_runtime::{current_thread_handle, queue_future, run}; use std::future::Future; @@ -387,4 +388,71 @@ mod tests { ui.shutdown().expect("shutdown should queue"); }); } + + #[test] + fn async_signal_updates_represent_scene_without_external_events() { + run_async_test(async move { + let mut ui = UiRuntime::headless(); + let window = ui + .create_window(WindowSpec::new("async-scene-effect").visible(true)) + .expect("window should be created"); + let scene_version = std::rc::Rc::new(std::cell::Cell::new(0_u64)); + let value = cell(0_u32); + let _scene_effect = window.attach_scene_effect({ + let scene_version = std::rc::Rc::clone(&scene_version); + let value = value.clone(); + move || { + let next_version = scene_version.get() + 1; + scene_version.set(next_version); + let mut scene = SceneSnapshot::new(next_version, UiSize::new(320.0, 180.0)); + scene.push_text(PreparedText::monospace( + format!("value={}", value.get()), + Point::new(16.0, 28.0), + 16.0, + 8.0, + Color::rgb(0xFF, 0xFF, 0xFF), + )); + scene + } + }); + + let _ = ui + .wait_for_event_matching(|event| { + matches!( + event, + PlatformEvent::FramePresented { + window_id, + scene_version: 1, + .. + } if *window_id == window.id() + ) + }) + .await + .expect("initial scene should present"); + + queue_future({ + let value = value.clone(); + async move { + ruin_runtime::yield_now().await; + let _ = value.set(1); + } + }); + + let _ = ui + .wait_for_event_matching(|event| { + matches!( + event, + PlatformEvent::FramePresented { + window_id, + scene_version: 2, + .. + } if *window_id == window.id() + ) + }) + .await + .expect("async signal update should re-present the scene"); + + ui.shutdown().expect("shutdown should queue"); + }); + } } diff --git a/lib/ui_platform_wayland/src/lib.rs b/lib/ui_platform_wayland/src/lib.rs index 7aaefe7..35b50e6 100644 --- a/lib/ui_platform_wayland/src/lib.rs +++ b/lib/ui_platform_wayland/src/lib.rs @@ -666,6 +666,11 @@ impl WaylandWindow { self.state.frame_callback = None; } + fn flush_connection(&mut self) -> Result<(), Box> { + self.state._connection.flush()?; + Ok(()) + } + pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box> { self.state._toplevel.set_title(spec.title.clone()); if let Some(app_id) = spec.app_id.as_ref() { @@ -1194,12 +1199,8 @@ fn pump_window_worker(state: Rc>) { let mut reschedule = true; { let mut state_ref = state.borrow_mut(); - if state_ref.shutdown_requested - || state_ref - .window - .wait_for_events(Duration::from_millis(16)) - .is_err() - { + let wait_result = state_ref.window.wait_for_events(Duration::from_millis(16)); + if state_ref.shutdown_requested || wait_result.is_err() { emit_window_closed(&mut state_ref, false); reschedule = false; } else { @@ -1261,6 +1262,7 @@ fn pump_window_worker(state: Rc>) { let scene = state_ref.latest_scene.clone(); if let Some(scene) = scene.as_ref() { if !state_ref.window.presentation_ready() { + state_ref.window.request_redraw(); // Wait for the compositor frame callback before attempting another present. } else if scene.logical_size != current_viewport { debug!( @@ -1278,20 +1280,37 @@ fn pump_window_worker(state: Rc>) { preview_scene.logical_size = current_viewport; state_ref.window.arm_frame_callback(); if state_ref.renderer.render(&preview_scene).is_ok() { - trace!( - target: "ruin_ui_platform_wayland::scene", - window_id = state_ref.window_id.raw(), - scene_version = scene.version, - width = current_viewport.width, - height = current_viewport.height, - "presented scaled preview scene for current viewport" - ); - let _ = state_ref.event_tx.send(PlatformEvent::FramePresented { - window_id: state_ref.window_id, - scene_version: scene.version, - item_count: scene.item_count(), - }); - finish_presented_viewport_request(&mut state_ref, scene.logical_size); + match state_ref.window.flush_connection() { + Ok(()) => { + trace!( + target: "ruin_ui_platform_wayland::scene", + window_id = state_ref.window_id.raw(), + scene_version = scene.version, + width = current_viewport.width, + height = current_viewport.height, + "presented scaled preview scene for current viewport" + ); + let _ = state_ref.event_tx.send(PlatformEvent::FramePresented { + window_id: state_ref.window_id, + scene_version: scene.version, + item_count: scene.item_count(), + }); + finish_presented_viewport_request( + &mut state_ref, + scene.logical_size, + ); + } + Err(error) => { + debug!( + target: "ruin_ui_platform_wayland::scene", + window_id = state_ref.window_id.raw(), + error = %error, + "failed to flush presented preview scene" + ); + state_ref.window.clear_frame_callback(); + state_ref.window.request_redraw(); + } + } } else { state_ref.window.clear_frame_callback(); } @@ -1299,23 +1318,38 @@ fn pump_window_worker(state: Rc>) { state_ref.window.arm_frame_callback(); match state_ref.renderer.render(scene) { Ok(()) => { - trace!( - target: "ruin_ui_platform_wayland::scene", - window_id = state_ref.window_id.raw(), - scene_version = scene.version, - width = scene.logical_size.width, - height = scene.logical_size.height, - "presented matching scene" - ); - finish_presented_viewport_request( - &mut state_ref, - scene.logical_size, - ); - let _ = state_ref.event_tx.send(PlatformEvent::FramePresented { - window_id: state_ref.window_id, - scene_version: scene.version, - item_count: scene.item_count(), - }); + match state_ref.window.flush_connection() { + Ok(()) => { + trace!( + target: "ruin_ui_platform_wayland::scene", + window_id = state_ref.window_id.raw(), + scene_version = scene.version, + width = scene.logical_size.width, + height = scene.logical_size.height, + "presented matching scene" + ); + finish_presented_viewport_request( + &mut state_ref, + scene.logical_size, + ); + let _ = + state_ref.event_tx.send(PlatformEvent::FramePresented { + window_id: state_ref.window_id, + scene_version: scene.version, + item_count: scene.item_count(), + }); + } + Err(error) => { + debug!( + target: "ruin_ui_platform_wayland::scene", + window_id = state_ref.window_id.raw(), + error = %error, + "failed to flush presented scene" + ); + state_ref.window.clear_frame_callback(); + state_ref.window.request_redraw(); + } + } } Err(RenderError::Lost | RenderError::Outdated) => { state_ref.window.clear_frame_callback();