Ported example 5
This commit is contained in:
@@ -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::<EffectHandle>));
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
639
lib/ruin_app/example/05_async_runtime_io.rs
Normal file
639
lib/ruin_app/example/05_async_runtime_io.rs
Normal file
@@ -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<String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn spawn_demo_server() -> std::io::Result<SocketAddr> {
|
||||
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<DemoResponse, String> {
|
||||
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<DemoResponse, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +666,11 @@ impl WaylandWindow {
|
||||
self.state.frame_callback = None;
|
||||
}
|
||||
|
||||
fn flush_connection(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
self.state._connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_spec(&mut self, spec: &WindowSpec) -> Result<(), Box<dyn Error>> {
|
||||
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<RefCell<WindowWorkerState>>) {
|
||||
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<RefCell<WindowWorkerState>>) {
|
||||
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,6 +1280,8 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
preview_scene.logical_size = current_viewport;
|
||||
state_ref.window.arm_frame_callback();
|
||||
if state_ref.renderer.render(&preview_scene).is_ok() {
|
||||
match state_ref.window.flush_connection() {
|
||||
Ok(()) => {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
window_id = state_ref.window_id.raw(),
|
||||
@@ -1291,13 +1295,30 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
scene_version: scene.version,
|
||||
item_count: scene.item_count(),
|
||||
});
|
||||
finish_presented_viewport_request(&mut state_ref, scene.logical_size);
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
state_ref.window.arm_frame_callback();
|
||||
match state_ref.renderer.render(scene) {
|
||||
Ok(()) => {
|
||||
match state_ref.window.flush_connection() {
|
||||
Ok(()) => {
|
||||
trace!(
|
||||
target: "ruin_ui_platform_wayland::scene",
|
||||
@@ -1311,12 +1332,25 @@ fn pump_window_worker(state: Rc<RefCell<WindowWorkerState>>) {
|
||||
&mut state_ref,
|
||||
scene.logical_size,
|
||||
);
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::FramePresented {
|
||||
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();
|
||||
state_ref.renderer.resize(frame.width, frame.height);
|
||||
|
||||
Reference in New Issue
Block a user