Early UI work.
This commit is contained in:
647
lib/ui/src/platform.rs
Normal file
647
lib/ui/src/platform.rs
Normal file
@@ -0,0 +1,647 @@
|
||||
//! Headless platform/render worker for the explicit-construction prototype.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use ruin_runtime::channel::mpsc;
|
||||
use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::scene::{SceneSnapshot, UiSize};
|
||||
use crate::trace_targets;
|
||||
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlatformProxy {
|
||||
command_tx: mpsc::UnboundedSender<PlatformCommand>,
|
||||
next_window_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// Low-level platform/runtime host for the explicit prototype.
|
||||
///
|
||||
/// The headless backend currently co-locates logical platform and rendering work on one runtime
|
||||
/// worker thread, but the event/snapshot API is intentionally shaped so future backends can split
|
||||
/// them if needed.
|
||||
pub struct PlatformRuntime {
|
||||
proxy: PlatformProxy,
|
||||
events: mpsc::Receiver<PlatformEvent>,
|
||||
_worker: WorkerHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
Opened {
|
||||
window_id: WindowId,
|
||||
},
|
||||
Closed {
|
||||
window_id: WindowId,
|
||||
},
|
||||
VisibilityChanged {
|
||||
window_id: WindowId,
|
||||
visible: bool,
|
||||
},
|
||||
Configured {
|
||||
window_id: WindowId,
|
||||
configuration: WindowConfigured,
|
||||
},
|
||||
FramePresented {
|
||||
window_id: WindowId,
|
||||
scene_version: u64,
|
||||
item_count: usize,
|
||||
},
|
||||
CloseRequested {
|
||||
window_id: WindowId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PlatformClosed;
|
||||
|
||||
impl fmt::Display for PlatformClosed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("platform worker is closed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlatformClosed {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PlatformCommand {
|
||||
CreateWindow {
|
||||
window_id: WindowId,
|
||||
spec: WindowSpec,
|
||||
},
|
||||
UpdateWindow {
|
||||
window_id: WindowId,
|
||||
update: WindowUpdate,
|
||||
},
|
||||
ReplaceScene {
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
},
|
||||
EmitCloseRequested {
|
||||
window_id: WindowId,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HeadlessState {
|
||||
events: mpsc::UnboundedSender<PlatformEvent>,
|
||||
windows: BTreeMap<WindowId, HeadlessWindow>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HeadlessWindow {
|
||||
spec: WindowSpec,
|
||||
lifecycle: WindowLifecycle,
|
||||
configuration: WindowConfigured,
|
||||
pending_scene: Option<SceneSnapshot>,
|
||||
render_scheduled: bool,
|
||||
}
|
||||
|
||||
impl HeadlessWindow {
|
||||
fn new(spec: WindowSpec) -> Self {
|
||||
let actual_inner_size = spec
|
||||
.requested_inner_size
|
||||
.unwrap_or_else(|| UiSize::new(800.0, 600.0));
|
||||
Self {
|
||||
spec,
|
||||
lifecycle: WindowLifecycle::LogicalClosed,
|
||||
configuration: WindowConfigured {
|
||||
actual_inner_size,
|
||||
scale_factor: 1.0,
|
||||
visible: false,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
},
|
||||
pending_scene: None,
|
||||
render_scheduled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformRuntime {
|
||||
pub fn headless() -> Self {
|
||||
start_headless()
|
||||
}
|
||||
|
||||
pub fn proxy(&self) -> PlatformProxy {
|
||||
self.proxy.clone()
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<PlatformEvent> {
|
||||
self.events.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformProxy {
|
||||
pub fn create_window(&self, spec: WindowSpec) -> Result<WindowId, PlatformClosed> {
|
||||
let window_id = WindowId::from_raw(self.next_window_id.fetch_add(1, Ordering::Relaxed));
|
||||
self.send(PlatformCommand::CreateWindow { window_id, spec })?;
|
||||
Ok(window_id)
|
||||
}
|
||||
|
||||
pub fn update_window(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
update: WindowUpdate,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::UpdateWindow { window_id, update })
|
||||
}
|
||||
|
||||
pub fn replace_scene(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::ReplaceScene { window_id, scene })
|
||||
}
|
||||
|
||||
pub fn emit_close_requested(&self, window_id: WindowId) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::EmitCloseRequested { window_id })
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::Shutdown)
|
||||
}
|
||||
|
||||
fn send(&self, command: PlatformCommand) -> Result<(), PlatformClosed> {
|
||||
self.command_tx.send(command).map_err(|_| PlatformClosed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the headless prototype backend.
|
||||
///
|
||||
/// Policy note: when a window becomes visible again, the backend may re-present the latest retained
|
||||
/// scene for that window even if the scene version itself did not change. Visibility restoration is
|
||||
/// treated as a presentation-affecting state change.
|
||||
pub fn start_headless() -> PlatformRuntime {
|
||||
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<PlatformCommand>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
|
||||
|
||||
let worker = spawn_worker(
|
||||
move || {
|
||||
let state = Rc::new(RefCell::new(HeadlessState {
|
||||
events: event_tx.clone(),
|
||||
windows: BTreeMap::new(),
|
||||
}));
|
||||
|
||||
queue_future(async move {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_started",
|
||||
backend = "headless",
|
||||
"starting headless platform worker"
|
||||
);
|
||||
|
||||
while let Some(command) = command_rx.recv().await {
|
||||
match command {
|
||||
PlatformCommand::CreateWindow { window_id, spec } => {
|
||||
handle_create_window(&state, window_id, spec);
|
||||
}
|
||||
PlatformCommand::UpdateWindow { window_id, update } => {
|
||||
handle_update_window(&state, window_id, update);
|
||||
}
|
||||
PlatformCommand::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformCommand::EmitCloseRequested { window_id } => {
|
||||
let sender = state.borrow().events.clone();
|
||||
let _ = sender.send(PlatformEvent::CloseRequested { window_id });
|
||||
}
|
||||
PlatformCommand::Shutdown => {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_shutdown_requested",
|
||||
"shutting down headless platform worker"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|| {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_exited",
|
||||
backend = "headless",
|
||||
"headless platform worker exited"
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PlatformRuntime {
|
||||
proxy: PlatformProxy {
|
||||
command_tx,
|
||||
next_window_id: Arc::new(AtomicU64::new(1)),
|
||||
},
|
||||
events: event_rx,
|
||||
_worker: worker,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_create_window(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId, spec: WindowSpec) {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "create_window",
|
||||
window_id = window_id.raw(),
|
||||
title = spec.title.as_str(),
|
||||
open = spec.open,
|
||||
visible = spec.visible,
|
||||
"creating logical window"
|
||||
);
|
||||
|
||||
state
|
||||
.borrow_mut()
|
||||
.windows
|
||||
.insert(window_id, HeadlessWindow::new(spec.clone()));
|
||||
|
||||
if spec.open {
|
||||
begin_open_transition(Rc::clone(state), window_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_update_window(
|
||||
state: &Rc<RefCell<HeadlessState>>,
|
||||
window_id: WindowId,
|
||||
update: WindowUpdate,
|
||||
) {
|
||||
let mut action = UpdateAction::None;
|
||||
{
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
let was_open = window.spec.open;
|
||||
let was_visible = window.spec.visible;
|
||||
update.apply_to(&mut window.spec);
|
||||
|
||||
if let Some(requested_inner_size) = window.spec.requested_inner_size {
|
||||
window.configuration.actual_inner_size = requested_inner_size;
|
||||
}
|
||||
window.configuration.maximized = window.spec.maximized;
|
||||
window.configuration.fullscreen = window.spec.fullscreen;
|
||||
|
||||
match (was_open, window.spec.open) {
|
||||
(false, true) => action = UpdateAction::Open,
|
||||
(true, false) => action = UpdateAction::Close,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if was_open == window.spec.open && was_visible != window.spec.visible {
|
||||
action = UpdateAction::Visibility(window.spec.visible);
|
||||
}
|
||||
}
|
||||
|
||||
match action {
|
||||
UpdateAction::None => {
|
||||
maybe_schedule_render(Rc::clone(state), window_id);
|
||||
}
|
||||
UpdateAction::Open => begin_open_transition(Rc::clone(state), window_id),
|
||||
UpdateAction::Close => close_window(state, window_id),
|
||||
UpdateAction::Visibility(visible) => set_visibility(state, window_id, visible),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_replace_scene(
|
||||
state: &Rc<RefCell<HeadlessState>>,
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
) {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::trace!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "replace_scene",
|
||||
window_id = window_id.raw(),
|
||||
version = scene.version,
|
||||
items = scene.item_count(),
|
||||
"received scene snapshot"
|
||||
);
|
||||
|
||||
if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) {
|
||||
window.pending_scene = Some(scene);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
maybe_schedule_render(Rc::clone(state), window_id);
|
||||
}
|
||||
|
||||
fn maybe_schedule_render(state: Rc<RefCell<HeadlessState>>, window_id: WindowId) {
|
||||
let should_schedule = {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
if window.render_scheduled {
|
||||
false
|
||||
} else {
|
||||
window.render_scheduled = true;
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if should_schedule {
|
||||
queue_microtask(move || present_latest_scene(&state, window_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_open_transition(state: Rc<RefCell<HeadlessState>>, window_id: WindowId) {
|
||||
{
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
window.lifecycle = WindowLifecycle::Opening;
|
||||
}
|
||||
|
||||
queue_microtask(move || {
|
||||
{
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
window.lifecycle = WindowLifecycle::AwaitingInitialConfigure;
|
||||
window.configuration.visible = window.spec.visible;
|
||||
window.configuration.maximized = window.spec.maximized;
|
||||
window.configuration.fullscreen = window.spec.fullscreen;
|
||||
window.lifecycle = if window.spec.visible {
|
||||
WindowLifecycle::OpenVisible
|
||||
} else {
|
||||
WindowLifecycle::OpenHidden
|
||||
};
|
||||
}
|
||||
|
||||
emit_event(
|
||||
&state,
|
||||
PlatformEvent::Configured {
|
||||
window_id,
|
||||
configuration: state
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.expect("window should exist during configure")
|
||||
.configuration,
|
||||
},
|
||||
);
|
||||
emit_event(&state, PlatformEvent::Opened { window_id });
|
||||
maybe_schedule_render(state, window_id);
|
||||
});
|
||||
}
|
||||
|
||||
fn close_window(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId) {
|
||||
let should_emit = {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
if window.lifecycle == WindowLifecycle::LogicalClosed {
|
||||
false
|
||||
} else {
|
||||
window.lifecycle = WindowLifecycle::Closing;
|
||||
window.render_scheduled = false;
|
||||
window.configuration.visible = false;
|
||||
window.lifecycle = WindowLifecycle::LogicalClosed;
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if should_emit {
|
||||
emit_event(state, PlatformEvent::Closed { window_id });
|
||||
}
|
||||
}
|
||||
|
||||
fn set_visibility(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId, visible: bool) {
|
||||
let should_emit = {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
if !window.spec.open {
|
||||
false
|
||||
} else {
|
||||
window.configuration.visible = visible;
|
||||
window.lifecycle = if visible {
|
||||
WindowLifecycle::OpenVisible
|
||||
} else {
|
||||
WindowLifecycle::OpenHidden
|
||||
};
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if should_emit {
|
||||
emit_event(
|
||||
state,
|
||||
PlatformEvent::VisibilityChanged { window_id, visible },
|
||||
);
|
||||
maybe_schedule_render(Rc::clone(state), window_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn present_latest_scene(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId) {
|
||||
let presentation = {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
let Some(window) = state_ref.windows.get_mut(&window_id) else {
|
||||
return;
|
||||
};
|
||||
window.render_scheduled = false;
|
||||
if window.lifecycle != WindowLifecycle::OpenVisible {
|
||||
return;
|
||||
}
|
||||
let Some(scene) = window.pending_scene.clone() else {
|
||||
return;
|
||||
};
|
||||
Some((scene.version, scene.item_count(), window.spec.title.clone()))
|
||||
};
|
||||
|
||||
let Some((scene_version, item_count, title)) = presentation else {
|
||||
return;
|
||||
};
|
||||
|
||||
info!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "present_scene",
|
||||
backend = "headless",
|
||||
window_id = window_id.raw(),
|
||||
title = title.as_str(),
|
||||
scene_version,
|
||||
item_count,
|
||||
"presenting latest scene snapshot"
|
||||
);
|
||||
emit_event(
|
||||
state,
|
||||
PlatformEvent::FramePresented {
|
||||
window_id,
|
||||
scene_version,
|
||||
item_count,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_event(state: &Rc<RefCell<HeadlessState>>, event: PlatformEvent) {
|
||||
let sender = state.borrow().events.clone();
|
||||
let _ = sender.send(event);
|
||||
}
|
||||
|
||||
enum UpdateAction {
|
||||
None,
|
||||
Open,
|
||||
Close,
|
||||
Visibility(bool),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PlatformEvent, start_headless};
|
||||
use crate::scene::{Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use crate::window::{WindowSpec, WindowUpdate};
|
||||
use ruin_runtime::{current_thread_handle, queue_future, run};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn headless_platform_coalesces_latest_scene_per_window() {
|
||||
let observed = Arc::new(Mutex::new(Vec::<PlatformEvent>::new()));
|
||||
let done = Arc::new(Mutex::new(false));
|
||||
let main = current_thread_handle();
|
||||
|
||||
queue_future({
|
||||
let observed = Arc::clone(&observed);
|
||||
let done = Arc::clone(&done);
|
||||
async move {
|
||||
let mut platform = start_headless();
|
||||
let proxy = platform.proxy();
|
||||
let window_id = proxy
|
||||
.create_window(
|
||||
WindowSpec::new("coalesce").requested_inner_size(UiSize::new(320.0, 180.0)),
|
||||
)
|
||||
.expect("window should be created");
|
||||
|
||||
let mut one = SceneSnapshot::new(1, UiSize::new(320.0, 180.0));
|
||||
one.push_quad(
|
||||
Rect::new(0.0, 0.0, 320.0, 180.0),
|
||||
Color::rgb(0x22, 0x22, 0x33),
|
||||
);
|
||||
let mut two = SceneSnapshot::new(2, UiSize::new(320.0, 180.0));
|
||||
two.push_quad(
|
||||
Rect::new(0.0, 0.0, 320.0, 180.0),
|
||||
Color::rgb(0x33, 0x22, 0x22),
|
||||
);
|
||||
let mut three = SceneSnapshot::new(3, UiSize::new(320.0, 180.0));
|
||||
three
|
||||
.push_quad(
|
||||
Rect::new(0.0, 0.0, 320.0, 180.0),
|
||||
Color::rgb(0x22, 0x33, 0x22),
|
||||
)
|
||||
.push_text(PreparedText::monospace(
|
||||
"v3",
|
||||
Point::new(16.0, 28.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
));
|
||||
|
||||
proxy
|
||||
.replace_scene(window_id, one)
|
||||
.expect("scene v1 should queue");
|
||||
proxy
|
||||
.replace_scene(window_id, two)
|
||||
.expect("scene v2 should queue");
|
||||
proxy
|
||||
.replace_scene(window_id, three)
|
||||
.expect("scene v3 should queue");
|
||||
|
||||
while let Some(event) = platform.next_event().await {
|
||||
observed.lock().unwrap().push(event.clone());
|
||||
if let PlatformEvent::FramePresented {
|
||||
window_id: presented,
|
||||
scene_version,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
assert_eq!(presented, window_id);
|
||||
assert_eq!(scene_version, 3);
|
||||
proxy.shutdown().expect("shutdown should queue");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while platform.next_event().await.is_some() {}
|
||||
*done.lock().unwrap() = true;
|
||||
let _ = main.queue_task(|| {});
|
||||
}
|
||||
});
|
||||
|
||||
run();
|
||||
assert!(*done.lock().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_false_closes_and_reopen_reconfigures() {
|
||||
let counts = Arc::new(Mutex::new((0usize, 0usize, 0usize)));
|
||||
let done = Arc::new(Mutex::new(false));
|
||||
let main = current_thread_handle();
|
||||
|
||||
queue_future({
|
||||
let counts = Arc::clone(&counts);
|
||||
let done = Arc::clone(&done);
|
||||
async move {
|
||||
let mut platform = start_headless();
|
||||
let proxy = platform.proxy();
|
||||
let window_id = proxy
|
||||
.create_window(
|
||||
WindowSpec::new("reopen").requested_inner_size(UiSize::new(640.0, 360.0)),
|
||||
)
|
||||
.expect("window should be created");
|
||||
|
||||
let mut saw_initial_open = false;
|
||||
while let Some(event) = platform.next_event().await {
|
||||
match event {
|
||||
PlatformEvent::Configured { window_id: id, .. } if id == window_id => {
|
||||
counts.lock().unwrap().0 += 1;
|
||||
}
|
||||
PlatformEvent::Opened { window_id: id } if id == window_id => {
|
||||
counts.lock().unwrap().1 += 1;
|
||||
if !saw_initial_open {
|
||||
saw_initial_open = true;
|
||||
proxy
|
||||
.update_window(window_id, WindowUpdate::new().open(false))
|
||||
.expect("close should queue");
|
||||
proxy
|
||||
.update_window(window_id, WindowUpdate::new().open(true))
|
||||
.expect("reopen should queue");
|
||||
}
|
||||
}
|
||||
PlatformEvent::Closed { window_id: id } if id == window_id => {
|
||||
counts.lock().unwrap().2 += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let (configured, opened, closed) = *counts.lock().unwrap();
|
||||
if configured >= 2 && opened >= 2 && closed >= 1 {
|
||||
proxy.shutdown().expect("shutdown should queue");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while platform.next_event().await.is_some() {}
|
||||
*done.lock().unwrap() = true;
|
||||
let _ = main.queue_task(|| {});
|
||||
}
|
||||
});
|
||||
|
||||
run();
|
||||
let (configured, opened, closed) = *counts.lock().unwrap();
|
||||
assert_eq!(configured, 2);
|
||||
assert_eq!(opened, 2);
|
||||
assert_eq!(closed, 1);
|
||||
assert!(*done.lock().unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user