From bfda24ad0a64708c9c184f00f5ec4e184d1ffad1 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Fri, 15 May 2026 16:28:06 -0700 Subject: [PATCH] Cleanup --- lib/ruin_app/example/05_async_runtime_io.rs | 1 - lib/ruin_app/src/lib.rs | 36 +- .../src/platform/linux_x86_64/runtime.rs | 18 +- .../src/platform/macos_aarch64/driver.rs | 156 +++++++- .../src/platform/macos_aarch64/runtime.rs | 23 +- lib/runtime/src/platform/mod.rs | 2 + lib/runtime/src/platform/runtime_shared.rs | 17 + lib/runtime/src/sys/macos/fd.rs | 64 +-- lib/runtime/src/sys/macos/fs.rs | 84 +++- lib/runtime/src/sys/macos/net.rs | 288 ++++++++------ lib/ui/src/layout.rs | 34 ++ lib/ui_platform_macos/src/lib.rs | 370 ++++++++++-------- lib/ui_renderer_wgpu/src/lib.rs | 52 ++- 13 files changed, 782 insertions(+), 363 deletions(-) create mode 100644 lib/runtime/src/platform/runtime_shared.rs diff --git a/lib/ruin_app/example/05_async_runtime_io.rs b/lib/ruin_app/example/05_async_runtime_io.rs index ab3a465..1cba44e 100644 --- a/lib/ruin_app/example/05_async_runtime_io.rs +++ b/lib/ruin_app/example/05_async_runtime_io.rs @@ -268,7 +268,6 @@ fn RuntimeIoExample(server_addr: SocketAddr) -> impl IntoView { }; eprintln!("example05: {message}"); let _ = save_status.set(Some(message)); - flush_reactive_updates(); } })); } diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 843b316..a296fca 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -1363,10 +1363,6 @@ pub fn use_effect(effect: impl Fn() + 'static) { with_hook_slot(|| ruin_reactivity::effect(effect), |_| ()); } -pub fn flush_reactive_updates() { - current_reactor().flush_now(); -} - pub fn use_window_title(compute: impl FnOnce() -> String) { with_render_context_state(|context| { context.side_effects.borrow_mut().window_title = Some(compute()); @@ -1580,7 +1576,6 @@ where let result = future.await; if generation.get() == next_generation { let _ = resource.state.replace(ResourceState::Ready(result)); - current_reactor().flush_now(); } })); } @@ -2058,10 +2053,10 @@ pub mod prelude { ContextKey, FocusScope, FontWeight, IntoBorder, IntoEdges, IntoView, Key, Memo, Mountable, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, - WidgetRef, Window, block, button, colors, column, component, context_provider, - flush_reactive_updates, provide, row, scroll_box, surfaces, text, use_context, use_effect, - use_memo, use_resource, use_shortcut, use_shortcut_with_context, use_signal, - use_widget_ref, use_window_title, view, + WidgetRef, Window, block, button, colors, column, component, context_provider, provide, + row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, + use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, + view, }; pub use ruin_ui::{ Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, @@ -2632,4 +2627,27 @@ mod tests { assert!(debug.contains("offset_y: 96.0"), "{debug}"); } + + #[test] + fn async_signal_write_flushes_without_manual_reactor_flush() { + let signal = Signal::new("before".to_string()); + let observed = Rc::new(RefCell::new(String::new())); + let _effect = ruin_reactivity::effect({ + let signal = signal.clone(); + let observed = Rc::clone(&observed); + move || { + *observed.borrow_mut() = signal.get(); + } + }); + + ruin_runtime::queue_future({ + let signal = signal.clone(); + async move { + let _ = signal.set("after".to_string()); + } + }); + ruin_runtime::run_until_stalled(); + + assert_eq!(observed.borrow().as_str(), "after"); + } } diff --git a/lib/runtime/src/platform/linux_x86_64/runtime.rs b/lib/runtime/src/platform/linux_x86_64/runtime.rs index 109b7b7..a6ada11 100644 --- a/lib/runtime/src/platform/linux_x86_64/runtime.rs +++ b/lib/runtime/src/platform/linux_x86_64/runtime.rs @@ -13,16 +13,12 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; use std::time::Duration; use super::driver::{Driver, ThreadNotifier, create_driver, monotonic_now}; +use crate::platform::runtime_shared::{ + IntervalCallback, LocalBoxFuture, LocalTask, LocalTaskQueue, MICROTASK_STARVATION_THRESHOLD, + MacroTaskQueue, SendTask, +}; use crate::trace_targets; -type LocalTask = Box; -type SendTask = Box; -type LocalBoxFuture = Pin + 'static>>; - -/// If the microtask queue runs more than this many tasks in a single turn -/// without yielding to the macrotask queue, a warning is emitted. -const MICROTASK_STARVATION_THRESHOLD: u64 = 1000; - struct MacroTask { task: LocalTask, /// Wall time at which this task entered the local queue. Populated only @@ -668,12 +664,12 @@ struct ThreadState { driver: Driver, shared: Arc, worker_completion: Option>, - local_microtasks: RefCell>, - local_macrotasks: RefCell>, + local_microtasks: RefCell, + local_macrotasks: RefCell>, timers: RefCell, /// Zero-delay intervals bypasses the timer heap entirely. Each entry /// re-enqueues itself as a macrotask on every turn. - immediate_intervals: RefCell>>>>, + immediate_intervals: RefCell>, next_timer_id: Cell, children: RefCell>, } diff --git a/lib/runtime/src/platform/macos_aarch64/driver.rs b/lib/runtime/src/platform/macos_aarch64/driver.rs index bdd96e4..d8389ec 100644 --- a/lib/runtime/src/platform/macos_aarch64/driver.rs +++ b/lib/runtime/src/platform/macos_aarch64/driver.rs @@ -1,12 +1,37 @@ //! Public runtime driver primitives for macOS. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use std::io; use std::os::fd::RawFd; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use crate::op::completion::CompletionHandle; + +type FdCompletion = CompletionHandle>; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) struct FdReadinessToken(u64); + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) enum FdInterest { + Readable, + Writable, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +struct FdKey { + fd: RawFd, + interest: FdInterest, +} + +struct FdWaiter { + token: FdReadinessToken, + completion: FdCompletion, +} + #[derive(Clone)] struct NotifierInner { write_fd: RawFd, @@ -73,6 +98,8 @@ pub struct Driver { timer_deadline: Cell>, pending_wakes: Cell, pending_timers: Cell, + next_fd_token: Cell, + fd_waiters: RefCell>>, } /// Creates a new driver and its paired [`ThreadNotifier`]. @@ -130,6 +157,8 @@ pub fn create_driver() -> io::Result<(Driver, ThreadNotifier)> { timer_deadline: Cell::new(None), pending_wakes: Cell::new(0), pending_timers: Cell::new(0), + next_fd_token: Cell::new(1), + fd_waiters: RefCell::new(HashMap::new()), }; let notifier = ThreadNotifier { @@ -205,6 +234,51 @@ impl Driver { } } + pub(crate) fn register_fd_readiness( + &self, + fd: RawFd, + interest: FdInterest, + completion: FdCompletion, + ) -> io::Result { + let key = FdKey { fd, interest }; + let should_register = !self.fd_waiters.borrow().contains_key(&key); + if should_register { + self.update_fd_interest(key, libc::EV_ADD | libc::EV_ENABLE)?; + } + + let token = self.allocate_fd_token(); + self.fd_waiters + .borrow_mut() + .entry(key) + .or_default() + .push(FdWaiter { token, completion }); + Ok(token) + } + + pub(crate) fn cancel_fd_readiness(&self, token: FdReadinessToken) { + let mut empty_key = None; + { + let mut waiters = self.fd_waiters.borrow_mut(); + for (key, entries) in waiters.iter_mut() { + if let Some(index) = entries.iter().position(|entry| entry.token == token) { + entries.swap_remove(index); + if entries.is_empty() { + empty_key = Some(*key); + } + break; + } + } + + if let Some(key) = empty_key { + waiters.remove(&key); + } + } + + if let Some(key) = empty_key { + let _ = self.update_fd_interest(key, libc::EV_DELETE); + } + } + fn process(&self, timeout: Option) -> io::Result> { let mut ready = ReadyEvents::default(); @@ -241,6 +315,8 @@ impl Driver { let wakes = drain_wake_pipe(self.wake_read_fd)?; self.pending_wakes .set(self.pending_wakes.get().saturating_add(wakes)); + } else if let Some(interest) = interest_from_filter(event.filter) { + self.complete_fd_waiters(event.ident as RawFd, interest, event); } } } @@ -257,6 +333,59 @@ impl Driver { if saw_any { Ok(Some(ready)) } else { Ok(None) } } + + fn allocate_fd_token(&self) -> FdReadinessToken { + let token = self.next_fd_token.get(); + self.next_fd_token.set( + token + .checked_add(1) + .expect("fd readiness token space exhausted"), + ); + FdReadinessToken(token) + } + + fn update_fd_interest(&self, key: FdKey, flags: u16) -> io::Result<()> { + let event = libc::kevent { + ident: key.fd as usize, + filter: filter_for_interest(key.interest), + flags, + fflags: 0, + data: 0, + udata: std::ptr::null_mut(), + }; + let submitted = unsafe { + libc::kevent( + self.kqueue_fd, + &event, + 1, + std::ptr::null_mut(), + 0, + std::ptr::null(), + ) + }; + if submitted < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + fn complete_fd_waiters(&self, fd: RawFd, interest: FdInterest, event: &libc::kevent) { + let key = FdKey { fd, interest }; + let waiters = self.fd_waiters.borrow_mut().remove(&key); + let Some(waiters) = waiters else { + return; + }; + + let _ = self.update_fd_interest(key, libc::EV_DELETE); + let result = fd_event_result(event); + for waiter in waiters { + waiter.completion.complete(match &result { + Ok(()) => Ok(()), + Err(error) => Err(io::Error::new(error.kind(), error.to_string())), + }); + } + } } impl Drop for Driver { @@ -290,6 +419,31 @@ fn cvt(value: libc::c_int) -> io::Result { } } +fn filter_for_interest(interest: FdInterest) -> i16 { + match interest { + FdInterest::Readable => libc::EVFILT_READ, + FdInterest::Writable => libc::EVFILT_WRITE, + } +} + +fn interest_from_filter(filter: i16) -> Option { + if filter == libc::EVFILT_READ { + Some(FdInterest::Readable) + } else if filter == libc::EVFILT_WRITE { + Some(FdInterest::Writable) + } else { + None + } +} + +fn fd_event_result(event: &libc::kevent) -> io::Result<()> { + if event.flags & libc::EV_ERROR != 0 && event.data != 0 { + Err(io::Error::from_raw_os_error(event.data as i32)) + } else { + Ok(()) + } +} + fn set_nonblocking(fd: RawFd) -> io::Result<()> { let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?; cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) })?; diff --git a/lib/runtime/src/platform/macos_aarch64/runtime.rs b/lib/runtime/src/platform/macos_aarch64/runtime.rs index 1432b48..1ba742c 100644 --- a/lib/runtime/src/platform/macos_aarch64/runtime.rs +++ b/lib/runtime/src/platform/macos_aarch64/runtime.rs @@ -12,18 +12,13 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; use std::time::Duration; -use super::driver::{Driver, ThreadNotifier, create_driver, monotonic_now}; +use super::driver::{Driver, FdReadinessToken, ThreadNotifier, create_driver, monotonic_now}; +use crate::platform::runtime_shared::{ + IntervalCallback, LocalBoxFuture, LocalTask, LocalTaskQueue, MICROTASK_STARVATION_THRESHOLD, + MacroTaskQueue, SendTask, +}; use crate::trace_targets; -type LocalTask = Box; -type SendTask = Box; -type LocalBoxFuture = Pin + 'static>>; -type IntervalCallback = Rc>>; - -/// If the microtask queue runs more than this many tasks in a single turn -/// without yielding to the macrotask queue, a warning is emitted. -const MICROTASK_STARVATION_THRESHOLD: u64 = 1000; - struct MacroTask { task: LocalTask, /// Wall time at which this task entered the local queue. Populated only @@ -100,6 +95,10 @@ pub(crate) fn with_current_driver(f: impl FnOnce(&Driver) -> T) -> T { f(¤t_thread().driver) } +pub(crate) fn cancel_fd_readiness(token: FdReadinessToken) { + current_thread().driver.cancel_fd_readiness(token); +} + /// Queues a macrotask on the current runtime thread. /// /// The task runs after all currently-queued macrotasks, and after all microtasks. @@ -670,8 +669,8 @@ struct ThreadState { driver: Driver, shared: Arc, worker_completion: Option>, - local_microtasks: RefCell>, - local_macrotasks: RefCell>, + local_microtasks: RefCell, + local_macrotasks: RefCell>, timers: RefCell, /// Zero-delay intervals bypasses the timer heap entirely. Each entry /// re-enqueues itself as a macrotask on every turn. diff --git a/lib/runtime/src/platform/mod.rs b/lib/runtime/src/platform/mod.rs index 2148e0d..3bc77b3 100644 --- a/lib/runtime/src/platform/mod.rs +++ b/lib/runtime/src/platform/mod.rs @@ -1,3 +1,5 @@ +pub(crate) mod runtime_shared; + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] pub mod linux_x86_64; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] diff --git a/lib/runtime/src/platform/runtime_shared.rs b/lib/runtime/src/platform/runtime_shared.rs new file mode 100644 index 0000000..432eb89 --- /dev/null +++ b/lib/runtime/src/platform/runtime_shared.rs @@ -0,0 +1,17 @@ +use std::cell::RefCell; +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +pub(crate) type LocalTask = Box; +pub(crate) type SendTask = Box; +pub(crate) type LocalBoxFuture = Pin + 'static>>; +pub(crate) type IntervalCallback = Rc>>; + +/// If the microtask queue runs more than this many tasks in a single turn +/// without yielding to the macrotask queue, a warning is emitted. +pub(crate) const MICROTASK_STARVATION_THRESHOLD: u64 = 1000; + +pub(crate) type LocalTaskQueue = VecDeque; +pub(crate) type MacroTaskQueue = VecDeque; diff --git a/lib/runtime/src/sys/macos/fd.rs b/lib/runtime/src/sys/macos/fd.rs index 27107c9..1a51033 100644 --- a/lib/runtime/src/sys/macos/fd.rs +++ b/lib/runtime/src/sys/macos/fd.rs @@ -2,42 +2,48 @@ use std::io; use std::os::fd::RawFd; -use std::time::Duration; use crate::op::completion::completion_for_current_thread; +use crate::platform::current::driver::FdInterest; +use crate::platform::current::runtime::{ + cancel_fd_readiness, current_thread_handle, with_current_driver, +}; /// Waits until `fd` becomes readable or reports an error/hangup condition. pub async fn wait_readable(fd: RawFd) -> io::Result<()> { - let (future, handle) = completion_for_current_thread::>(); - std::thread::spawn(move || { - let mut pollfd = libc::pollfd { - fd, - events: libc::POLLIN | libc::POLLERR | libc::POLLHUP, - revents: 0, - }; - loop { - let result = unsafe { libc::poll(&mut pollfd, 1, -1) }; - if result > 0 { - handle.complete(Ok(())); - return; - } - if result == 0 { - continue; - } + wait_fd_readiness(fd, FdInterest::Readable).await +} - let error = io::Error::last_os_error(); - if error.kind() == io::ErrorKind::Interrupted { - continue; - } - handle.complete(Err(error)); - return; +/// Waits until `fd` becomes writable or reports an error/hangup condition. +pub async fn wait_writable(fd: RawFd) -> io::Result<()> { + wait_fd_readiness(fd, FdInterest::Writable).await +} + +async fn wait_fd_readiness(fd: RawFd, interest: FdInterest) -> io::Result<()> { + let (future, handle) = completion_for_current_thread::>(); + let owner = current_thread_handle(); + let token = + with_current_driver(|driver| driver.register_fd_readiness(fd, interest, handle.clone())); + match token { + Ok(token) => { + handle.set_cancel({ + let handle = handle.clone(); + move || { + let queued_handle = handle.clone(); + let queued = owner.queue_task(move || { + cancel_fd_readiness(token); + queued_handle.finish(None); + }); + if !queued { + handle.finish(None); + } + } + }); } - }); + Err(error) => { + handle.complete(Err(error)); + } + } future.await } - -#[allow(dead_code)] -fn _duration_to_poll_timeout(duration: Duration) -> i32 { - duration.as_millis().min(i32::MAX as u128) as i32 -} diff --git a/lib/runtime/src/sys/macos/fs.rs b/lib/runtime/src/sys/macos/fs.rs index 2393e28..1fb241e 100644 --- a/lib/runtime/src/sys/macos/fs.rs +++ b/lib/runtime/src/sys/macos/fs.rs @@ -8,8 +8,9 @@ use std::os::fd::{FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; use std::path::PathBuf; +use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, mpsc}; use std::task::{Context, Poll, Waker}; use std::thread; @@ -17,6 +18,10 @@ use crate::op::completion::completion_for_current_thread; use crate::op::fs::{FileType, FsOp, MetadataTarget, RawDirEntry, RawMetadata}; use crate::platform::current::runtime::{ThreadHandle, current_thread_handle}; +type BlockingTask = Box; + +static BLOCKING_POOL: OnceLock> = OnceLock::new(); + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExecutionPath { Offload, @@ -227,12 +232,9 @@ impl ReadDirStream { let state = Arc::new(ReadDirState::new(current_thread_handle())); let producer = Arc::clone(&state); - if let Err(error) = thread::Builder::new() - .name("ruin-runtime-read-dir".into()) - .spawn(move || produce_dir_entries(path, producer)) - { + if let Err(error) = spawn_blocking(Box::new(move || produce_dir_entries(path, producer))) { state.release_pending(); - return Err(io::Error::other(error)); + return Err(error); } Ok(Self { state }) @@ -357,12 +359,76 @@ async fn offload( work: impl FnOnce() -> io::Result + Send + 'static, ) -> io::Result { let (future, handle) = completion_for_current_thread::>(); - thread::spawn(move || { - handle.complete(work()); - }); + let handle_for_task = handle.clone(); + if let Err(error) = spawn_blocking(Box::new(move || handle_for_task.complete(work()))) { + handle.complete(Err(error)); + } future.await } +struct BlockingPool { + sender: mpsc::Sender, +} + +impl BlockingPool { + fn spawn(&self, task: BlockingTask) -> io::Result<()> { + self.sender.send(task).map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "filesystem blocking worker pool has stopped", + ) + }) + } +} + +fn spawn_blocking(task: BlockingTask) -> io::Result<()> { + blocking_pool()?.spawn(task) +} + +fn blocking_pool() -> io::Result<&'static BlockingPool> { + match BLOCKING_POOL.get_or_init(create_blocking_pool) { + Ok(pool) => Ok(pool), + Err(error) => Err(io::Error::new(error.kind(), error.to_string())), + } +} + +fn create_blocking_pool() -> io::Result { + let (sender, receiver) = mpsc::channel::(); + let receiver = Arc::new(Mutex::new(receiver)); + let worker_count = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(2) + .clamp(2, 4); + let mut spawned = 0usize; + let mut last_error = None; + + for index in 0..worker_count { + let receiver = Arc::clone(&receiver); + match thread::Builder::new() + .name(format!("ruin-runtime-fs-offload-{index}")) + .spawn(move || { + loop { + let task = receiver.lock().unwrap().recv(); + match task { + Ok(task) => task(), + Err(_) => break, + } + } + }) { + Ok(_) => spawned += 1, + Err(error) => last_error = Some(error), + } + } + + if spawned == 0 { + return Err(io::Error::other( + last_error.expect("at least one worker spawn should have been attempted"), + )); + } + + Ok(BlockingPool { sender }) +} + fn path_to_c_string(path: PathBuf) -> io::Result { CString::new(path.as_os_str().as_bytes()).map_err(|_| { io::Error::new( diff --git a/lib/runtime/src/sys/macos/net.rs b/lib/runtime/src/sys/macos/net.rs index b76ba81..d43520f 100644 --- a/lib/runtime/src/sys/macos/net.rs +++ b/lib/runtime/src/sys/macos/net.rs @@ -23,11 +23,24 @@ type ShutdownFuture = Pin> + 'static>>; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExecutionPath { + Kqueue, Offload, } -pub fn execution_path(_op: &NetOp) -> ExecutionPath { - ExecutionPath::Offload +pub fn execution_path(op: &NetOp) -> ExecutionPath { + match op { + NetOp::Socket { .. } + | NetOp::Connect { .. } + | NetOp::Bind { .. } + | NetOp::Listen { .. } + | NetOp::Accept { .. } + | NetOp::Send { .. } + | NetOp::SendTo { .. } + | NetOp::Recv { .. } + | NetOp::RecvFrom { .. } + | NetOp::Shutdown { .. } + | NetOp::Close { .. } => ExecutionPath::Kqueue, + } } pub async fn resolve_addrs(addr: A) -> io::Result> @@ -59,7 +72,7 @@ pub async fn socket(op: NetOp) -> io::Result { unreachable!("socket backend called with non-socket op"); }; - offload(move || socket_sync(domain, socket_type, protocol, flags)).await + socket_sync(domain, socket_type, protocol, flags) } pub async fn connect(op: NetOp) -> io::Result<()> { @@ -67,7 +80,7 @@ pub async fn connect(op: NetOp) -> io::Result<()> { unreachable!("connect backend called with non-connect op"); }; - offload(move || connect_sync(fd, RawSocketAddr::from_socket_addr(addr))).await + connect_async(fd, RawSocketAddr::from_socket_addr(addr)).await } pub async fn bind(op: NetOp) -> io::Result<()> { @@ -75,7 +88,7 @@ pub async fn bind(op: NetOp) -> io::Result<()> { unreachable!("bind backend called with non-bind op"); }; - offload(move || bind_sync(fd, RawSocketAddr::from_socket_addr(addr))).await + bind_sync(fd, RawSocketAddr::from_socket_addr(addr)) } pub async fn listen(op: NetOp) -> io::Result<()> { @@ -83,7 +96,7 @@ pub async fn listen(op: NetOp) -> io::Result<()> { unreachable!("listen backend called with non-listen op"); }; - offload(move || listen_sync(fd, backlog)).await + listen_sync(fd, backlog) } pub async fn accept(op: NetOp) -> io::Result { @@ -91,7 +104,7 @@ pub async fn accept(op: NetOp) -> io::Result { unreachable!("accept backend called with non-accept op"); }; - offload(move || accept_sync(fd)).await + accept_async(fd).await } pub async fn send(op: NetOp) -> io::Result { @@ -99,7 +112,7 @@ pub async fn send(op: NetOp) -> io::Result { unreachable!("send backend called with non-send op"); }; - offload(move || send_sync(fd, data, flags)).await + send_async(fd, data, flags).await } pub async fn send_to(op: NetOp) -> io::Result { @@ -113,7 +126,7 @@ pub async fn send_to(op: NetOp) -> io::Result { unreachable!("send_to backend called with non-send_to op"); }; - offload(move || send_to_sync(fd, target, data, flags)).await + send_to_async(fd, target, data, flags).await } pub async fn recv(op: NetOp) -> io::Result> { @@ -121,7 +134,7 @@ pub async fn recv(op: NetOp) -> io::Result> { unreachable!("recv backend called with non-recv op"); }; - offload(move || recv_sync(fd, len, flags)).await + recv_async(fd, len, flags).await } pub async fn recv_from(op: NetOp) -> io::Result { @@ -129,7 +142,7 @@ pub async fn recv_from(op: NetOp) -> io::Result { unreachable!("recv_from backend called with non-recv_from op"); }; - offload(move || recv_from_sync(fd, len, flags)).await + recv_from_async(fd, len, flags).await } pub async fn shutdown(op: NetOp) -> io::Result<()> { @@ -137,7 +150,7 @@ pub async fn shutdown(op: NetOp) -> io::Result<()> { unreachable!("shutdown backend called with non-shutdown op"); }; - offload(move || shutdown_sync(fd, how)).await + shutdown_sync(fd, how) } pub async fn close(op: NetOp) -> io::Result<()> { @@ -145,7 +158,7 @@ pub async fn close(op: NetOp) -> io::Result<()> { unreachable!("close backend called with non-close op"); }; - offload(move || close_sync(fd)).await + close_sync(fd) } pub async fn connect_stream(addr: SocketAddr) -> io::Result { @@ -234,11 +247,9 @@ async fn bind_datagram_inner(addr: SocketAddr) -> io::Result { } pub async fn duplicate(fd: RawFd) -> io::Result { - offload(move || { - let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?; - Ok(unsafe { OwnedFd::from_raw_fd(duplicated) }) - }) - .await + let duplicated = cvt(unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) })?; + set_nonblocking(duplicated)?; + Ok(unsafe { OwnedFd::from_raw_fd(duplicated) }) } pub async fn recv_timeout( @@ -247,11 +258,7 @@ pub async fn recv_timeout( flags: i32, timeout: Duration, ) -> io::Result> { - offload(move || { - wait_for_fd(fd, libc::POLLIN, timeout)?; - recv_sync(fd, len, flags) - }) - .await + io_timeout(timeout, recv_async(fd, len, flags)).await } pub async fn send_timeout( @@ -260,11 +267,7 @@ pub async fn send_timeout( flags: i32, timeout: Duration, ) -> io::Result { - offload(move || { - wait_for_fd(fd, libc::POLLOUT, timeout)?; - send_sync(fd, data, flags) - }) - .await + io_timeout(timeout, send_async(fd, data, flags)).await } pub async fn recv_from_timeout( @@ -273,11 +276,7 @@ pub async fn recv_from_timeout( flags: i32, timeout: Duration, ) -> io::Result { - offload(move || { - wait_for_fd(fd, libc::POLLIN, timeout)?; - recv_from_sync(fd, len, flags) - }) - .await + io_timeout(timeout, recv_from_async(fd, len, flags)).await } pub async fn send_to_timeout( @@ -287,15 +286,21 @@ pub async fn send_to_timeout( flags: i32, timeout: Duration, ) -> io::Result { - offload(move || { - wait_for_fd(fd, libc::POLLOUT, timeout)?; - send_to_sync(fd, target, data, flags) - }) - .await + io_timeout(timeout, send_to_async(fd, target, data, flags)).await } pub async fn connect_stream_timeout(addr: SocketAddr, timeout: Duration) -> io::Result { - offload(move || connect_stream_timeout_sync(addr, timeout)).await + let fd = socket_sync(socket_domain(addr), libc::SOCK_STREAM, 0, 0)?; + if let Err(error) = io_timeout( + timeout, + connect_async(fd.as_raw_fd(), RawSocketAddr::from_socket_addr(addr)), + ) + .await + { + drop(fd); + return Err(error); + } + Ok(fd) } pub fn local_addr(fd: RawFd) -> io::Result { @@ -396,6 +401,15 @@ async fn offload( future.await } +async fn io_timeout( + timeout: Duration, + future: impl Future>, +) -> io::Result { + crate::time::timeout(timeout, future) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "operation timed out"))? +} + fn socket_domain(addr: SocketAddr) -> i32 { match addr { SocketAddr::V4(_) => libc::AF_INET, @@ -573,11 +587,28 @@ impl RawSocketAddr { fn socket_sync(domain: i32, socket_type: i32, protocol: i32, _flags: u32) -> io::Result { let fd = cvt(unsafe { libc::socket(domain, socket_type, protocol) })?; set_cloexec(fd)?; + set_nonblocking(fd)?; Ok(unsafe { OwnedFd::from_raw_fd(fd) }) } -fn connect_sync(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> { - cvt(unsafe { libc::connect(fd, addr.as_ptr(), addr.len()) }).map(|_| ()) +async fn connect_async(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> { + loop { + let result = unsafe { libc::connect(fd, addr.as_ptr(), addr.len()) }; + if result == 0 { + return Ok(()); + } + + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::EINTR) => continue, + Some(libc::EINPROGRESS) | Some(libc::EALREADY) => { + crate::sys::current::fd::wait_writable(fd).await?; + return socket_error(fd); + } + Some(libc::EISCONN) => return Ok(()), + _ => return Err(error), + } + } } fn bind_sync(fd: RawFd, addr: RawSocketAddr) -> io::Result<()> { @@ -602,12 +633,59 @@ fn accept_sync(fd: RawFd) -> io::Result { }) } -fn send_sync(fd: RawFd, data: Vec, flags: i32) -> io::Result { +async fn accept_async(fd: RawFd) -> io::Result { + loop { + match accept_sync(fd) { + Ok(socket) => { + set_nonblocking(socket.fd)?; + return Ok(socket); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + crate::sys::current::fd::wait_readable(fd).await?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} + +async fn send_async(fd: RawFd, data: Vec, flags: i32) -> io::Result { + loop { + match send_slice_sync(fd, &data, flags) { + Ok(written) => return Ok(written), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + crate::sys::current::fd::wait_writable(fd).await?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} + +fn send_slice_sync(fd: RawFd, data: &[u8], flags: i32) -> io::Result { let written = unsafe { libc::send(fd, data.as_ptr().cast::(), data.len(), flags) }; cvt_long(written).map(|written| written as usize) } -fn send_to_sync(fd: RawFd, target: SocketAddr, data: Vec, flags: i32) -> io::Result { +async fn send_to_async( + fd: RawFd, + target: SocketAddr, + data: Vec, + flags: i32, +) -> io::Result { + loop { + match send_to_slice_sync(fd, target, &data, flags) { + Ok(written) => return Ok(written), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + crate::sys::current::fd::wait_writable(fd).await?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} + +fn send_to_slice_sync(fd: RawFd, target: SocketAddr, data: &[u8], flags: i32) -> io::Result { let addr = RawSocketAddr::from_socket_addr(target); let written = unsafe { libc::sendto( @@ -622,6 +700,19 @@ fn send_to_sync(fd: RawFd, target: SocketAddr, data: Vec, flags: i32) -> io: cvt_long(written).map(|written| written as usize) } +async fn recv_async(fd: RawFd, len: usize, flags: i32) -> io::Result> { + loop { + match recv_sync(fd, len, flags) { + Ok(data) => return Ok(data), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + crate::sys::current::fd::wait_readable(fd).await?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} + fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result> { let mut data = vec![0u8; len]; let read = unsafe { libc::recv(fd, data.as_mut_ptr().cast::(), len, flags) }; @@ -630,6 +721,19 @@ fn recv_sync(fd: RawFd, len: usize, flags: i32) -> io::Result> { Ok(data) } +async fn recv_from_async(fd: RawFd, len: usize, flags: i32) -> io::Result { + loop { + match recv_from_sync(fd, len, flags) { + Ok(datagram) => return Ok(datagram), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + crate::sys::current::fd::wait_readable(fd).await?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} + fn recv_from_sync(fd: RawFd, len: usize, flags: i32) -> io::Result { let mut data = vec![0u8; len]; let mut storage = MaybeUninit::::zeroed(); @@ -659,79 +763,6 @@ fn close_sync(fd: RawFd) -> io::Result<()> { cvt(unsafe { libc::close(fd) }).map(|_| ()) } -fn connect_stream_timeout_sync(addr: SocketAddr, timeout: Duration) -> io::Result { - let fd = cvt(unsafe { libc::socket(socket_domain(addr), libc::SOCK_STREAM, 0) })?; - set_cloexec(fd)?; - set_nonblocking(fd)?; - - let raw = RawSocketAddr::from_socket_addr(addr); - let connect_result = unsafe { libc::connect(fd, raw.as_ptr(), raw.len()) }; - if connect_result < 0 { - let error = io::Error::last_os_error(); - if error.raw_os_error() != Some(libc::EINPROGRESS) { - let _ = unsafe { libc::close(fd) }; - return Err(error); - } - } - - let wait = wait_for_fd(fd, libc::POLLOUT, timeout); - if let Err(error) = wait { - let _ = unsafe { libc::close(fd) }; - return Err(error); - } - - let mut so_error: libc::c_int = 0; - let mut len = std::mem::size_of::() as libc::socklen_t; - if unsafe { - libc::getsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_ERROR, - &mut so_error as *mut libc::c_int as *mut c_void, - &mut len, - ) - } < 0 - { - let error = io::Error::last_os_error(); - let _ = unsafe { libc::close(fd) }; - return Err(error); - } - - if so_error != 0 { - let _ = unsafe { libc::close(fd) }; - return Err(io::Error::from_raw_os_error(so_error)); - } - - clear_nonblocking(fd)?; - Ok(unsafe { OwnedFd::from_raw_fd(fd) }) -} - -fn wait_for_fd(fd: RawFd, events: i16, timeout: Duration) -> io::Result<()> { - let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; - let mut pfd = libc::pollfd { - fd, - events, - revents: 0, - }; - loop { - let result = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; - if result > 0 { - return Ok(()); - } - if result == 0 { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "operation timed out", - )); - } - let error = io::Error::last_os_error(); - if error.kind() == io::ErrorKind::Interrupted { - continue; - } - return Err(error); - } -} - fn set_cloexec(fd: RawFd) -> io::Result<()> { let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFD) })?; cvt(unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) })?; @@ -744,12 +775,6 @@ fn set_nonblocking(fd: RawFd) -> io::Result<()> { Ok(()) } -fn clear_nonblocking(fd: RawFd) -> io::Result<()> { - let flags = cvt(unsafe { libc::fcntl(fd, libc::F_GETFL) })?; - cvt(unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) })?; - Ok(()) -} - fn should_try_ipv4_loopback(addr: SocketAddr, error: &io::Error) -> bool { matches!(addr, SocketAddr::V6(v6) if v6.ip().is_loopback()) && matches!( @@ -758,6 +783,25 @@ fn should_try_ipv4_loopback(addr: SocketAddr, error: &io::Error) -> bool { ) } +fn socket_error(fd: RawFd) -> io::Result<()> { + let mut so_error: libc::c_int = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + cvt(unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_ERROR, + &mut so_error as *mut libc::c_int as *mut c_void, + &mut len, + ) + })?; + if so_error == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(so_error)) + } +} + fn localhost_v4(addr: SocketAddr) -> SocketAddr { SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, addr.port())) } diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 4a33477..e903160 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -2611,4 +2611,38 @@ mod tests { "offscreen descendants must keep geometry for widget-ref scrolling" ); } + + #[test] + fn scroll_culling_keeps_visible_text_selectable_inside_viewport_clip() { + let text_id = ElementId::new(77); + let root = Element::new().child( + Element::scroll_box(120.0).width(360.0).height(120.0).child( + Element::paragraph( + "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09", + text_style().with_line_height(20.0), + ) + .id(text_id), + ), + ); + + let snapshot = layout_snapshot(1, UiSize::new(360.0, 120.0), &root); + let text = snapshot + .interaction_tree + .text_for_element(text_id) + .expect("selectable text should remain in the interaction tree"); + let visible_hit = snapshot + .interaction_tree + .text_hit_test(Point::new(8.0, 8.0)) + .expect("visible scrolled text should be selectable"); + + assert_eq!(text.element_id, Some(text_id)); + assert_eq!(visible_hit.target.element_id, Some(text_id)); + assert!( + snapshot + .interaction_tree + .text_hit_test(Point::new(8.0, 160.0)) + .is_none(), + "text hit testing should still honor the scroll viewport clip" + ); + } } diff --git a/lib/ui_platform_macos/src/lib.rs b/lib/ui_platform_macos/src/lib.rs index eb01a66..609b4f0 100644 --- a/lib/ui_platform_macos/src/lib.rs +++ b/lib/ui_platform_macos/src/lib.rs @@ -73,17 +73,83 @@ struct RunLoopWakeHandle { struct MacosWindow { spec: WindowSpec, - lifecycle: WindowLifecycle, + state: MacosWindowState, configuration: WindowConfigured, latest_scene: Option, clipboard_text: Option, - desired_visible: bool, cursor_icon: CursorIcon, + resize_pipeline: ResizePipeline, + native: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct MacosWindowState { + lifecycle: WindowLifecycle, + desired_visible: bool, close_requested_sent: bool, close_in_progress: bool, has_presented_frame: bool, - resize_pipeline: ResizePipeline, - native: Option, +} + +impl MacosWindowState { + fn new(desired_visible: bool) -> Self { + Self { + lifecycle: WindowLifecycle::LogicalClosed, + desired_visible, + close_requested_sent: false, + close_in_progress: false, + has_presented_frame: false, + } + } + + fn open_with_visibility(&mut self, visible: bool) { + self.lifecycle = lifecycle_for_visibility(visible); + self.desired_visible = visible; + self.close_requested_sent = false; + self.close_in_progress = false; + } + + fn set_native_visibility(&mut self, visible: bool) { + self.lifecycle = lifecycle_for_visibility(visible); + if visible { + self.close_requested_sent = false; + } else { + self.has_presented_frame = false; + } + } + + fn request_logical_close(&mut self) { + self.desired_visible = false; + self.close_in_progress = true; + } + + fn finalize_close(&mut self) { + self.lifecycle = WindowLifecycle::LogicalClosed; + self.desired_visible = false; + self.close_in_progress = false; + self.has_presented_frame = false; + } + + fn note_presented(&mut self) { + self.has_presented_frame = true; + } + + fn mark_close_requested_sent(&mut self) -> bool { + if self.close_requested_sent { + false + } else { + self.close_requested_sent = true; + true + } + } +} + +fn lifecycle_for_visibility(visible: bool) -> WindowLifecycle { + if visible { + WindowLifecycle::OpenVisible + } else { + WindowLifecycle::OpenHidden + } } struct NativeWindow { @@ -95,6 +161,62 @@ struct NativeWindow { renderer: Option, } +impl NativeWindow { + fn window_ptr(&self) -> usize { + self.window as usize + } + + fn set_title(&self, title: &str) { + let title = NSString::from_str(title); + unsafe { + let _: () = objc2::msg_send![self.window, setTitle: &*title]; + } + } + + fn set_content_size(&self, size: UiSize) { + let content_size = CGSize::new(size.width as f64, size.height as f64); + unsafe { + let _: () = objc2::msg_send![self.window, setContentSize: content_size]; + } + } + + fn show(&self) { + unsafe { + let _: () = objc2::msg_send![self.window, makeKeyAndOrderFront: std::ptr::null_mut::()]; + } + } + + fn hide(&self) { + unsafe { + let _: () = objc2::msg_send![self.window, orderOut: std::ptr::null_mut::()]; + } + } + + fn close(&self) { + unsafe { + let _: () = objc2::msg_send![self.window, close]; + } + } + + fn flush_display(&self) -> bool { + let in_live_resize: bool = unsafe { objc2::msg_send![self.view, inLiveResize] }; + unsafe { + let _: () = objc2::msg_send![self.view, displayIfNeeded]; + let _: () = objc2::msg_send![self.window, displayIfNeeded]; + let _: () = objc2::msg_send![objc2::class!(CATransaction), flush]; + } + in_live_resize + } + + fn detach_delegate(&self) { + unsafe { + let _: () = + objc2::msg_send![self.window, setDelegate: std::ptr::null_mut::()]; + let _: () = objc2::msg_send![self.delegate, release]; + } + } +} + #[derive(Default)] struct DelegateState { pending_close_requests: Vec, @@ -124,7 +246,6 @@ enum DelegateInputEvent { thread_local! { static ACTIVE_MACOS_STATE: RefCell>>> = const { RefCell::new(None) }; static APPKIT_PUMP_ACTIVE: Cell = const { Cell::new(false) }; - static IMMEDIATE_DELEGATE_FLUSH_ACTIVE: Cell = const { Cell::new(false) }; } fn run_loop_wake_handle() -> &'static Mutex> { @@ -182,7 +303,7 @@ impl MacosWindow { .unwrap_or_else(|| UiSize::new(960.0, 640.0)); Self { spec, - lifecycle: WindowLifecycle::LogicalClosed, + state: MacosWindowState::new(desired_visible), configuration: WindowConfigured { actual_inner_size, scale_factor: 1.0, @@ -192,11 +313,7 @@ impl MacosWindow { }, latest_scene: None, clipboard_text: None, - desired_visible, cursor_icon: CursorIcon::Default, - close_requested_sent: false, - close_in_progress: false, - has_presented_frame: false, resize_pipeline: ResizePipeline::default(), native: None, } @@ -494,7 +611,7 @@ fn window_delegate_class() -> &'static AnyClass { "windowShouldClose:" ); queue_delegate_close_request(sender); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); Bool::YES } extern "C-unwind" fn did_close(_this: &AnyObject, _cmd: Sel, notification: *mut AnyObject) { @@ -505,7 +622,7 @@ fn window_delegate_class() -> &'static AnyClass { "windowWillClose:" ); queue_delegate_window_closed(window); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn did_resize( _this: &AnyObject, @@ -535,7 +652,7 @@ fn window_delegate_class() -> &'static AnyClass { UiSize::new(frame.size.width as f32, frame.size.height as f32), (scale_factor as f32).max(1.0), ); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn did_change_occlusion_state( _this: &AnyObject, @@ -636,7 +753,7 @@ fn content_view_class() -> &'static AnyClass { extern "C-unwind" fn mouse_moved(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { queue_pointer_from_view_event(this, event, PointerEventKind::Move); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn left_mouse_down(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { @@ -647,7 +764,7 @@ fn content_view_class() -> &'static AnyClass { button: PointerButton::Primary, }, ); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn left_mouse_up(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { @@ -658,7 +775,7 @@ fn content_view_class() -> &'static AnyClass { button: PointerButton::Primary, }, ); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn left_mouse_dragged( @@ -667,7 +784,7 @@ fn content_view_class() -> &'static AnyClass { event: *mut AnyObject, ) { queue_pointer_from_view_event(this, event, PointerEventKind::Move); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn other_mouse_down(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { @@ -680,7 +797,7 @@ fn content_view_class() -> &'static AnyClass { button: PointerButton::Middle, }, ); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } } @@ -694,7 +811,7 @@ fn content_view_class() -> &'static AnyClass { button: PointerButton::Middle, }, ); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } } @@ -706,7 +823,7 @@ fn content_view_class() -> &'static AnyClass { let button_number: usize = unsafe { objc2::msg_send![event, buttonNumber] }; if button_number == 2 { queue_pointer_from_view_event(this, event, PointerEventKind::Move); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } } @@ -720,17 +837,17 @@ fn content_view_class() -> &'static AnyClass { delta: Point::new(delta_x as f32, -(delta_y as f32)), }, ); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn key_down(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { queue_keyboard_from_view_event(this, event, KeyboardEventKind::Pressed); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } extern "C-unwind" fn key_up(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { queue_keyboard_from_view_event(this, event, KeyboardEventKind::Released); - flush_delegate_events_immediately(); + signal_platform_run_loop_source(); } let class_name = CString::new("RuinContentView").expect("class name is valid"); @@ -851,47 +968,6 @@ fn try_enter_appkit_pump() -> Option { }) } -struct ImmediateDelegateFlushGuard; - -impl Drop for ImmediateDelegateFlushGuard { - fn drop(&mut self) { - IMMEDIATE_DELEGATE_FLUSH_ACTIVE.with(|flag| flag.set(false)); - } -} - -fn flush_delegate_events_immediately() { - let Some(_guard) = IMMEDIATE_DELEGATE_FLUSH_ACTIVE.with(|flag| { - if flag.replace(true) { - None - } else { - Some(ImmediateDelegateFlushGuard) - } - }) else { - return; - }; - - let Some(state) = ACTIVE_MACOS_STATE.with(|slot| slot.borrow().as_ref().cloned()) else { - return; - }; - let mut pending = Vec::new(); - { - let Ok(mut state_ref) = state.try_borrow_mut() else { - return; - }; - apply_delegate_events(&mut state_ref, &mut pending); - } - if pending.is_empty() { - return; - } - trace!( - target: "ruin_ui_platform_macos::events", - count = pending.len(), - "flushing delegate events immediately during AppKit callback" - ); - let sender = state.borrow().endpoint_events.clone(); - flush_pending_events(&sender, &mut pending); -} - fn drain_macos_run_loop_work(state: &Rc>) { let Some(_guard) = try_enter_appkit_pump() else { return; @@ -1021,15 +1097,8 @@ fn handle_create_window(state: &Rc>, window_id: WindowId, sp if let Some(window) = state_ref.windows.get_mut(&window_id) { ensure_native_window(window, app); sync_window_metrics(window); - window.lifecycle = if visible { - WindowLifecycle::OpenVisible - } else { - WindowLifecycle::OpenHidden - }; + window.state.open_with_visibility(visible); window.configuration.visible = visible; - window.desired_visible = visible; - window.close_requested_sent = false; - window.close_in_progress = false; let _ = window .resize_pipeline .ensure_initial_request(window.configuration.actual_inner_size); @@ -1082,46 +1151,37 @@ fn handle_update_window( if let Some(title) = update.title { window.spec.title = title; if let Some(native) = window.native.as_ref() { - let title = NSString::from_str(&window.spec.title); - unsafe { - let _: () = objc2::msg_send![native.window, setTitle: &*title]; - } + native.set_title(&window.spec.title); } } if let Some(open) = update.open { if open { - if window.lifecycle == WindowLifecycle::LogicalClosed { + if window.state.lifecycle == WindowLifecycle::LogicalClosed { ensure_native_window(window, app); sync_window_metrics(window); - window.lifecycle = if window.configuration.visible { - WindowLifecycle::OpenVisible - } else { - WindowLifecycle::OpenHidden - }; - window.close_requested_sent = false; - window.close_in_progress = false; + window + .state + .open_with_visibility(window.configuration.visible); sync_visibility(window); apply_cursor_icon(window.cursor_icon); emit_opened = true; } - } else if window.lifecycle != WindowLifecycle::LogicalClosed { + } else if window.state.lifecycle != WindowLifecycle::LogicalClosed { debug!( target: "ruin_ui_platform_macos::lifecycle", ?window_id, "platform received open(false), closing native window" ); - window.desired_visible = false; + window.state.request_logical_close(); if window.native.is_some() { - window.close_in_progress = true; close_native_window(window); // Keep behavior aligned with other current platform backends: // treat a requested close as immediately closed for app shutdown. - window.lifecycle = WindowLifecycle::LogicalClosed; - window.close_in_progress = false; + window.state.finalize_close(); emit_closed = true; } else { - window.lifecycle = WindowLifecycle::LogicalClosed; + window.state.finalize_close(); emit_closed = true; } } @@ -1131,15 +1191,7 @@ fn handle_update_window( && window.configuration.visible != visible { window.configuration.visible = visible; - window.desired_visible = visible; - window.lifecycle = if visible { - WindowLifecycle::OpenVisible - } else { - WindowLifecycle::OpenHidden - }; - if visible { - window.close_requested_sent = false; - } + window.state.open_with_visibility(visible); sync_visibility(window); emit_visibility = Some(visible); } @@ -1269,7 +1321,7 @@ fn try_present_scene( return None; } - window.has_presented_frame = true; + window.state.note_presented(); flush_presented_frame(window); if let Some(next_resize) = finish_presented_resize(window, scene_viewport) { trace!( @@ -1304,7 +1356,7 @@ fn repaint_retained_scene_for_resize(window_id: WindowId, window: &mut MacosWind "repainting retained scene into resized drawable" ); if render_scene_to_current_drawable(window_id, window, &scene) { - window.has_presented_frame = true; + window.state.note_presented(); flush_presented_frame(window); } } @@ -1486,10 +1538,7 @@ fn create_native_window(window: &MacosWindow, app: *mut AnyObject) -> Option()]; - } + native.show(); } else { - unsafe { - let _: () = - objc2::msg_send![native.window, orderOut: std::ptr::null_mut::()]; - } + native.hide(); } } @@ -1513,12 +1557,10 @@ fn close_native_window(window: &mut MacosWindow) { if let Some(native) = window.native.as_ref() { debug!( target: "ruin_ui_platform_macos::lifecycle", - window_ptr = native.window as usize, + window_ptr = native.window_ptr(), "sending NSWindow close" ); - unsafe { - let _: () = objc2::msg_send![native.window, close]; - } + native.close(); } } @@ -1534,11 +1576,11 @@ fn apply_delegate_events(state: &mut MacosState, pending: &mut Vec 0.0 @@ -1563,11 +1605,11 @@ fn apply_delegate_events(state: &mut MacosState, pending: &mut Vec()]; - let _: () = objc2::msg_send![native.delegate, release]; - } + native.detach_delegate(); } - window.close_in_progress = false; - window.lifecycle = WindowLifecycle::LogicalClosed; - window.desired_visible = false; + window.state.finalize_close(); if window.configuration.visible { window.configuration.visible = false; @@ -1954,8 +1978,8 @@ fn sync_window_metrics(window: &mut MacosWindow) { #[cfg(test)] mod tests { - use super::ResizePipeline; - use ruin_ui::UiSize; + use super::{MacosWindowState, ResizePipeline}; + use ruin_ui::{UiSize, WindowLifecycle}; #[test] fn resize_pipeline_stages_latest_resize_while_active_request_is_in_flight() { @@ -2020,4 +2044,40 @@ mod tests { assert_eq!(pipeline.active_resize, Some(active)); assert_eq!(pipeline.next_resize, Some(next)); } + + #[test] + fn window_state_distinguishes_hide_from_close() { + let mut state = MacosWindowState::new(true); + state.open_with_visibility(true); + state.note_presented(); + + state.set_native_visibility(false); + assert_eq!(state.lifecycle, WindowLifecycle::OpenHidden); + assert!(state.desired_visible); + assert!(!state.close_in_progress); + assert!(!state.has_presented_frame); + + state.set_native_visibility(true); + assert_eq!(state.lifecycle, WindowLifecycle::OpenVisible); + assert!(state.desired_visible); + } + + #[test] + fn window_state_close_request_is_single_shot_and_finalize_resets_state() { + let mut state = MacosWindowState::new(true); + state.open_with_visibility(true); + + assert!(state.mark_close_requested_sent()); + assert!(!state.mark_close_requested_sent()); + + state.request_logical_close(); + assert!(!state.desired_visible); + assert!(state.close_in_progress); + + state.finalize_close(); + assert_eq!(state.lifecycle, WindowLifecycle::LogicalClosed); + assert!(!state.desired_visible); + assert!(!state.close_in_progress); + assert!(!state.has_presented_frame); + } } diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index f0eda62..47bba4c 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -170,8 +170,8 @@ struct UploadedAtlasText { #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct TextTextureKey { text: String, - bounds: Option<(u32, u32)>, - clip: Option<(u32, u32, u32, u32)>, + bounds: Option<(i32, i32)>, + clip: Option<(i32, i32, i32, i32)>, font_size_bits: u32, line_height_bits: u32, color: (u8, u8, u8, u8), @@ -1315,7 +1315,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { clip: ActiveClip, ) -> Option { let raster_clip = text_raster_clip(text, clip, logical_size)?; - let key = text_texture_key(text, raster_clip); + let key = text_texture_key(text, raster_clip, self.scale_factor); if !self.text_cache.contains_key(&key) { let rasterized = self.rasterize_text(text, raster_clip)?; let cached = self.create_cached_text_texture(&rasterized); @@ -2442,20 +2442,21 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option) -> &[GlyphInstan &text.glyphs[glyph_start..glyph_end.min(text.glyphs.len())] } -fn text_texture_key(text: &PreparedText, raster_clip: Option) -> TextTextureKey { +fn text_texture_key( + text: &PreparedText, + raster_clip: Option, + scale_factor: f32, +) -> TextTextureKey { TextTextureKey { text: text.text.clone(), - bounds: text - .bounds - .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), - clip: raster_clip.map(|clip| { + bounds: text.bounds.map(|bounds| { ( - clip.origin.x.to_bits(), - clip.origin.y.to_bits(), - clip.size.width.to_bits(), - clip.size.height.to_bits(), + (bounds.width * scale_factor).ceil() as i32, + (bounds.height * scale_factor).ceil() as i32, ) }), + clip: raster_clip + .map(|clip| pixel_rect_tuple(scale_rect_to_pixel_rect(clip, scale_factor))), font_size_bits: text.font_size.to_bits(), line_height_bits: text.line_height.to_bits(), color: ( @@ -2479,6 +2480,10 @@ fn text_texture_key(text: &PreparedText, raster_clip: Option) -> TextTextu } } +fn pixel_rect_tuple(rect: PixelRect) -> (i32, i32, i32, i32) { + (rect.left, rect.top, rect.right, rect.bottom) +} + #[cfg(test)] mod tests { use super::{ @@ -2539,11 +2544,30 @@ mod tests { ); assert_eq!( - text_texture_key(&first, None), - text_texture_key(&second, None) + text_texture_key(&first, None, 1.0), + text_texture_key(&second, None, 1.0) ); } + #[test] + fn text_texture_key_normalizes_clip_to_device_pixels() { + let text = PreparedText::monospace( + "scrolling text", + Point::new(20.0, 30.0), + 16.0, + 8.0, + Color::rgb(0xEE, 0xEE, 0xEE), + ); + + let first = text_texture_key(&text, Some(Rect::new(0.001, 4.001, 80.001, 20.001)), 2.0); + let second = text_texture_key(&text, Some(Rect::new(0.002, 4.002, 80.002, 20.002)), 2.0); + let different_pixel = + text_texture_key(&text, Some(Rect::new(0.6, 4.001, 80.001, 20.001)), 2.0); + + assert_eq!(first, second); + assert_ne!(first, different_pixel); + } + #[test] fn nested_clip_with_empty_intersection_stays_clipped() { let mut clip_stack = Vec::new();