diff --git a/Cargo.lock b/Cargo.lock index 566fc12..b7ab3a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,6 +1314,32 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -1333,6 +1359,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -1352,8 +1389,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags", + "block2", + "libc", "objc2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", "objc2-foundation", "objc2-metal", ] @@ -1863,7 +1904,9 @@ dependencies = [ "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", + "raw-window-metal", "ruin-runtime", "ruin_ui", "ruin_ui_renderer_wgpu", diff --git a/lib/reactivity/src/reactor.rs b/lib/reactivity/src/reactor.rs index 3b29941..c0b01ef 100644 --- a/lib/reactivity/src/reactor.rs +++ b/lib/reactivity/src/reactor.rs @@ -282,6 +282,14 @@ impl Reactor { self.inner.ensure_flush_scheduled(); } + /// Flushes currently queued reactive jobs immediately on the calling thread. + /// + /// This is useful when host integrations need synchronous propagation + /// (for example, during native resize loops). + pub fn flush_now(&self) { + Rc::clone(&self.inner).flush_jobs(); + } + pub(crate) fn allocate_node(&self) -> NodeId { let raw = self.inner.next_node.get(); self.inner.next_node.set(raw.wrapping_add(1)); diff --git a/lib/ruin_app/build.rs b/lib/ruin_app/build.rs new file mode 100644 index 0000000..bc8f2bf --- /dev/null +++ b/lib/ruin_app/build.rs @@ -0,0 +1,18 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=macos/Info.plist"); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "macos" { + return; + } + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir")); + let plist = manifest_dir.join("macos/Info.plist"); + let arg = format!("-Wl,-sectcreate,__TEXT,__info_plist,{}", plist.display()); + + // Ensure all ruin_app executables/examples opt into Retina rendering. + println!("cargo:rustc-link-arg-examples={arg}"); +} diff --git a/lib/ruin_app/example/00_bootstrap_and_counter.rs b/lib/ruin_app/example/00_bootstrap_and_counter.rs index 162af9c..aaee07e 100644 --- a/lib/ruin_app/example/00_bootstrap_and_counter.rs +++ b/lib/ruin_app/example/00_bootstrap_and_counter.rs @@ -1,7 +1,16 @@ use ruin_app::prelude::*; +use tracing_subscriber::EnvFilter; #[ruin_runtime::async_main] async fn main() -> ruin_app::Result<()> { + let _ = tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_target(true) + .without_time() + .try_init(); + let title = "RUIN Counter"; App::new() diff --git a/lib/ruin_app/example/03_fine_grained_list.rs b/lib/ruin_app/example/03_fine_grained_list.rs index 6b18733..707b257 100644 --- a/lib/ruin_app/example/03_fine_grained_list.rs +++ b/lib/ruin_app/example/03_fine_grained_list.rs @@ -90,21 +90,12 @@ fn TaskBoard() -> impl IntoView { } }); - let all_label = if matches!(filter.get(), TaskFilter::All) { - "● All" - } else { - "○ All" - }; - let open_label = if matches!(filter.get(), TaskFilter::OpenOnly) { - "● Open" - } else { - "○ Open" - }; - let done_label = if matches!(filter.get(), TaskFilter::CompletedOnly) { - "● Completed" - } else { - "○ Completed" - }; + let all_label = filter_label(matches!(filter.get(), TaskFilter::All), "All"); + let open_label = filter_label(matches!(filter.get(), TaskFilter::OpenOnly), "Open"); + let done_label = filter_label( + matches!(filter.get(), TaskFilter::CompletedOnly), + "Completed", + ); let task_rows = visible_ids.with(|ids| { ids.iter() .map(|task_id| { @@ -466,6 +457,14 @@ fn move_task(tasks: &Signal>, task_id: u64, direction: MoveDirection) }); } +fn filter_label(active: bool, label: &str) -> String { + if active { + format!("[x] {label}") + } else { + format!("[ ] {label}") + } +} + fn seed_tasks() -> Vec { vec![ Task { diff --git a/lib/ruin_app/example/05_async_runtime_io.rs b/lib/ruin_app/example/05_async_runtime_io.rs index 1cba44e..ab3a465 100644 --- a/lib/ruin_app/example/05_async_runtime_io.rs +++ b/lib/ruin_app/example/05_async_runtime_io.rs @@ -268,6 +268,7 @@ 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/macos/Info.plist b/lib/ruin_app/macos/Info.plist new file mode 100644 index 0000000..6fdb09c --- /dev/null +++ b/lib/ruin_app/macos/Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleName + RUIN + CFBundleIdentifier + dev.ruin.example + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + NSHighResolutionCapable + + + diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index aaed846..843b316 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -15,8 +15,8 @@ use std::marker::PhantomData; use std::rc::Rc; use std::time::Instant; -use ruin_reactivity::effect; -use ruin_runtime::queue_future; +use ruin_reactivity::{current as current_reactor, effect}; +use ruin_runtime::{queue_future, spawn_worker}; use ruin_ui::{ Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent, @@ -26,7 +26,7 @@ use ruin_ui::{ layout_snapshot_with_cache, }; #[cfg(target_os = "macos")] -use ruin_ui_platform_macos::start_macos_ui; +use ruin_ui_platform_macos::{run_macos_app_event_loop, start_macos_ui}; #[cfg(target_os = "linux")] use ruin_ui_platform_wayland::start_wayland_ui; @@ -98,8 +98,7 @@ impl App { pub fn mount(self, root: M) -> MountedApp { MountedApp { window: self.window, - root: Rc::new(root), - render_state: Rc::new(RenderState::default()), + root, } } } @@ -150,19 +149,18 @@ impl Mountable for Element { pub struct MountedApp { window: Window, - root: Rc, - render_state: Rc, + root: M, } impl MountedApp { - pub async fn run(self) -> Result<()> { - let MountedApp { - window: app_window, - root, - render_state, - } = self; - - let mut ui = start_platform_ui(); + async fn run_with_ui_runtime( + window: Window, + root: M, + mut ui: ruin_ui::UiRuntime, + ) -> Result<()> { + let root = Rc::new(root); + let render_state = Rc::new(RenderState::default()); + let app_window = window; let window = ui.create_window(app_window.spec.clone())?; let initial_viewport = app_window .spec @@ -170,6 +168,7 @@ impl MountedApp { .unwrap_or(UiSize::new(960.0, 640.0)); let viewport = ruin_reactivity::cell(initial_viewport); + let configure_serial = ruin_reactivity::cell(0_u64); let scene_version = StdCell::new(0_u64); let text_system = Rc::new(RefCell::new(TextSystem::new())); let layout_cache = Rc::new(RefCell::new(LayoutCache::new())); @@ -183,6 +182,7 @@ impl MountedApp { let _scene_effect = effect({ let window = window.clone(); let viewport = viewport.clone(); + let configure_serial = configure_serial.clone(); let text_system = Rc::clone(&text_system); let layout_cache = Rc::clone(&layout_cache); let interaction_tree = Rc::clone(&interaction_tree); @@ -194,6 +194,7 @@ impl MountedApp { let text_selection = Rc::clone(&input_state.text_selection); move || { let viewport = viewport.get(); + let _ = configure_serial.get(); let version = scene_version.get().wrapping_add(1); scene_version.set(version); let _ = text_selection.version.get(); @@ -266,6 +267,8 @@ impl MountedApp { "app received Configured, queuing layout effect" ); let _ = viewport.set(configuration.actual_inner_size); + configure_serial.update(|value| *value = value.wrapping_add(1)); + current_reactor().flush_now(); } PlatformEvent::Pointer { window_id, event } if window_id == window.id() => { Self::handle_pointer_event( @@ -276,6 +279,7 @@ impl MountedApp { &mut input_state, event, )?; + current_reactor().flush_now(); } PlatformEvent::Keyboard { window_id, event } if window_id == window.id() => { Self::handle_keyboard_event( @@ -285,11 +289,22 @@ impl MountedApp { &input_state, event, )?; + current_reactor().flush_now(); } PlatformEvent::CloseRequested { window_id } if window_id == window.id() => { + tracing::debug!( + target: "ruin_app::lifecycle", + ?window_id, + "app received CloseRequested, issuing open(false)" + ); let _ = window.update(WindowUpdate::new().open(false)); } PlatformEvent::Closed { window_id } if window_id == window.id() => { + tracing::debug!( + target: "ruin_app::lifecycle", + ?window_id, + "app received Closed, shutting down ui runtime" + ); ui.shutdown()?; return Ok(()); } @@ -301,6 +316,14 @@ impl MountedApp { ui.shutdown()?; Ok(()) } +} + +impl MountedApp { + #[cfg(not(target_os = "macos"))] + pub async fn run(self) -> Result<()> { + let ui = start_platform_ui(); + Self::run_with_ui_runtime(self.window, self.root, ui).await + } fn handle_pointer_event( window: &WindowController, @@ -453,13 +476,46 @@ impl MountedApp { if selection_changed { text_selection.version.update(|value| *value += 1); #[cfg(target_os = "linux")] - sync_primary_selection(_window, interaction_tree, *text_selection.selection.borrow())?; + sync_primary_selection( + _window, + interaction_tree, + *text_selection.selection.borrow(), + )?; } Ok(()) } } +#[cfg(target_os = "macos")] +impl MountedApp { + pub async fn run(self) -> Result<()> { + let ui = start_platform_ui(); + let (done_tx, done_rx) = std::sync::mpsc::channel::>(); + let window = self.window; + let root = self.root; + let _worker = spawn_worker( + move || { + queue_future(async move { + let result = Self::run_with_ui_runtime(window, root, ui) + .await + .map_err(|err| err.to_string()); + let _ = done_tx.send(result); + }); + }, + || {}, + ); + + run_macos_app_event_loop(); + + match done_rx.recv() { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(err.into()), + Err(_) => Err("ui worker terminated before completion".into()), + } + } +} + #[derive(Clone, Default)] pub struct View { element: Element, @@ -1307,6 +1363,10 @@ 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()); @@ -1520,6 +1580,7 @@ where let result = future.await; if generation.get() == next_generation { let _ = resource.state.replace(ResourceState::Ready(result)); + current_reactor().flush_now(); } })); } @@ -1997,10 +2058,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, 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, + 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, }; pub use ruin_ui::{ Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, diff --git a/lib/runtime/src/lib.rs b/lib/runtime/src/lib.rs index c2aaf8d..3ffd7a5 100644 --- a/lib/runtime/src/lib.rs +++ b/lib/runtime/src/lib.rs @@ -77,7 +77,7 @@ pub use platform::current::driver::{ pub use platform::current::runtime::{ IntervalHandle, JoinHandle, ThreadHandle, TimeoutHandle, WorkerHandle, clear_interval, clear_timeout, current_thread_handle, queue_future, queue_microtask, queue_task, run, - set_interval, set_timeout, spawn_worker, yield_now, + run_ready_tasks, run_until_stalled, set_interval, set_timeout, spawn_worker, yield_now, }; /// Public mesh-allocator surface re-exported from the Linux x86_64 backend. #[cfg(all(target_os = "linux", target_arch = "x86_64"))] diff --git a/lib/runtime/src/platform/linux_x86_64/runtime.rs b/lib/runtime/src/platform/linux_x86_64/runtime.rs index d92102e..109b7b7 100644 --- a/lib/runtime/src/platform/linux_x86_64/runtime.rs +++ b/lib/runtime/src/platform/linux_x86_64/runtime.rs @@ -478,6 +478,99 @@ pub fn run() { } } +/// Drains ready work on the current runtime thread without blocking for future work. +/// +/// Unlike [`run`], this returns as soon as there are no immediately runnable +/// microtasks or macrotasks left. It is intended for host integrations that +/// need to re-enter the scheduler while an outer platform loop remains active. +pub fn run_until_stalled() { + let _ = current_thread(); + + loop { + drain_all(); + + let mut microtasks_run: u64 = 0; + while let Some(task) = pop_microtask() { + task(); + microtasks_run += 1; + drain_all(); + } + if microtasks_run >= MICROTASK_STARVATION_THRESHOLD { + tracing::warn!( + target: trace_targets::SCHEDULER, + event = "microtask_starvation", + count = microtasks_run, + "microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved", + ); + } + + if let Some(task) = pop_macrotask() { + task(); + continue; + } + + drain_all(); + + if has_ready_work() { + continue; + } + + current_thread() + .shared + .closing + .store(false, Ordering::Release); + return; + } +} + +/// Drains already-queued work on the current runtime thread without polling the +/// driver for timers or I/O readiness. +/// +/// This is intended for host integrations that need to flush application work +/// from inside a host callback without re-entering timer callbacks. +pub fn run_ready_tasks() { + let _ = current_thread(); + + loop { + drain_remote_tasks(); + drain_completed_workers(); + + let mut microtasks_run: u64 = 0; + while let Some(task) = pop_microtask() { + task(); + microtasks_run += 1; + drain_remote_tasks(); + drain_completed_workers(); + } + if microtasks_run >= MICROTASK_STARVATION_THRESHOLD { + tracing::warn!( + target: trace_targets::SCHEDULER, + event = "microtask_starvation", + count = microtasks_run, + "microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved", + ); + } + + if let Some(task) = pop_macrotask() { + task(); + continue; + } + + drain_remote_tasks(); + drain_completed_workers(); + + if has_ready_work() { + continue; + } + + current_thread() + .shared + .closing + .store(false, Ordering::Release); + return; + } +} + fn drain_all() { drain_driver_events(); drain_remote_tasks(); diff --git a/lib/runtime/src/platform/macos_aarch64/runtime.rs b/lib/runtime/src/platform/macos_aarch64/runtime.rs index f182a2d..1432b48 100644 --- a/lib/runtime/src/platform/macos_aarch64/runtime.rs +++ b/lib/runtime/src/platform/macos_aarch64/runtime.rs @@ -480,6 +480,99 @@ pub fn run() { } } +/// Drains ready work on the current runtime thread without blocking for future work. +/// +/// Unlike [`run`], this returns as soon as there are no immediately runnable +/// microtasks or macrotasks left. It is intended for host integrations that +/// need to re-enter the scheduler while an outer platform loop remains active. +pub fn run_until_stalled() { + let _ = current_thread(); + + loop { + drain_all(); + + let mut microtasks_run: u64 = 0; + while let Some(task) = pop_microtask() { + task(); + microtasks_run += 1; + drain_all(); + } + if microtasks_run >= MICROTASK_STARVATION_THRESHOLD { + tracing::warn!( + target: trace_targets::SCHEDULER, + event = "microtask_starvation", + count = microtasks_run, + "microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved", + ); + } + + if let Some(task) = pop_macrotask() { + task(); + continue; + } + + drain_all(); + + if has_ready_work() { + continue; + } + + current_thread() + .shared + .closing + .store(false, Ordering::Release); + return; + } +} + +/// Drains already-queued work on the current runtime thread without polling the +/// driver for timers or I/O readiness. +/// +/// This is intended for host integrations that need to flush application work +/// from inside a host callback without re-entering timer callbacks. +pub fn run_ready_tasks() { + let _ = current_thread(); + + loop { + drain_remote_tasks(); + drain_completed_workers(); + + let mut microtasks_run: u64 = 0; + while let Some(task) = pop_microtask() { + task(); + microtasks_run += 1; + drain_remote_tasks(); + drain_completed_workers(); + } + if microtasks_run >= MICROTASK_STARVATION_THRESHOLD { + tracing::warn!( + target: trace_targets::SCHEDULER, + event = "microtask_starvation", + count = microtasks_run, + "microtask queue ran {microtasks_run} tasks in a single turn; macrotask handlers may be starved", + ); + } + + if let Some(task) = pop_macrotask() { + task(); + continue; + } + + drain_remote_tasks(); + drain_completed_workers(); + + if has_ready_work() { + continue; + } + + current_thread() + .shared + .closing + .store(false, Ordering::Release); + return; + } +} + fn drain_all() { drain_driver_events(); drain_remote_tasks(); diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 1760e7e..4a33477 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -270,20 +270,14 @@ fn layout_element( && !rects_overlap(rect, clip) { perf_stats.viewport_culled += 1; - return LayoutNode { - path, - element_id: element.id, + return layout_interaction_skeleton( + element, rect, - corner_radius: 0.0, - clip_rect: None, - pointer_events: element.style.pointer_events, - focusable: element.style.focusable, - cursor: element.style.cursor.unwrap_or(CursorIcon::Default), - scroll_metrics: None, - prepared_image: None, - prepared_text: None, - children: Vec::new(), - }; + path, + text_system, + perf_stats, + layout_cache, + ); } // Incremental layout cache check. @@ -295,6 +289,7 @@ fn layout_element( subtree_hash, avail_width_bits: rect.size.width.round() as u32, avail_height_bits: rect.size.height.round() as u32, + clip_rect_bits: cache_clip_rect_bits(rect, clip_rect), }; if let Some(cached) = layout_cache.results.get(&cache_key) { let offset = rect.origin; @@ -888,6 +883,210 @@ fn layout_container_children( children } +fn layout_interaction_skeleton( + element: &Element, + rect: Rect, + path: LayoutPath, + text_system: &mut TextSystem, + perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, +) -> LayoutNode { + let cursor = element.style.cursor.unwrap_or_else(|| { + if element.text_node().is_some() { + CursorIcon::Text + } else { + CursorIcon::Default + } + }); + let mut interaction = LayoutNode { + path, + element_id: element.id, + rect, + corner_radius: uniform_corner_radius(&element.style, rect), + clip_rect: None, + pointer_events: element.style.pointer_events, + focusable: element.style.focusable, + cursor, + scroll_metrics: None, + prepared_image: None, + prepared_text: None, + children: Vec::new(), + }; + + if rect.size.width <= 0.0 || rect.size.height <= 0.0 { + return interaction; + } + + if let Some(scroll_box) = element.scroll_box_node() { + let content = inset_rect(rect, content_insets(&element.style)); + if content.size.width <= 0.0 || content.size.height <= 0.0 { + return interaction; + } + let gutter_width = scroll_box + .scrollbar + .gutter_width + .max(0.0) + .min(content.size.width); + let viewport_rect = Rect::new( + content.origin.x, + content.origin.y, + (content.size.width - gutter_width).max(0.0), + content.size.height, + ); + interaction.clip_rect = Some(viewport_rect); + let content_size = intrinsic_container_content_size( + element, + UiSize::new( + viewport_rect.size.width.max(0.0), + viewport_rect.size.height.max(0.0), + ), + text_system, + perf_stats, + layout_cache, + ); + let content_height = content_size.height.max(viewport_rect.size.height); + let offset_y = scroll_box + .offset_y + .max(0.0) + .min((content_height - viewport_rect.size.height).max(0.0)); + interaction.children = layout_interaction_skeleton_children( + element, + Rect::new( + viewport_rect.origin.x, + viewport_rect.origin.y - offset_y, + viewport_rect.size.width, + content_height, + ), + &interaction.path, + text_system, + perf_stats, + layout_cache, + ); + let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry( + content, + scroll_box.scrollbar, + content_height, + viewport_rect.size.height, + offset_y, + ); + interaction.scroll_metrics = Some(ScrollMetrics { + viewport_rect, + content_height, + offset_y, + max_offset_y: (content_height - viewport_rect.size.height).max(0.0), + scrollbar_track, + scrollbar_thumb, + }); + return interaction; + } + + if element.text_node().is_some() + || element.image_node().is_some() + || element.children.is_empty() + { + return interaction; + } + + let content = inset_rect(rect, content_insets(&element.style)); + interaction.children = layout_interaction_skeleton_children( + element, + content, + &interaction.path, + text_system, + perf_stats, + layout_cache, + ); + interaction +} + +fn layout_interaction_skeleton_children( + element: &Element, + content: Rect, + path: &LayoutPath, + text_system: &mut TextSystem, + perf_stats: &mut LayoutPerfStats, + layout_cache: &mut LayoutCache, +) -> Vec { + if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 { + return Vec::new(); + } + + let gap_count = element.children.len().saturating_sub(1) as f32; + let total_gap = element.style.gap * gap_count; + let available_main = + (main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0); + let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0); + + let mut measured_children = Vec::with_capacity(element.children.len()); + let mut fixed_total = 0.0; + let mut flex_total = 0.0; + for child in &element.children { + let cross = child_cross_size(child, element.style.direction) + .unwrap_or(available_cross) + .clamp(0.0, available_cross); + let explicit_main = + child_main_size(child, element.style.direction).map(|main| main.max(0.0)); + let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0; + let measured_main = explicit_main.unwrap_or_else(|| { + if is_flex { + 0.0 + } else { + intrinsic_main_size( + child, + element.style.direction, + cross, + available_main, + text_system, + perf_stats, + layout_cache, + ) + } + }); + if is_flex { + flex_total += child_flex_weight(child); + } else { + fixed_total += measured_main; + } + measured_children.push(MeasuredChild { + cross, + main: measured_main, + is_flex, + }); + } + + let remaining_main = (available_main - fixed_total).max(0.0); + let mut cursor = main_axis_origin(content, element.style.direction); + let mut children = Vec::with_capacity(element.children.len()); + for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { + let child_main = if measured.is_flex { + if flex_total <= 0.0 { + 0.0 + } else { + remaining_main * (child_flex_weight(child) / flex_total) + } + } else { + measured.main + }; + let child_rect = child_rect( + content, + element.style.direction, + cursor, + child_main.max(0.0), + measured.cross, + ); + children.push(layout_interaction_skeleton( + child, + child_rect, + path.child(index), + text_system, + perf_stats, + layout_cache, + )); + cursor += child_main.max(0.0) + element.style.gap; + } + children +} + fn intrinsic_main_size( child: &Element, direction: FlexDirection, @@ -1147,6 +1346,7 @@ struct LayoutCacheKey { subtree_hash: u64, avail_width_bits: u32, avail_height_bits: u32, + clip_rect_bits: Option<(u32, u32, u32, u32)>, } struct CachedLayout { @@ -1208,6 +1408,16 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { && a.origin.y + a.size.height > b.origin.y } +fn cache_clip_rect_bits(rect: Rect, clip_rect: Option) -> Option<(u32, u32, u32, u32)> { + let clip = clip_rect?; + Some(( + (clip.origin.x - rect.origin.x).round().to_bits(), + (clip.origin.y - rect.origin.y).round().to_bits(), + clip.size.width.round().to_bits(), + clip.size.height.round().to_bits(), + )) +} + fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option) -> PreparedImage { let source_size = image.resource.size(); let source_aspect = if source_size.height > 0.0 { @@ -1599,7 +1809,9 @@ fn child_rect( #[cfg(test)] mod tests { - use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache}; + use super::{ + LayoutCache, LayoutSnapshot, layout_scene, layout_snapshot, layout_snapshot_with_cache, + }; use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize}; use crate::text::TextSystem; use crate::text::{TextStyle, TextWrap}; @@ -2254,7 +2466,6 @@ mod tests { #[test] fn scroll_culling_skips_off_screen_children() { - use crate::tree::ScrollbarStyle; let mut text_system = TextSystem::new(); let mut layout_cache = LayoutCache::new(); @@ -2296,4 +2507,108 @@ mod tests { "expected >= 4 text items visible, got {text_items}" ); } + + #[test] + fn cached_scroll_container_recomputes_visible_children_after_offset_changes() { + fn scroll_root(offset_y: f32) -> Element { + let mut list = Element::column().gap(0.0); + for i in 0..20 { + list = list.child( + Element::new() + .height(40.0) + .child(Element::text(format!("item {i:02}"), text_style())), + ); + } + Element::new().child( + Element::scroll_box(offset_y) + .width(400.0) + .height(200.0) + .child(list), + ) + } + + fn visible_texts(snapshot: &LayoutSnapshot) -> Vec<&str> { + snapshot + .scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect() + } + + let mut text_system = TextSystem::new(); + let mut layout_cache = LayoutCache::new(); + let size = UiSize::new(400.0, 200.0); + + let first = layout_snapshot_with_cache( + 1, + size, + &scroll_root(0.0), + &mut text_system, + &mut layout_cache, + ); + let second = layout_snapshot_with_cache( + 2, + size, + &scroll_root(240.0), + &mut text_system, + &mut layout_cache, + ); + + let first_visible = visible_texts(&first); + let second_visible = visible_texts(&second); + assert!(first_visible.iter().any(|text| text.contains("item 00"))); + assert!( + second_visible.iter().any(|text| text.contains("item 06")), + "expected scrolled snapshot to include newly visible rows, got {second_visible:?}" + ); + assert!( + !second_visible.iter().any(|text| text.contains("item 00")), + "expected initial rows to be culled after scrolling, got {second_visible:?}" + ); + } + + #[test] + fn culled_container_keeps_descendant_geometry_for_scroll_into_view() { + let target_id = ElementId::new(42); + let root = Element::new().child( + Element::scroll_box(0.0).width(400.0).height(120.0).child( + Element::column() + .child( + Element::column() + .height(160.0) + .child(Element::text("visible group", text_style())), + ) + .child( + Element::column().height(160.0).child( + Element::new() + .id(target_id) + .height(40.0) + .child(Element::text("offscreen target", text_style())), + ), + ), + ), + ); + + let snapshot = layout_snapshot(1, UiSize::new(400.0, 120.0), &root); + + assert!( + snapshot + .scene + .items + .iter() + .all(|item| !matches!(item, DisplayItem::Text(text) if text.text.contains("offscreen target"))), + "offscreen target text should still be scene-culled" + ); + assert!( + snapshot + .interaction_tree + .rect_for_element(target_id) + .is_some(), + "offscreen descendants must keep geometry for widget-ref scrolling" + ); + } } diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 6dcb844..4843c57 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -36,7 +36,7 @@ pub use layout::{ pub use layout::{layout_scene, layout_scene_with_text_system}; pub use platform::{ PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest, - PlatformRuntime, start_headless, + PlatformRuntime, set_command_wake_hook, start_headless, }; pub use runtime::{EventStreamClosed, UiRuntime, WindowController}; pub use scene::{ diff --git a/lib/ui/src/platform.rs b/lib/ui/src/platform.rs index 632dab2..098aee5 100644 --- a/lib/ui/src/platform.rs +++ b/lib/ui/src/platform.rs @@ -4,8 +4,8 @@ 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 std::sync::{Arc, Mutex, OnceLock}; use ruin_runtime::channel::mpsc; use ruin_runtime::{WorkerHandle, queue_future, queue_microtask, spawn_worker}; @@ -18,6 +18,13 @@ use crate::trace_targets; use crate::tree::CursorIcon; use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate}; +type CommandWakeHook = Arc; + +fn command_wake_hook() -> &'static Mutex> { + static HOOK: OnceLock>> = OnceLock::new(); + HOOK.get_or_init(|| Mutex::new(None)) +} + #[derive(Clone)] pub struct PlatformProxy { command_tx: mpsc::UnboundedSender, @@ -346,7 +353,20 @@ impl PlatformProxy { } fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> { - self.command_tx.send(command).map_err(|_| PlatformClosed) + self.command_tx.send(command).map_err(|_| PlatformClosed)?; + if let Ok(hook) = command_wake_hook().lock() + && let Some(hook) = hook.as_ref().cloned() + { + hook(); + } + Ok(()) + } +} + +#[doc(hidden)] +pub fn set_command_wake_hook(hook: Option) { + if let Ok(mut slot) = command_wake_hook().lock() { + *slot = hook; } } diff --git a/lib/ui_platform_macos/Cargo.toml b/lib/ui_platform_macos/Cargo.toml index 358a1d6..ea67892 100644 --- a/lib/ui_platform_macos/Cargo.toml +++ b/lib/ui_platform_macos/Cargo.toml @@ -8,7 +8,9 @@ libc = "0.2" objc2 = "0.6.4" objc2-core-foundation = "0.3.2" objc2-foundation = "0.3.2" +objc2-quartz-core = "0.3.2" raw-window-handle = "0.6" +raw-window-metal = "1.1.0" ruin_ui = { path = "../ui" } ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" } ruin_runtime = { package = "ruin-runtime", path = "../runtime" } diff --git a/lib/ui_platform_macos/src/lib.rs b/lib/ui_platform_macos/src/lib.rs index 304aa98..eb01a66 100644 --- a/lib/ui_platform_macos/src/lib.rs +++ b/lib/ui_platform_macos/src/lib.rs @@ -2,32 +2,35 @@ //! //! Native backend implemented on top of Cocoa/AppKit runtime messaging. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::BTreeMap; -use std::collections::HashSet; use std::ffi::{CStr, CString, c_void}; -use std::num::NonZeroU32; use std::ptr::NonNull; use std::rc::Rc; -use std::sync::{Mutex, Once, OnceLock}; -use std::time::Duration; +use std::sync::{Arc, Mutex, Once, OnceLock}; +use objc2::rc::Retained; use objc2::runtime::{AnyClass, AnyObject, Bool, ClassBuilder, Sel}; use objc2::sel; -use objc2_core_foundation::{CGPoint, CGRect, CGSize}; +use objc2_core_foundation::{ + CFRetained, CFRunLoop, CFRunLoopSource, CFRunLoopSourceContext, CGPoint, CGRect, CGSize, + kCFRunLoopCommonModes, +}; use objc2_foundation::{NSString, ns_string}; +use objc2_quartz_core::{CAMetalLayer, kCAGravityTopLeft}; use raw_window_handle::{ AppKitDisplayHandle, AppKitWindowHandle, DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle, }; -use ruin_runtime::{queue_future, set_interval}; +use raw_window_metal::Layer as MetalLayer; use ruin_ui::{ - CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, - PlatformEndpoint, PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, - PointerEvent, PointerEventKind, SceneSnapshot, UiRuntime, UiSize, WindowConfigured, WindowId, - WindowLifecycle, WindowSpec, WindowUpdate, + CursorIcon, KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers, PlatformEndpoint, + PlatformEvent, PlatformRequest, PlatformRuntime, Point, PointerButton, PointerEvent, + PointerEventKind, SceneSnapshot, UiRuntime, UiSize, WindowConfigured, WindowId, + WindowLifecycle, WindowSpec, WindowUpdate, set_command_wake_hook, }; use ruin_ui_renderer_wgpu::{RenderError, WgpuSceneRenderer}; +use tracing::{debug, trace}; #[link(name = "AppKit", kind = "framework")] unsafe extern "C" {} @@ -40,21 +43,8 @@ const NS_WINDOW_STYLE_MINIATURIZABLE: usize = 1 << 2; const NS_WINDOW_STYLE_RESIZABLE: usize = 1 << 3; const NS_VIEW_WIDTH_SIZABLE: usize = 1 << 1; const NS_VIEW_HEIGHT_SIZABLE: usize = 1 << 4; - -const NSEVENT_TYPE_LEFT_MOUSE_DOWN: usize = 1; -const NSEVENT_TYPE_LEFT_MOUSE_UP: usize = 2; -const NSEVENT_TYPE_RIGHT_MOUSE_DOWN: usize = 3; -const NSEVENT_TYPE_RIGHT_MOUSE_UP: usize = 4; -const NSEVENT_TYPE_MOUSE_MOVED: usize = 5; -const NSEVENT_TYPE_LEFT_MOUSE_DRAGGED: usize = 6; -const NSEVENT_TYPE_RIGHT_MOUSE_DRAGGED: usize = 7; -const NSEVENT_TYPE_MOUSE_EXITED: usize = 9; -const NSEVENT_TYPE_KEY_DOWN: usize = 10; -const NSEVENT_TYPE_KEY_UP: usize = 11; -const NSEVENT_TYPE_SCROLL_WHEEL: usize = 22; -const NSEVENT_TYPE_OTHER_MOUSE_DOWN: usize = 25; -const NSEVENT_TYPE_OTHER_MOUSE_UP: usize = 26; -const NSEVENT_TYPE_OTHER_MOUSE_DRAGGED: usize = 27; +const NS_VIEW_LAYER_CONTENTS_REDRAW_DURING_VIEW_RESIZE: usize = 2; +const NS_VIEW_LAYER_CONTENTS_PLACEMENT_TOP_LEFT: usize = 11; const NSEVENT_MODIFIER_FLAG_SHIFT: usize = 1 << 17; const NSEVENT_MODIFIER_FLAG_CONTROL: usize = 1 << 18; @@ -63,10 +53,24 @@ const NSEVENT_MODIFIER_FLAG_COMMAND: usize = 1 << 20; struct MacosState { app: *mut AnyObject, + commands: ruin_runtime::channel::mpsc::Receiver, endpoint_events: ruin_runtime::channel::mpsc::UnboundedSender, + _run_loop_source: RunLoopSourceRegistration, + shutdown_requested: bool, windows: BTreeMap, } +struct RunLoopSourceRegistration { + _run_loop: CFRetained, + _source: CFRetained, +} + +#[derive(Clone, Copy)] +struct RunLoopWakeHandle { + run_loop: usize, + source: usize, +} + struct MacosWindow { spec: WindowSpec, lifecycle: WindowLifecycle, @@ -77,6 +81,8 @@ struct MacosWindow { cursor_icon: CursorIcon, close_requested_sent: bool, close_in_progress: bool, + has_presented_frame: bool, + resize_pipeline: ResizePipeline, native: Option, } @@ -85,6 +91,7 @@ struct NativeWindow { #[allow(dead_code)] view: *mut AnyObject, delegate: *mut AnyObject, + _metal_layer: Retained, renderer: Option, } @@ -92,7 +99,79 @@ struct NativeWindow { struct DelegateState { pending_close_requests: Vec, pending_closed_windows: Vec, - allowed_programmatic_closes: HashSet, + pending_resize_events: Vec, + pending_input_events: Vec, +} + +#[derive(Clone, Copy)] +struct DelegateResizeEvent { + window_ptr: usize, + size: UiSize, + scale_factor: f32, +} + +enum DelegateInputEvent { + Pointer { + window_ptr: usize, + event: PointerEvent, + }, + Keyboard { + window_ptr: usize, + event: KeyboardEvent, + }, +} + +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> { + static HANDLE: OnceLock>> = OnceLock::new(); + HANDLE.get_or_init(|| Mutex::new(None)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct ResizePipeline { + active_resize: Option, + next_resize: Option, +} + +impl ResizePipeline { + fn note_resize(&mut self, viewport: UiSize) -> Option { + if self.active_resize.is_some() { + self.next_resize = Some(viewport); + return None; + } + self.active_resize = Some(viewport); + Some(viewport) + } + + fn ensure_initial_request(&mut self, viewport: UiSize) -> Option { + if self.active_resize.is_some() || self.next_resize.is_some() { + return None; + } + self.active_resize = Some(viewport); + Some(viewport) + } + + fn note_presented_scene(&mut self, scene_viewport: UiSize) -> Option { + if self.active_resize != Some(scene_viewport) { + return None; + } + + self.active_resize = None; + if self.next_resize == Some(scene_viewport) { + self.next_resize = None; + } + + let Some(next_resize) = self.next_resize.take() else { + return None; + }; + self.active_resize = Some(next_resize); + Some(next_resize) + } } impl MacosWindow { @@ -117,6 +196,8 @@ impl MacosWindow { cursor_icon: CursorIcon::Default, close_requested_sent: false, close_in_progress: false, + has_presented_frame: false, + resize_pipeline: ResizePipeline::default(), native: None, } } @@ -153,71 +234,20 @@ impl HasWindowHandle for AppKitSurfaceTarget { pub fn start_macos_platform() -> PlatformRuntime { assert_main_thread(); let app = ensure_ns_application(); + let run_loop_source = + install_platform_run_loop_source().expect("macOS run-loop source must install"); - PlatformRuntime::custom_local(|mut endpoint: PlatformEndpoint| { + PlatformRuntime::custom_local(|endpoint: PlatformEndpoint| { let state = Rc::new(RefCell::new(MacosState { app, + commands: endpoint.commands, endpoint_events: endpoint.events.clone(), + _run_loop_source: run_loop_source, + shutdown_requested: false, windows: BTreeMap::new(), })); - - // Drive AppKit in non-blocking mode from the runtime loop. - let state_for_pump = Rc::clone(&state); - let _pump = set_interval(Duration::from_millis(8), move || { - pump_appkit(&state_for_pump); - }); - - queue_future(async move { - while let Some(command) = endpoint.commands.recv().await { - match command { - PlatformRequest::CreateWindow { window_id, spec } => { - handle_create_window(&state, window_id, spec); - } - PlatformRequest::UpdateWindow { window_id, update } => { - handle_update_window(&state, window_id, update); - } - PlatformRequest::ReplaceScene { window_id, scene } => { - handle_replace_scene(&state, window_id, scene); - } - PlatformRequest::SetClipboardText { window_id, text } => { - if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { - window.clipboard_text = Some(text); - } - } - PlatformRequest::RequestClipboardText { window_id } => { - let text = state - .borrow() - .windows - .get(&window_id) - .and_then(|window| window.clipboard_text.clone()) - .unwrap_or_default(); - emit(&state, PlatformEvent::ClipboardText { window_id, text }); - } - PlatformRequest::SetCursorIcon { window_id, cursor } => { - if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { - window.cursor_icon = cursor; - apply_cursor_icon(cursor); - } - } - PlatformRequest::EmitCloseRequested { window_id } => { - emit(&state, PlatformEvent::CloseRequested { window_id }); - } - PlatformRequest::EmitPointerEvent { window_id, event } => { - emit(&state, PlatformEvent::Pointer { window_id, event }); - } - PlatformRequest::EmitKeyboardEvent { window_id, event } => { - emit(&state, PlatformEvent::Keyboard { window_id, event }); - } - PlatformRequest::EmitWake { window_id, token } => { - emit(&state, PlatformEvent::Wake { window_id, token }); - } - #[cfg(target_os = "linux")] - PlatformRequest::SetPrimarySelectionText { .. } => {} - #[cfg(target_os = "linux")] - PlatformRequest::RequestPrimarySelectionText { .. } => {} - PlatformRequest::Shutdown => break, - } - } + ACTIVE_MACOS_STATE.with(|slot| { + *slot.borrow_mut() = Some(Rc::clone(&state)); }); }) } @@ -227,6 +257,64 @@ pub fn start_macos_ui() -> UiRuntime { UiRuntime::from_platform(start_macos_platform()) } +pub fn run_macos_app_event_loop() { + assert_main_thread(); + let app = ensure_ns_application(); + unsafe { + let _: () = objc2::msg_send![app, run]; + } +} + +fn install_platform_run_loop_source() -> Option { + let run_loop = CFRunLoop::main()?; + let mut context = CFRunLoopSourceContext { + version: 0, + info: std::ptr::null_mut(), + retain: None, + release: None, + copyDescription: None, + equal: None, + hash: None, + schedule: None, + cancel: None, + perform: Some(platform_run_loop_source_perform), + }; + let source = unsafe { CFRunLoopSource::new(None, 0, &mut context) }?; + let common_modes = unsafe { kCFRunLoopCommonModes }; + run_loop.add_source(Some(&source), common_modes); + + let handle = RunLoopWakeHandle { + run_loop: CFRetained::as_ptr(&run_loop).as_ptr().cast::() as usize, + source: CFRetained::as_ptr(&source).as_ptr().cast::() as usize, + }; + if let Ok(mut slot) = run_loop_wake_handle().lock() { + *slot = Some(handle); + } + set_command_wake_hook(Some(Arc::new(signal_platform_run_loop_source))); + + Some(RunLoopSourceRegistration { + _run_loop: run_loop, + _source: source, + }) +} + +extern "C-unwind" fn platform_run_loop_source_perform(_info: *mut c_void) { + let Some(state) = ACTIVE_MACOS_STATE.with(|slot| slot.borrow().as_ref().cloned()) else { + return; + }; + drain_macos_run_loop_work(&state); +} + +fn signal_platform_run_loop_source() { + let Some(handle) = run_loop_wake_handle().lock().ok().and_then(|slot| *slot) else { + return; + }; + let source = unsafe { &*(handle.source as *const CFRunLoopSource) }; + let run_loop = unsafe { &*(handle.run_loop as *const CFRunLoop) }; + source.signal(); + run_loop.wake_up(); +} + fn assert_main_thread() { let is_main = unsafe { libc::pthread_main_np() == 1 }; assert!( @@ -236,7 +324,9 @@ fn assert_main_thread() { } fn ensure_ns_application() -> *mut AnyObject { - let app: *mut AnyObject = unsafe { objc2::msg_send![objc2::class!(NSApplication), sharedApplication] }; + let app: *mut AnyObject = + unsafe { objc2::msg_send![objc2::class!(NSApplication), sharedApplication] }; + install_app_delegate(app); unsafe { let _: bool = objc2::msg_send![app, setActivationPolicy: NS_APPLICATION_ACTIVATION_POLICY_REGULAR]; @@ -246,6 +336,56 @@ fn ensure_ns_application() -> *mut AnyObject { app } +fn app_delegate_class() -> &'static AnyClass { + static REGISTER: Once = Once::new(); + static CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); + + REGISTER.call_once(|| { + extern "C-unwind" fn terminate_after_last_window_closed( + _this: &AnyObject, + _cmd: Sel, + _sender: *mut AnyObject, + ) -> Bool { + Bool::YES + } + + let class_name = CString::new("RuinAppDelegate").expect("class name is valid"); + if let Some(mut builder) = ClassBuilder::new(&class_name, objc2::class!(NSObject)) { + unsafe { + let method: extern "C-unwind" fn(_, _, _) -> _ = terminate_after_last_window_closed; + builder.add_method::( + sel!(applicationShouldTerminateAfterLastWindowClosed:), + method, + ); + } + let _ = CLASS.set(builder.register()); + } else { + let existing = + AnyClass::get(class_name.as_c_str()).expect("RuinAppDelegate class must exist"); + let _ = CLASS.set(existing); + } + }); + + CLASS + .get() + .expect("RuinAppDelegate class registration must succeed") +} + +fn install_app_delegate(app: *mut AnyObject) { + static INSTANCE: OnceLock = OnceLock::new(); + let delegate = *INSTANCE.get_or_init(|| { + let class = app_delegate_class(); + let delegate_obj: *mut AnyObject = unsafe { objc2::msg_send![class, new] }; + delegate_obj as usize + }) as *mut AnyObject; + if delegate.is_null() { + return; + } + unsafe { + let _: () = objc2::msg_send![app, setDelegate: delegate]; + } +} + fn delegate_state() -> &'static Mutex { static DELEGATE_STATE: OnceLock> = OnceLock::new(); DELEGATE_STATE.get_or_init(|| Mutex::new(DelegateState::default())) @@ -269,23 +409,45 @@ fn queue_delegate_window_closed(window: *mut AnyObject) { } } -fn mark_programmatic_window_close(window: *mut AnyObject) { +fn queue_delegate_resize(window: *mut AnyObject, size: UiSize, scale_factor: f32) { if window.is_null() { return; } if let Ok(mut state) = delegate_state().lock() { - state.allowed_programmatic_closes.insert(window as usize); + state.pending_resize_events.push(DelegateResizeEvent { + window_ptr: window as usize, + size, + scale_factor, + }); } } -fn consume_programmatic_close(window: *mut AnyObject) -> bool { +fn queue_delegate_pointer_event(window: *mut AnyObject, event: PointerEvent) { if window.is_null() { - return false; + return; } if let Ok(mut state) = delegate_state().lock() { - return state.allowed_programmatic_closes.remove(&(window as usize)); + state + .pending_input_events + .push(DelegateInputEvent::Pointer { + window_ptr: window as usize, + event, + }); + } +} + +fn queue_delegate_keyboard_event(window: *mut AnyObject, event: KeyboardEvent) { + if window.is_null() { + return; + } + if let Ok(mut state) = delegate_state().lock() { + state + .pending_input_events + .push(DelegateInputEvent::Keyboard { + window_ptr: window as usize, + event, + }); } - false } fn take_delegate_close_requests() -> Vec { @@ -302,6 +464,20 @@ fn take_delegate_closed_windows() -> Vec { Vec::new() } +fn take_delegate_resize_events() -> Vec { + if let Ok(mut state) = delegate_state().lock() { + return std::mem::take(&mut state.pending_resize_events); + } + Vec::new() +} + +fn take_delegate_input_events() -> Vec { + if let Ok(mut state) = delegate_state().lock() { + return std::mem::take(&mut state.pending_input_events); + } + Vec::new() +} + fn window_delegate_class() -> &'static AnyClass { static REGISTER: Once = Once::new(); static CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); @@ -312,20 +488,106 @@ fn window_delegate_class() -> &'static AnyClass { _cmd: Sel, sender: *mut AnyObject, ) -> Bool { - if consume_programmatic_close(sender) { - Bool::YES - } else { - queue_delegate_close_request(sender); - Bool::NO - } + debug!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = sender as usize, + "windowShouldClose:" + ); + queue_delegate_close_request(sender); + flush_delegate_events_immediately(); + Bool::YES } - extern "C-unwind" fn did_close( + extern "C-unwind" fn did_close(_this: &AnyObject, _cmd: Sel, notification: *mut AnyObject) { + let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; + debug!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = window as usize, + "windowWillClose:" + ); + queue_delegate_window_closed(window); + flush_delegate_events_immediately(); + } + extern "C-unwind" fn did_resize( _this: &AnyObject, _cmd: Sel, notification: *mut AnyObject, ) { let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; - queue_delegate_window_closed(window); + if window.is_null() { + return; + } + let view: *mut AnyObject = unsafe { objc2::msg_send![window, contentView] }; + if view.is_null() { + return; + } + let frame: CGRect = unsafe { objc2::msg_send![view, frame] }; + let scale_factor: f64 = unsafe { objc2::msg_send![window, backingScaleFactor] }; + trace!( + target: "ruin_ui_platform_macos::resize", + window_ptr = window as usize, + width = frame.size.width, + height = frame.size.height, + scale_factor, + "windowDidResize:" + ); + queue_delegate_resize( + window, + UiSize::new(frame.size.width as f32, frame.size.height as f32), + (scale_factor as f32).max(1.0), + ); + flush_delegate_events_immediately(); + } + extern "C-unwind" fn did_change_occlusion_state( + _this: &AnyObject, + _cmd: Sel, + notification: *mut AnyObject, + ) { + let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; + trace!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = window as usize, + "windowDidChangeOcclusionState:" + ); + signal_platform_run_loop_source(); + } + extern "C-unwind" fn did_become_key( + _this: &AnyObject, + _cmd: Sel, + notification: *mut AnyObject, + ) { + let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; + trace!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = window as usize, + "windowDidBecomeKey:" + ); + signal_platform_run_loop_source(); + } + extern "C-unwind" fn did_miniaturize( + _this: &AnyObject, + _cmd: Sel, + notification: *mut AnyObject, + ) { + let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; + trace!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = window as usize, + "windowDidMiniaturize:" + ); + signal_platform_run_loop_source(); + } + extern "C-unwind" fn did_deminiaturize( + _this: &AnyObject, + _cmd: Sel, + notification: *mut AnyObject, + ) { + let window: *mut AnyObject = unsafe { objc2::msg_send![notification, object] }; + trace!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = window as usize, + "windowDidDeminiaturize:" + ); + signal_platform_run_loop_source(); } let class_name = CString::new("RuinWindowDelegate").expect("class name is valid"); @@ -335,6 +597,16 @@ fn window_delegate_class() -> &'static AnyClass { builder.add_method::(sel!(windowShouldClose:), method); let method: extern "C-unwind" fn(_, _, _) = did_close; builder.add_method::(sel!(windowWillClose:), method); + let method: extern "C-unwind" fn(_, _, _) = did_resize; + builder.add_method::(sel!(windowDidResize:), method); + let method: extern "C-unwind" fn(_, _, _) = did_change_occlusion_state; + builder.add_method::(sel!(windowDidChangeOcclusionState:), method); + let method: extern "C-unwind" fn(_, _, _) = did_become_key; + builder.add_method::(sel!(windowDidBecomeKey:), method); + let method: extern "C-unwind" fn(_, _, _) = did_miniaturize; + builder.add_method::(sel!(windowDidMiniaturize:), method); + let method: extern "C-unwind" fn(_, _, _) = did_deminiaturize; + builder.add_method::(sel!(windowDidDeminiaturize:), method); } let _ = CLASS.set(builder.register()); } else { @@ -349,80 +621,389 @@ fn window_delegate_class() -> &'static AnyClass { .expect("RuinWindowDelegate class registration must succeed") } -fn pump_appkit(state: &Rc>) { +fn content_view_class() -> &'static AnyClass { + static REGISTER: Once = Once::new(); + static CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); + + REGISTER.call_once(|| { + extern "C-unwind" fn accepts_first_responder(_this: &AnyObject, _cmd: Sel) -> Bool { + Bool::YES + } + + extern "C-unwind" fn is_flipped(_this: &AnyObject, _cmd: Sel) -> Bool { + Bool::YES + } + + 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(); + } + + extern "C-unwind" fn left_mouse_down(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { + queue_pointer_from_view_event( + this, + event, + PointerEventKind::Down { + button: PointerButton::Primary, + }, + ); + flush_delegate_events_immediately(); + } + + extern "C-unwind" fn left_mouse_up(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { + queue_pointer_from_view_event( + this, + event, + PointerEventKind::Up { + button: PointerButton::Primary, + }, + ); + flush_delegate_events_immediately(); + } + + extern "C-unwind" fn left_mouse_dragged( + this: &AnyObject, + _cmd: Sel, + event: *mut AnyObject, + ) { + queue_pointer_from_view_event(this, event, PointerEventKind::Move); + flush_delegate_events_immediately(); + } + + extern "C-unwind" fn other_mouse_down(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { + let button_number: usize = unsafe { objc2::msg_send![event, buttonNumber] }; + if button_number == 2 { + queue_pointer_from_view_event( + this, + event, + PointerEventKind::Down { + button: PointerButton::Middle, + }, + ); + flush_delegate_events_immediately(); + } + } + + extern "C-unwind" fn other_mouse_up(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { + let button_number: usize = unsafe { objc2::msg_send![event, buttonNumber] }; + if button_number == 2 { + queue_pointer_from_view_event( + this, + event, + PointerEventKind::Up { + button: PointerButton::Middle, + }, + ); + flush_delegate_events_immediately(); + } + } + + extern "C-unwind" fn other_mouse_dragged( + this: &AnyObject, + _cmd: Sel, + event: *mut AnyObject, + ) { + 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(); + } + } + + extern "C-unwind" fn scroll_wheel(this: &AnyObject, _cmd: Sel, event: *mut AnyObject) { + let delta_x: f64 = unsafe { objc2::msg_send![event, scrollingDeltaX] }; + let delta_y: f64 = unsafe { objc2::msg_send![event, scrollingDeltaY] }; + queue_pointer_from_view_event( + this, + event, + PointerEventKind::Scroll { + delta: Point::new(delta_x as f32, -(delta_y as f32)), + }, + ); + flush_delegate_events_immediately(); + } + + 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(); + } + + 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(); + } + + let class_name = CString::new("RuinContentView").expect("class name is valid"); + if let Some(mut builder) = ClassBuilder::new(&class_name, objc2::class!(NSView)) { + unsafe { + let method: extern "C-unwind" fn(_, _) -> _ = accepts_first_responder; + builder.add_method::(sel!(acceptsFirstResponder), method); + let method: extern "C-unwind" fn(_, _) -> _ = is_flipped; + builder.add_method::(sel!(isFlipped), method); + let method: extern "C-unwind" fn(_, _, _) = mouse_moved; + builder.add_method::(sel!(mouseMoved:), method); + let method: extern "C-unwind" fn(_, _, _) = mouse_moved; + builder.add_method::(sel!(rightMouseDragged:), method); + let method: extern "C-unwind" fn(_, _, _) = left_mouse_down; + builder.add_method::(sel!(mouseDown:), method); + let method: extern "C-unwind" fn(_, _, _) = left_mouse_up; + builder.add_method::(sel!(mouseUp:), method); + let method: extern "C-unwind" fn(_, _, _) = left_mouse_dragged; + builder.add_method::(sel!(mouseDragged:), method); + let method: extern "C-unwind" fn(_, _, _) = other_mouse_down; + builder.add_method::(sel!(otherMouseDown:), method); + let method: extern "C-unwind" fn(_, _, _) = other_mouse_up; + builder.add_method::(sel!(otherMouseUp:), method); + let method: extern "C-unwind" fn(_, _, _) = other_mouse_dragged; + builder.add_method::(sel!(otherMouseDragged:), method); + let method: extern "C-unwind" fn(_, _, _) = scroll_wheel; + builder.add_method::(sel!(scrollWheel:), method); + let method: extern "C-unwind" fn(_, _, _) = key_down; + builder.add_method::(sel!(keyDown:), method); + let method: extern "C-unwind" fn(_, _, _) = key_up; + builder.add_method::(sel!(keyUp:), method); + } + let _ = CLASS.set(builder.register()); + } else { + let existing = + AnyClass::get(class_name.as_c_str()).expect("RuinContentView class must exist"); + let _ = CLASS.set(existing); + } + }); + + CLASS + .get() + .expect("RuinContentView class registration must succeed") +} + +fn event_window_for_view(this: &AnyObject, event: *mut AnyObject) -> *mut AnyObject { + let mut window: *mut AnyObject = unsafe { objc2::msg_send![event, window] }; + if window.is_null() { + window = unsafe { objc2::msg_send![this, window] }; + } + window +} + +fn pointer_position_in_view(this: &AnyObject, event: *mut AnyObject) -> Point { + let location: CGPoint = unsafe { objc2::msg_send![event, locationInWindow] }; + let frame: CGRect = unsafe { objc2::msg_send![this, frame] }; + Point::new( + location.x as f32, + frame.size.height as f32 - location.y as f32, + ) +} + +fn queue_pointer_from_view_event(this: &AnyObject, event: *mut AnyObject, kind: PointerEventKind) { + let window = event_window_for_view(this, event); + let position = pointer_position_in_view(this, event); + queue_delegate_pointer_event(window, PointerEvent::new(0, position, kind)); +} + +fn queue_keyboard_from_view_event( + this: &AnyObject, + event: *mut AnyObject, + kind: KeyboardEventKind, +) { + let window = event_window_for_view(this, event); + let keycode: u16 = unsafe { objc2::msg_send![event, keyCode] }; + let text = if kind == KeyboardEventKind::Pressed { + event_characters(event) + } else { + None + }; + let key = keyboard_key(keycode, text.as_deref()); + queue_delegate_keyboard_event( + window, + KeyboardEvent::new( + u32::from(keycode), + kind, + key, + keyboard_modifiers(event), + text, + ), + ); +} + +fn flush_pending_events( + sender: &ruin_runtime::channel::mpsc::UnboundedSender, + pending: &mut Vec, +) { + for event in pending.drain(..) { + let _ = sender.send(event); + } +} + +struct AppKitPumpGuard; + +impl Drop for AppKitPumpGuard { + fn drop(&mut self) { + APPKIT_PUMP_ACTIVE.with(|flag| flag.set(false)); + } +} + +fn try_enter_appkit_pump() -> Option { + APPKIT_PUMP_ACTIVE.with(|flag| { + if flag.replace(true) { + None + } else { + Some(AppKitPumpGuard) + } + }) +} + +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; + }; + let (shutdown_requested, sender) = { + let Ok(state_ref) = state.try_borrow() else { + return; + }; + ( + state_ref.shutdown_requested, + state_ref.endpoint_events.clone(), + ) + }; + if shutdown_requested { + return; + } let mut pending = Vec::new(); + let _ = drain_platform_commands(state); + { - let mut state_ref = state.borrow_mut(); - let app = state_ref.app; - - loop { - let event: *mut AnyObject = unsafe { - objc2::msg_send![ - app, - nextEventMatchingMask: usize::MAX, - untilDate: std::ptr::null_mut::(), - inMode: ns_string!("kCFRunLoopDefaultMode"), - dequeue: true - ] - }; - if event.is_null() { - break; - } - - if let Some(window_id) = window_id_for_event(&state_ref, event) - && let Some(window) = state_ref.windows.get_mut(&window_id) - && let Some(platform_event) = translate_event(window_id, window, event) - { - pending.push(platform_event); - } - - unsafe { - let _: () = objc2::msg_send![app, sendEvent: event]; - } - } - + let Ok(mut state_ref) = state.try_borrow_mut() else { + return; + }; unsafe { - let _: () = objc2::msg_send![app, updateWindows]; + let _: () = objc2::msg_send![state_ref.app, updateWindows]; } - for (window_id, window) in &mut state_ref.windows { collect_window_state_events(*window_id, window, &mut pending); } + apply_delegate_events(&mut state_ref, &mut pending); + } - for window_ptr in take_delegate_close_requests() { - let Some((window_id, window)) = state_ref.windows.iter_mut().find(|(_, window)| { - window - .native - .as_ref() - .is_some_and(|native| native.window as usize == window_ptr) - }) else { - continue; - }; - if !window.close_requested_sent { - window.close_requested_sent = true; - pending.push(PlatformEvent::CloseRequested { - window_id: *window_id, - }); + flush_pending_events(&sender, &mut pending); +} + +fn take_next_platform_command(state: &Rc>) -> Option { + let mut state_ref = state.borrow_mut(); + if state_ref.shutdown_requested { + return None; + } + state_ref.commands.try_recv().ok() +} + +fn drain_platform_commands(state: &Rc>) -> bool { + let mut handled_any = false; + while let Some(command) = take_next_platform_command(state) { + handled_any = true; + match command { + PlatformRequest::CreateWindow { window_id, spec } => { + handle_create_window(state, window_id, spec); + } + PlatformRequest::UpdateWindow { window_id, update } => { + handle_update_window(state, window_id, update); + } + PlatformRequest::ReplaceScene { window_id, scene } => { + handle_replace_scene(state, window_id, scene); + } + PlatformRequest::SetClipboardText { window_id, text } => { + if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { + window.clipboard_text = Some(text); + } + } + PlatformRequest::RequestClipboardText { window_id } => { + let text = state + .borrow() + .windows + .get(&window_id) + .and_then(|window| window.clipboard_text.clone()) + .unwrap_or_default(); + emit(state, PlatformEvent::ClipboardText { window_id, text }); + } + PlatformRequest::SetCursorIcon { window_id, cursor } => { + if let Some(window) = state.borrow_mut().windows.get_mut(&window_id) { + window.cursor_icon = cursor; + apply_cursor_icon(cursor); + } + } + PlatformRequest::EmitCloseRequested { window_id } => { + emit(state, PlatformEvent::CloseRequested { window_id }); + } + PlatformRequest::EmitPointerEvent { window_id, event } => { + emit(state, PlatformEvent::Pointer { window_id, event }); + } + PlatformRequest::EmitKeyboardEvent { window_id, event } => { + emit(state, PlatformEvent::Keyboard { window_id, event }); + } + PlatformRequest::EmitWake { window_id, token } => { + emit(state, PlatformEvent::Wake { window_id, token }); + } + #[cfg(target_os = "linux")] + PlatformRequest::SetPrimarySelectionText { .. } => {} + #[cfg(target_os = "linux")] + PlatformRequest::RequestPrimarySelectionText { .. } => {} + PlatformRequest::Shutdown => { + let app = { + let mut state_ref = state.borrow_mut(); + state_ref.shutdown_requested = true; + state_ref.app + }; + unsafe { + let _: () = objc2::msg_send![app, stop: std::ptr::null_mut::()]; + } + if let Ok(mut slot) = run_loop_wake_handle().lock() { + *slot = None; + } + set_command_wake_hook(None); + break; } } - - for window_ptr in take_delegate_closed_windows() { - let Some((window_id, window)) = state_ref.windows.iter_mut().find(|(_, window)| { - window - .native - .as_ref() - .is_some_and(|native| native.window as usize == window_ptr) - }) else { - continue; - }; - finalize_native_close(*window_id, window, &mut pending); - } - } - - for event in pending { - emit(state, event); } + handled_any } fn handle_create_window(state: &Rc>, window_id: WindowId, spec: WindowSpec) { @@ -449,6 +1030,9 @@ fn handle_create_window(state: &Rc>, window_id: WindowId, sp 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); sync_visibility(window); apply_cursor_icon(window.cursor_icon); emit_opened = true; @@ -471,11 +1055,18 @@ fn handle_create_window(state: &Rc>, window_id: WindowId, sp ); } if let Some(visible) = emit_visibility { - emit(state, PlatformEvent::VisibilityChanged { window_id, visible }); + emit( + state, + PlatformEvent::VisibilityChanged { window_id, visible }, + ); } } -fn handle_update_window(state: &Rc>, window_id: WindowId, update: WindowUpdate) { +fn handle_update_window( + state: &Rc>, + window_id: WindowId, + update: WindowUpdate, +) { let mut emit_opened = false; let mut emit_visibility = None; let mut emit_configured = None; @@ -515,10 +1106,20 @@ fn handle_update_window(state: &Rc>, window_id: WindowId, up emit_opened = true; } } else if window.lifecycle != WindowLifecycle::LogicalClosed { + debug!( + target: "ruin_ui_platform_macos::lifecycle", + ?window_id, + "platform received open(false), closing native window" + ); window.desired_visible = false; 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; + emit_closed = true; } else { window.lifecycle = WindowLifecycle::LogicalClosed; emit_closed = true; @@ -549,8 +1150,31 @@ fn handle_update_window(state: &Rc>, window_id: WindowId, up if let Some(native) = window.native.as_mut() { resize_native_window(native, size); } + repaint_retained_scene_for_resize(window_id, window); + if let Some(active_resize) = window.resize_pipeline.note_resize(size) { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + width = active_resize.width, + height = active_resize.height, + scale_factor = window.configuration.scale_factor, + "emitting Configured from window update resize" + ); + emit_configured = Some(window.configuration); + } else { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + active_width = window.resize_pipeline.active_resize.map(|s| s.width), + active_height = window.resize_pipeline.active_resize.map(|s| s.height), + next_width = window.resize_pipeline.next_resize.map(|s| s.width), + next_height = window.resize_pipeline.next_resize.map(|s| s.height), + "staged next resize from window update while another layout is in flight" + ); + } + } else { + emit_configured = Some(window.configuration); } - emit_configured = Some(window.configuration); } } @@ -558,7 +1182,10 @@ fn handle_update_window(state: &Rc>, window_id: WindowId, up emit(state, PlatformEvent::Opened { window_id }); } if let Some(visible) = emit_visibility { - emit(state, PlatformEvent::VisibilityChanged { window_id, visible }); + emit( + state, + PlatformEvent::VisibilityChanged { window_id, visible }, + ); } if let Some(configuration) = emit_configured { emit( @@ -574,37 +1201,34 @@ fn handle_update_window(state: &Rc>, window_id: WindowId, up } } -fn handle_replace_scene(state: &Rc>, window_id: WindowId, scene: SceneSnapshot) { - let (scene_version, item_count, visible) = { +fn handle_replace_scene( + state: &Rc>, + window_id: WindowId, + scene: SceneSnapshot, +) { + let (presented, followup_events) = { let mut state_ref = state.borrow_mut(); let app = state_ref.app; let Some(window) = state_ref.windows.get_mut(&window_id) else { return; }; + let mut followup_events = Vec::new(); ensure_native_window(window, app); - let item_count = scene.items.len(); - let scene_version = scene.version; - - if let Some(native) = window.native.as_mut() - && let Some(renderer) = native.renderer.as_mut() - { - match renderer.render(&scene) { - Ok(()) => {} - Err(RenderError::Lost | RenderError::Outdated) => { - let size = window.configuration.actual_inner_size; - renderer.resize(size.width.max(1.0) as u32, size.height.max(1.0) as u32); - let _ = renderer.render(&scene); - } - Err(RenderError::Timeout | RenderError::Occluded | RenderError::Validation) => {} - } - } - - window.latest_scene = Some(scene); - (scene_version, item_count, window.configuration.visible) + window.latest_scene = Some(scene.clone()); + ( + try_present_scene(window_id, window, &scene, &mut followup_events), + followup_events, + ) }; - if visible { + for event in followup_events { + emit(state, event); + } + + if let Some((scene_version, item_count, visible)) = presented + && visible + { emit( state, PlatformEvent::FramePresented { @@ -616,6 +1240,156 @@ fn handle_replace_scene(state: &Rc>, window_id: WindowId, sc } } +fn try_present_scene( + window_id: WindowId, + window: &mut MacosWindow, + scene: &SceneSnapshot, + followup_events: &mut Vec, +) -> Option<(u64, usize, bool)> { + let current_viewport = window.configuration.actual_inner_size; + let item_count = scene.item_count(); + let scene_version = scene.version; + let scene_viewport = scene.logical_size; + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + scene_width = scene_viewport.width, + scene_height = scene_viewport.height, + current_width = current_viewport.width, + current_height = current_viewport.height, + active_width = window.resize_pipeline.active_resize.map(|s| s.width), + active_height = window.resize_pipeline.active_resize.map(|s| s.height), + next_width = window.resize_pipeline.next_resize.map(|s| s.width), + next_height = window.resize_pipeline.next_resize.map(|s| s.height), + "handle_replace_scene start" + ); + let render_succeeded = render_scene_to_current_drawable(window_id, window, scene); + + if !render_succeeded { + return None; + } + + window.has_presented_frame = true; + flush_presented_frame(window); + if let Some(next_resize) = finish_presented_resize(window, scene_viewport) { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + scene_width = scene_viewport.width, + scene_height = scene_viewport.height, + next_width = next_resize.width, + next_height = next_resize.height, + "presented active resize scene; promoting staged next resize" + ); + emit_resize_request(window_id, window, next_resize, followup_events); + } + Some((scene_version, item_count, window.configuration.visible)) +} + +fn repaint_retained_scene_for_resize(window_id: WindowId, window: &mut MacosWindow) { + if !window.configuration.visible { + return; + } + let Some(scene) = window.latest_scene.clone() else { + return; + }; + + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + scene_width = scene.logical_size.width, + scene_height = scene.logical_size.height, + current_width = window.configuration.actual_inner_size.width, + current_height = window.configuration.actual_inner_size.height, + "repainting retained scene into resized drawable" + ); + if render_scene_to_current_drawable(window_id, window, &scene) { + window.has_presented_frame = true; + flush_presented_frame(window); + } +} + +fn render_scene_to_current_drawable( + window_id: WindowId, + window: &mut MacosWindow, + scene: &SceneSnapshot, +) -> bool { + let current_viewport = window.configuration.actual_inner_size; + let scene_viewport = scene.logical_size; + let mut render_succeeded = false; + if let Some(native) = window.native.as_mut() { + let Some(renderer) = native.renderer.as_mut() else { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + "render skipped; no renderer" + ); + return false; + }; + configure_view_resize_policy(native.view); + renderer.set_scale_factor(window.configuration.scale_factor); + let scale = window.configuration.scale_factor.max(1.0); + let drawable_width = (current_viewport.width * scale).max(1.0) as u32; + let drawable_height = (current_viewport.height * scale).max(1.0) as u32; + if renderer.size() != (drawable_width, drawable_height) { + renderer.resize(drawable_width, drawable_height); + } + match renderer.render_with_logical_size(scene, current_viewport) { + Ok(()) => { + render_succeeded = true; + } + Err(RenderError::Lost | RenderError::Outdated) => { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + "render lost/outdated; retrying after resize" + ); + renderer.resize(drawable_width, drawable_height); + if renderer + .render_with_logical_size(scene, current_viewport) + .is_ok() + { + render_succeeded = true; + } + } + Err(RenderError::Timeout) => { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + "render timeout" + ); + } + Err(RenderError::Occluded) => { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + "render occluded" + ); + } + Err(RenderError::Validation) => { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + "render validation failure" + ); + } + } + } + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + scene_width = scene_viewport.width, + scene_height = scene_viewport.height, + active_width = window.resize_pipeline.active_resize.map(|s| s.width), + active_height = window.resize_pipeline.active_resize.map(|s| s.height), + current_width = current_viewport.width, + current_height = current_viewport.height, + render_succeeded, + "render attempt complete" + ); + render_succeeded +} + fn ensure_native_window(window: &mut MacosWindow, app: *mut AnyObject) { if window.native.is_some() { return; @@ -659,12 +1433,12 @@ fn create_native_window(window: &MacosWindow, app: *mut AnyObject) -> Option Option()]; + let _: Bool = objc2::msg_send![window_obj, makeFirstResponder: view_obj]; + let _: () = + objc2::msg_send![window_obj, makeKeyAndOrderFront: std::ptr::null_mut::()]; let _: () = objc2::msg_send![app, activateIgnoringOtherApps: true]; } + let metal_layer = configure_metal_surface_layer(view_obj)?; + let scale_factor: f64 = unsafe { objc2::msg_send![window_obj, backingScaleFactor] }; let scale_factor = (scale_factor as f32).max(1.0); let target = AppKitSurfaceTarget { view: view_obj }; @@ -692,11 +1471,16 @@ fn create_native_window(window: &MacosWindow, app: *mut AnyObject) -> Option()]; + let _: () = + objc2::msg_send![native.window, orderOut: std::ptr::null_mut::()]; } } } fn close_native_window(window: &mut MacosWindow) { if let Some(native) = window.native.as_ref() { - mark_programmatic_window_close(native.window); + debug!( + target: "ruin_ui_platform_macos::lifecycle", + window_ptr = native.window as usize, + "sending NSWindow close" + ); unsafe { let _: () = objc2::msg_send![native.window, close]; } @@ -750,127 +1527,229 @@ fn emit(state: &Rc>, event: PlatformEvent) { let _ = sender.send(event); } -fn window_id_for_event(state: &MacosState, event: *mut AnyObject) -> Option { - let event_window: *mut AnyObject = unsafe { objc2::msg_send![event, window] }; - if event_window.is_null() { - return None; +fn apply_delegate_events(state: &mut MacosState, pending: &mut Vec) { + let mut saw_closed = false; + for resize in take_delegate_resize_events() { + let Some((window_id, window)) = state.windows.iter_mut().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == resize.window_ptr) + }) else { + continue; + }; + if window.lifecycle == WindowLifecycle::LogicalClosed { + continue; + } + let resized = resize.size.width > 0.0 + && resize.size.height > 0.0 + && resize.size != window.configuration.actual_inner_size; + let scale_changed = + (window.configuration.scale_factor - resize.scale_factor).abs() > f32::EPSILON; + if !(resized || scale_changed) { + continue; + } + + window.configuration.actual_inner_size = resize.size; + window.configuration.scale_factor = resize.scale_factor.max(1.0); + note_window_resize(*window_id, window, resize.size, pending); + repaint_retained_scene_for_resize(*window_id, window); } - state.windows.iter().find_map(|(window_id, window)| { - let matches = window - .native - .as_ref() - .is_some_and(|native| native.window == event_window); - matches.then_some(*window_id) - }) + for input in take_delegate_input_events() { + match input { + DelegateInputEvent::Pointer { window_ptr, event } => { + let Some((window_id, window)) = state.windows.iter().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == window_ptr) + }) else { + continue; + }; + if window.lifecycle != WindowLifecycle::LogicalClosed { + pending.push(PlatformEvent::Pointer { + window_id: *window_id, + event, + }); + } + } + DelegateInputEvent::Keyboard { window_ptr, event } => { + let Some((window_id, window)) = state.windows.iter().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == window_ptr) + }) else { + continue; + }; + if window.lifecycle != WindowLifecycle::LogicalClosed { + pending.push(PlatformEvent::Keyboard { + window_id: *window_id, + event, + }); + } + } + } + } + + for window_ptr in take_delegate_close_requests() { + let Some((window_id, window)) = state.windows.iter_mut().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == window_ptr) + }) else { + continue; + }; + if !window.close_requested_sent { + window.close_requested_sent = true; + debug!( + target: "ruin_ui_platform_macos::lifecycle", + ?window_id, + "emitting CloseRequested" + ); + pending.push(PlatformEvent::CloseRequested { + window_id: *window_id, + }); + } + } + + for window_ptr in take_delegate_closed_windows() { + let Some((window_id, window)) = state.windows.iter_mut().find(|(_, window)| { + window + .native + .as_ref() + .is_some_and(|native| native.window as usize == window_ptr) + }) else { + continue; + }; + finalize_native_close(*window_id, window, pending); + saw_closed = true; + } + + if saw_closed + && state + .windows + .values() + .all(|window| window.lifecycle == WindowLifecycle::LogicalClosed) + { + debug!( + target: "ruin_ui_platform_macos::lifecycle", + "all windows closed; terminating NSApplication" + ); + unsafe { + let _: () = objc2::msg_send![state.app, terminate: std::ptr::null_mut::()]; + } + } } -fn translate_event( +fn note_window_resize( window_id: WindowId, window: &mut MacosWindow, - event: *mut AnyObject, -) -> Option { - let event_type: usize = unsafe { objc2::msg_send![event, type] }; - match event_type { - NSEVENT_TYPE_MOUSE_MOVED - | NSEVENT_TYPE_LEFT_MOUSE_DRAGGED - | NSEVENT_TYPE_RIGHT_MOUSE_DRAGGED - | NSEVENT_TYPE_OTHER_MOUSE_DRAGGED => { - let position = pointer_position(event, window.configuration.actual_inner_size); - Some(PlatformEvent::Pointer { - window_id, - event: PointerEvent::new(0, position, PointerEventKind::Move), - }) + viewport: UiSize, + pending: &mut Vec, +) { + if let Some(active_resize) = window.resize_pipeline.note_resize(viewport) { + emit_resize_request(window_id, window, active_resize, pending); + } else { + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + active_width = window.resize_pipeline.active_resize.map(|s| s.width), + active_height = window.resize_pipeline.active_resize.map(|s| s.height), + next_width = window.resize_pipeline.next_resize.map(|s| s.width), + next_height = window.resize_pipeline.next_resize.map(|s| s.height), + "staged next resize while another layout is in flight" + ); + } +} + +fn emit_resize_request( + window_id: WindowId, + window: &mut MacosWindow, + viewport: UiSize, + pending: &mut Vec, +) { + pending.push(PlatformEvent::Configured { + window_id, + configuration: window.configuration, + }); + trace!( + target: "ruin_ui_platform_macos::resize", + ?window_id, + width = viewport.width, + height = viewport.height, + scale_factor = window.configuration.scale_factor, + "emitting Configured from delegate resize" + ); +} + +fn finish_presented_resize(window: &mut MacosWindow, presented: UiSize) -> Option { + window.resize_pipeline.note_presented_scene(presented) +} + +fn configure_metal_surface_layer(view: *mut AnyObject) -> Option> { + let view_ptr = NonNull::new(view.cast::())?; + let layer = unsafe { MetalLayer::from_ns_view(view_ptr) }; + let layer_ptr = layer.into_raw().as_ptr().cast::(); + let layer = unsafe { Retained::from_raw(layer_ptr) }?; + layer.setContentsGravity(unsafe { kCAGravityTopLeft }); + layer.setPresentsWithTransaction(false); + Some(layer) +} + +fn flush_presented_frame(window: &MacosWindow) { + let Some(native) = window.native.as_ref() else { + return; + }; + let in_live_resize: bool = unsafe { objc2::msg_send![native.view, inLiveResize] }; + unsafe { + let _: () = objc2::msg_send![native.view, displayIfNeeded]; + let _: () = objc2::msg_send![native.window, displayIfNeeded]; + let _: () = objc2::msg_send![objc2::class!(CATransaction), flush]; + } + trace!( + target: "ruin_ui_platform_macos::resize", + in_live_resize, + "requested display after presented frame" + ); +} + +fn configure_view_resize_policy(view: *mut AnyObject) { + if view.is_null() { + return; + } + unsafe { + let can_set_redraw: bool = + objc2::msg_send![view, respondsToSelector: sel!(setLayerContentsRedrawPolicy:)]; + if can_set_redraw { + let _: () = objc2::msg_send![ + view, + setLayerContentsRedrawPolicy: NS_VIEW_LAYER_CONTENTS_REDRAW_DURING_VIEW_RESIZE + ]; } - NSEVENT_TYPE_LEFT_MOUSE_DOWN => { - let position = pointer_position(event, window.configuration.actual_inner_size); - Some(PlatformEvent::Pointer { - window_id, - event: PointerEvent::new( - 0, - position, - PointerEventKind::Down { - button: PointerButton::Primary, - }, - ), - }) + let can_set_placement: bool = + objc2::msg_send![view, respondsToSelector: sel!(setLayerContentsPlacement:)]; + if can_set_placement { + let _: () = objc2::msg_send![ + view, + setLayerContentsPlacement: NS_VIEW_LAYER_CONTENTS_PLACEMENT_TOP_LEFT + ]; } - NSEVENT_TYPE_LEFT_MOUSE_UP => { - let position = pointer_position(event, window.configuration.actual_inner_size); - Some(PlatformEvent::Pointer { - window_id, - event: PointerEvent::new( - 0, - position, - PointerEventKind::Up { - button: PointerButton::Primary, - }, - ), - }) - } - NSEVENT_TYPE_OTHER_MOUSE_DOWN | NSEVENT_TYPE_OTHER_MOUSE_UP => { - let button_number: usize = unsafe { objc2::msg_send![event, buttonNumber] }; - if button_number != 2 { - return None; + let layer: *mut AnyObject = objc2::msg_send![view, layer]; + if !layer.is_null() { + let can_set_gravity: bool = + objc2::msg_send![layer, respondsToSelector: sel!(setContentsGravity:)]; + if can_set_gravity { + let _: () = objc2::msg_send![layer, setContentsGravity: ns_string!("topLeft")]; + } + let can_set_needs_display: bool = + objc2::msg_send![layer, respondsToSelector: sel!(setNeedsDisplayOnBoundsChange:)]; + if can_set_needs_display { + let _: () = objc2::msg_send![layer, setNeedsDisplayOnBoundsChange: false]; } - let position = pointer_position(event, window.configuration.actual_inner_size); - let kind = if event_type == NSEVENT_TYPE_OTHER_MOUSE_DOWN { - PointerEventKind::Down { - button: PointerButton::Middle, - } - } else { - PointerEventKind::Up { - button: PointerButton::Middle, - } - }; - Some(PlatformEvent::Pointer { - window_id, - event: PointerEvent::new(0, position, kind), - }) } - NSEVENT_TYPE_SCROLL_WHEEL => { - let position = pointer_position(event, window.configuration.actual_inner_size); - let delta_x: f64 = unsafe { objc2::msg_send![event, scrollingDeltaX] }; - let delta_y: f64 = unsafe { objc2::msg_send![event, scrollingDeltaY] }; - Some(PlatformEvent::Pointer { - window_id, - event: PointerEvent::new( - 0, - position, - PointerEventKind::Scroll { - delta: Point::new(delta_x as f32, -(delta_y as f32)), - }, - ), - }) - } - NSEVENT_TYPE_MOUSE_EXITED => { - let position = pointer_position(event, window.configuration.actual_inner_size); - Some(PlatformEvent::Pointer { - window_id, - event: PointerEvent::new(0, position, PointerEventKind::LeaveWindow), - }) - } - NSEVENT_TYPE_KEY_DOWN | NSEVENT_TYPE_KEY_UP => { - let keycode: u16 = unsafe { objc2::msg_send![event, keyCode] }; - let kind = if event_type == NSEVENT_TYPE_KEY_DOWN { - KeyboardEventKind::Pressed - } else { - KeyboardEventKind::Released - }; - let modifiers = keyboard_modifiers(event); - let text = if kind == KeyboardEventKind::Pressed { - event_characters(event) - } else { - None - }; - let key = keyboard_key(keycode, text.as_deref()); - Some(PlatformEvent::Keyboard { - window_id, - event: KeyboardEvent::new(u32::from(keycode), kind, key, modifiers, text), - }) - } - NSEVENT_TYPE_RIGHT_MOUSE_DOWN | NSEVENT_TYPE_RIGHT_MOUSE_UP => None, - _ => None, } } @@ -902,28 +1781,18 @@ fn collect_window_state_events( if resized || scale_changed { window.configuration.actual_inner_size = content_size; window.configuration.scale_factor = scale_factor.max(1.0); - if let Some(renderer) = window.native.as_mut().and_then(|native| native.renderer.as_mut()) { - let width = (content_size.width * window.configuration.scale_factor).max(1.0) as u32; - let height = (content_size.height * window.configuration.scale_factor).max(1.0) as u32; - renderer.resize(width, height); - // Avoid AppKit stretch artifacts during live resize while layout catches up. - if let Some(scene) = window.latest_scene.as_ref() { - let _ = renderer.render(scene); - } - } - pending.push(PlatformEvent::Configured { - window_id, - configuration: window.configuration, - }); + note_window_resize(window_id, window, content_size, pending); } - let is_visible: bool = unsafe { objc2::msg_send![window_ptr, isVisible] }; + let is_visible = native_window_is_logically_visible(window_ptr); + let became_visible = is_visible && !window.configuration.visible; if is_visible != window.configuration.visible { window.configuration.visible = is_visible; window.lifecycle = if is_visible { window.close_requested_sent = false; WindowLifecycle::OpenVisible } else { + window.has_presented_frame = false; WindowLifecycle::OpenHidden }; pending.push(PlatformEvent::VisibilityChanged { @@ -937,15 +1806,27 @@ fn collect_window_state_events( return; } - if !is_visible && window.desired_visible && !window.close_requested_sent { - window.close_requested_sent = true; - pending.push(PlatformEvent::CloseRequested { window_id }); + if is_visible + && let Some(scene) = window.latest_scene.clone() + && (became_visible + || !window.has_presented_frame + || window.resize_pipeline.active_resize == Some(scene.logical_size)) + { + let _ = try_present_scene(window_id, window, &scene, pending); + } else if is_visible + && !window.has_presented_frame + && let Some(initial_viewport) = window + .resize_pipeline + .ensure_initial_request(window.configuration.actual_inner_size) + { + emit_resize_request(window_id, window, initial_viewport, pending); } } -fn pointer_position(event: *mut AnyObject, size: UiSize) -> Point { - let location: CGPoint = unsafe { objc2::msg_send![event, locationInWindow] }; - Point::new(location.x as f32, size.height - location.y as f32) +fn native_window_is_logically_visible(window: *mut AnyObject) -> bool { + let is_visible: bool = unsafe { objc2::msg_send![window, isVisible] }; + let is_miniaturized: bool = unsafe { objc2::msg_send![window, isMiniaturized] }; + is_visible && !is_miniaturized } fn keyboard_modifiers(event: *mut AnyObject) -> KeyboardModifiers { @@ -1021,9 +1902,16 @@ fn finalize_native_close( window: &mut MacosWindow, pending: &mut Vec, ) { + debug!( + target: "ruin_ui_platform_macos::lifecycle", + ?window_id, + "finalizing native close" + ); + let should_emit_closed = window.lifecycle != WindowLifecycle::LogicalClosed; if let Some(native) = window.native.take() { unsafe { - let _: () = objc2::msg_send![native.window, setDelegate: std::ptr::null_mut::()]; + let _: () = + objc2::msg_send![native.window, setDelegate: std::ptr::null_mut::()]; let _: () = objc2::msg_send![native.delegate, release]; } } @@ -1039,7 +1927,14 @@ fn finalize_native_close( visible: false, }); } - pending.push(PlatformEvent::Closed { window_id }); + if should_emit_closed { + debug!( + target: "ruin_ui_platform_macos::lifecycle", + ?window_id, + "emitting Closed" + ); + pending.push(PlatformEvent::Closed { window_id }); + } } fn sync_window_metrics(window: &mut MacosWindow) { @@ -1056,3 +1951,73 @@ fn sync_window_metrics(window: &mut MacosWindow) { UiSize::new(frame.size.width as f32, frame.size.height as f32); window.configuration.scale_factor = (scale_factor as f32).max(1.0); } + +#[cfg(test)] +mod tests { + use super::ResizePipeline; + use ruin_ui::UiSize; + + #[test] + fn resize_pipeline_stages_latest_resize_while_active_request_is_in_flight() { + let mut pipeline = ResizePipeline::default(); + + assert_eq!( + pipeline.note_resize(UiSize::new(640.0, 480.0)), + Some(UiSize::new(640.0, 480.0)) + ); + assert_eq!(pipeline.active_resize, Some(UiSize::new(640.0, 480.0))); + assert_eq!(pipeline.next_resize, None); + + assert_eq!(pipeline.note_resize(UiSize::new(800.0, 600.0)), None); + assert_eq!(pipeline.active_resize, Some(UiSize::new(640.0, 480.0))); + assert_eq!(pipeline.next_resize, Some(UiSize::new(800.0, 600.0))); + + assert_eq!(pipeline.note_resize(UiSize::new(1024.0, 768.0)), None); + assert_eq!(pipeline.active_resize, Some(UiSize::new(640.0, 480.0))); + assert_eq!(pipeline.next_resize, Some(UiSize::new(1024.0, 768.0))); + } + + #[test] + fn resize_pipeline_promotes_next_resize_after_presenting_active_scene() { + let mut pipeline = ResizePipeline::default(); + let first = UiSize::new(640.0, 480.0); + let second = UiSize::new(1024.0, 768.0); + + assert_eq!(pipeline.note_resize(first), Some(first)); + assert_eq!(pipeline.note_resize(second), None); + + assert_eq!(pipeline.note_presented_scene(first), Some(second)); + assert_eq!(pipeline.active_resize, Some(second)); + assert_eq!(pipeline.next_resize, None); + } + + #[test] + fn resize_pipeline_drops_duplicate_staged_resize_when_present_already_satisfies_it() { + let mut pipeline = ResizePipeline::default(); + let viewport = UiSize::new(800.0, 600.0); + + assert_eq!(pipeline.note_resize(viewport), Some(viewport)); + assert_eq!(pipeline.note_resize(viewport), None); + + assert_eq!(pipeline.note_presented_scene(viewport), None); + assert_eq!(pipeline.active_resize, None); + assert_eq!(pipeline.next_resize, None); + } + + #[test] + fn resize_pipeline_ignores_presents_for_non_active_scene_sizes() { + let mut pipeline = ResizePipeline::default(); + let active = UiSize::new(640.0, 480.0); + let next = UiSize::new(900.0, 700.0); + + assert_eq!(pipeline.note_resize(active), Some(active)); + assert_eq!(pipeline.note_resize(next), None); + + assert_eq!( + pipeline.note_presented_scene(UiSize::new(700.0, 500.0)), + None + ); + assert_eq!(pipeline.active_resize, Some(active)); + assert_eq!(pipeline.next_resize, Some(next)); + } +} diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 3296245..f0eda62 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -91,7 +91,8 @@ struct PreparedGlyphBitmap { struct RasterizedText { origin_offset: Point, - size: UiSize, + draw_size: UiSize, + texture_size: UiSize, pixels: Vec, } @@ -170,6 +171,7 @@ struct UploadedAtlasText { struct TextTextureKey { text: String, bounds: Option<(u32, u32)>, + clip: Option<(u32, u32, u32, u32)>, font_size_bits: u32, line_height_bits: u32, color: (u8, u8, u8, u8), @@ -330,6 +332,7 @@ pub struct WgpuSceneRenderer { image_cache_order: VecDeque, #[allow(dead_code)] glyph_atlas: GlyphAtlas, + scale_factor: f32, } const MAX_TEXT_CACHE_ENTRIES: usize = 64; @@ -366,8 +369,8 @@ impl WgpuSceneRenderer { format, width: width.max(1), height: height.max(1), - present_mode: wgpu::PresentMode::AutoVsync, - desired_maximum_frame_latency: 2, + present_mode: wgpu::PresentMode::AutoNoVsync, + desired_maximum_frame_latency: 1, alpha_mode: caps.alpha_modes[0], view_formats: vec![], }; @@ -710,6 +713,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { image_cache: HashMap::new(), image_cache_order: VecDeque::new(), glyph_atlas, + scale_factor: 1.0, }) } @@ -723,9 +727,33 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { self.surface.configure(&self.device, &self.config); } + pub fn set_scale_factor(&mut self, scale_factor: f32) { + let next = scale_factor.max(1.0); + if (self.scale_factor - next).abs() <= f32::EPSILON { + return; + } + self.scale_factor = next; + // Text cache is scale-dependent; flush on scale transitions (e.g. display move). + self.text_cache.clear(); + self.text_cache_order.clear(); + self.glyph_atlas = create_glyph_atlas( + &self.device, + &self.text_bind_group_layout, + &self.text_sampler, + ); + } + pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> { + self.render_with_logical_size(scene, scene.logical_size) + } + + pub fn render_with_logical_size( + &mut self, + scene: &SceneSnapshot, + logical_size: UiSize, + ) -> Result<(), RenderError> { let render_start = std::time::Instant::now(); - let vertices = build_vertices(scene); + let vertices = build_vertices(scene, logical_size); let frame = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, @@ -749,7 +777,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { let mut uploaded_images = Vec::new(); let mut uploaded_texts = Vec::new(); let mut atlas_perf = AtlasTextPerfStats::default(); - let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene, &mut atlas_perf); + let uploaded_atlas_text = + self.prepare_uploaded_atlas_text(scene, logical_size, &mut atlas_perf); let mut clip_stack = Vec::new(); let mut active_clip = ActiveClip::default(); for item in &scene.items { @@ -760,17 +789,19 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip), DisplayItem::Image(image) => { if let Some(uploaded) = - self.prepare_uploaded_image(image, scene.logical_size, active_clip) + self.prepare_uploaded_image(image, logical_size, active_clip) { uploaded_images.push(uploaded); } } DisplayItem::Text(text) => { - if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { + if (self.scale_factor - 1.0).abs() <= f32::EPSILON + && text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) + { continue; } if let Some(uploaded) = - self.prepare_uploaded_text(text, scene.logical_size, active_clip) + self.prepare_uploaded_text(text, logical_size, active_clip) { uploaded_texts.push(uploaded); } @@ -854,21 +885,29 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { Ok(()) } - fn rasterize_text(&mut self, text: &PreparedText) -> Option { - if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { - return self.rasterize_prepared_text(text); + fn rasterize_text( + &mut self, + text: &PreparedText, + raster_clip: Option, + ) -> Option { + if (self.scale_factor - 1.0).abs() <= f32::EPSILON + && text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) + { + return self.rasterize_prepared_text(text, raster_clip); } - let glyphs = self.collect_glyph_bitmaps(text); - let clip = match text.bounds { - Some(bounds) => PixelRect { - left: 0, - top: 0, - right: bounds.width.ceil() as i32, - bottom: bounds.height.ceil() as i32, - }, - None => glyph_union(&glyphs)?, - }; + let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor)); + let glyphs = self.collect_glyph_bitmaps(text, clip); + let clip = clip + .or_else(|| { + text.bounds.map(|bounds| PixelRect { + left: 0, + top: 0, + right: (bounds.width * self.scale_factor).ceil() as i32, + bottom: (bounds.height * self.scale_factor).ceil() as i32, + }) + }) + .or_else(|| glyph_union(&glyphs))?; if clip.width() == 0 || clip.height() == 0 { return None; } @@ -879,23 +918,36 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } Some(RasterizedText { - origin_offset: Point::new(clip.left as f32, clip.top as f32), - size: UiSize::new(clip.width() as f32, clip.height() as f32), + origin_offset: Point::new( + clip.left as f32 / self.scale_factor, + clip.top as f32 / self.scale_factor, + ), + draw_size: UiSize::new( + clip.width() as f32 / self.scale_factor, + clip.height() as f32 / self.scale_factor, + ), + texture_size: UiSize::new(clip.width() as f32, clip.height() as f32), pixels, }) } - fn rasterize_prepared_text(&mut self, text: &PreparedText) -> Option { + fn rasterize_prepared_text( + &mut self, + text: &PreparedText, + raster_clip: Option, + ) -> Option { let glyphs = self.collect_prepared_glyphs(text); - let clip = match text.bounds { - Some(bounds) => PixelRect { - left: 0, - top: 0, - right: bounds.width.ceil() as i32, - bottom: bounds.height.ceil() as i32, - }, - None => glyph_union_prepared(&glyphs)?, - }; + let clip = raster_clip + .map(rect_to_pixel_rect) + .or_else(|| { + text.bounds.map(|bounds| PixelRect { + left: 0, + top: 0, + right: bounds.width.ceil() as i32, + bottom: bounds.height.ceil() as i32, + }) + }) + .or_else(|| glyph_union_prepared(&glyphs))?; if clip.width() == 0 || clip.height() == 0 { return None; } @@ -914,7 +966,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { Some(RasterizedText { origin_offset: Point::new(clip.left as f32, clip.top as f32), - size: UiSize::new(clip.width() as f32, clip.height() as f32), + draw_size: UiSize::new(clip.width() as f32, clip.height() as f32), + texture_size: UiSize::new(clip.width() as f32, clip.height() as f32), pixels, }) } @@ -955,13 +1008,20 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { glyphs } - fn collect_glyph_bitmaps(&mut self, text: &PreparedText) -> Vec { - let mut buffer = Buffer::new_empty(Metrics::new(text.font_size, text.line_height)); + fn collect_glyph_bitmaps( + &mut self, + text: &PreparedText, + raster_clip: Option, + ) -> Vec { + let mut buffer = Buffer::new_empty(Metrics::new( + text.font_size * self.scale_factor, + text.line_height * self.scale_factor, + )); { let mut borrowed = buffer.borrow_with(&mut self.font_system); borrowed.set_size( - text.bounds.map(|bounds| bounds.width), - text.bounds.map(|bounds| bounds.height), + text.bounds.map(|bounds| bounds.width * self.scale_factor), + text.bounds.map(|bounds| bounds.height * self.scale_factor), ); borrowed.set_text(&text.text, &Attrs::new(), Shaping::Advanced, None); } @@ -982,13 +1042,17 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { if width == 0 || height == 0 { continue; } + let rect = PixelRect { + left: physical.x + image.placement.left, + top: physical.y - image.placement.top, + right: physical.x + image.placement.left + width, + bottom: physical.y - image.placement.top + height, + }; + if raster_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) { + continue; + } glyphs.push(GlyphBitmap { - rect: PixelRect { - left: physical.x + image.placement.left, - top: physical.y - image.placement.top, - right: physical.x + image.placement.left + width, - bottom: physical.y - image.placement.top + height, - }, + rect, content: image.content, color: glyph .color_opt @@ -1003,8 +1067,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { fn prepare_uploaded_atlas_text( &mut self, scene: &SceneSnapshot, + logical_size: UiSize, perf: &mut AtlasTextPerfStats, ) -> Option { + if (self.scale_factor - 1.0).abs() > f32::EPSILON { + return None; + } let mut vertices = Vec::new(); let mut clip_stack = Vec::new(); let mut active_clip = ActiveClip::default(); @@ -1073,7 +1141,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { glyph_rect, atlas_glyph.atlas_rect, clip_rect, - scene.logical_size, + logical_size, resolved_color, active_clip, ); @@ -1246,9 +1314,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { logical_size: UiSize, clip: ActiveClip, ) -> Option { - let key = text_texture_key(text); + let raster_clip = text_raster_clip(text, clip, logical_size)?; + let key = text_texture_key(text, raster_clip); if !self.text_cache.contains_key(&key) { - let rasterized = self.rasterize_text(text)?; + let rasterized = self.rasterize_text(text, raster_clip)?; let cached = self.create_cached_text_texture(&rasterized); self.text_cache.insert(key.clone(), cached); } @@ -1316,8 +1385,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("ruin-ui-renderer-wgpu-texture"), size: wgpu::Extent3d { - width: text.size.width as u32, - height: text.size.height as u32, + width: text.texture_size.width as u32, + height: text.texture_size.height as u32, depth_or_array_layers: 1, }, mip_level_count: 1, @@ -1337,12 +1406,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { &text.pixels, wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(text.size.width as u32 * 4), - rows_per_image: Some(text.size.height as u32), + bytes_per_row: Some(text.texture_size.width as u32 * 4), + rows_per_image: Some(text.texture_size.height as u32), }, wgpu::Extent3d { - width: text.size.width as u32, - height: text.size.height as u32, + width: text.texture_size.width as u32, + height: text.texture_size.height as u32, depth_or_array_layers: 1, }, ); @@ -1365,7 +1434,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { _texture: texture, bind_group, origin_offset: text.origin_offset, - size: text.size, + size: text.draw_size, } } @@ -1885,9 +1954,9 @@ fn color_to_f32(color: Color) -> [f32; 4] { ] } -fn build_vertices(scene: &SceneSnapshot) -> Vec { - let width = scene.logical_size.width.max(1.0); - let height = scene.logical_size.height.max(1.0); +fn build_vertices(scene: &SceneSnapshot, logical_size: UiSize) -> Vec { + let width = logical_size.width.max(1.0); + let height = logical_size.height.max(1.0); let mut vertices = Vec::new(); let mut clip_stack = Vec::new(); let mut active_clip = ActiveClip::default(); @@ -2149,6 +2218,22 @@ fn rect_to_pixel_rect(rect: Rect) -> PixelRect { } } +fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect { + PixelRect { + left: (rect.origin.x * scale).floor() as i32, + top: (rect.origin.y * scale).floor() as i32, + right: ((rect.origin.x + rect.size.width) * scale).ceil() as i32, + bottom: ((rect.origin.y + rect.size.height) * scale).ceil() as i32, + } +} + +fn pixel_rects_intersect(first: PixelRect, second: PixelRect) -> bool { + first.left < second.right + && first.right > second.left + && first.top < second.bottom + && first.bottom > second.top +} + fn shadow_blur_extent(blur: f32) -> f32 { blur.max(0.0) * 2.0 } @@ -2208,6 +2293,33 @@ fn intersect_rects(first: Option, second: Option) -> Option { } } +fn text_raster_clip( + text: &PreparedText, + clip: ActiveClip, + logical_size: UiSize, +) -> Option> { + let mut absolute_clip = Some(Rect::new(0.0, 0.0, logical_size.width, logical_size.height)); + if clip.rect_active { + absolute_clip = intersect_rects(absolute_clip, Some(clip.resolved_rect()?)); + } + if let Some(bounds) = text.bounds { + let text_bounds = Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height); + absolute_clip = intersect_rects(absolute_clip, Some(text_bounds)); + } + if absolute_clip.is_none() { + return None; + } + + Some(absolute_clip.map(|rect| { + Rect::new( + rect.origin.x - text.origin.x, + rect.origin.y - text.origin.y, + rect.size.width, + rect.size.height, + ) + })) +} + fn empty_clip_rect() -> Rect { Rect::new(1.0, 1.0, -1.0, -1.0) } @@ -2330,12 +2442,20 @@ 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) -> TextTextureKey { +fn text_texture_key(text: &PreparedText, raster_clip: Option) -> TextTextureKey { TextTextureKey { text: text.text.clone(), bounds: text .bounds .map(|bounds| (bounds.width.to_bits(), bounds.height.to_bits())), + clip: raster_clip.map(|clip| { + ( + clip.origin.x.to_bits(), + clip.origin.y.to_bits(), + clip.size.width.to_bits(), + clip.size.height.to_bits(), + ) + }), font_size_bits: text.font_size.to_bits(), line_height_bits: text.line_height.to_bits(), color: ( @@ -2365,10 +2485,7 @@ mod tests { ActiveClip, blend_rgba, build_text_vertices, build_vertices, clip_rect_array, push_clip_state, rounded_clip_arrays, text_texture_key, }; - use ruin_ui::{ - ClipRegion, Color, GlyphInstance, Point, PreparedText, Rect, SceneSnapshot, - TextSelectionStyle, UiSize, - }; + use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize}; #[test] fn quad_scenes_expand_to_six_vertices_per_quad() { @@ -2389,7 +2506,7 @@ mod tests { Color::rgb(0xFF, 0xFF, 0xFF), )); - let vertices = build_vertices(&scene); + let vertices = build_vertices(&scene, scene.logical_size); assert_eq!(vertices.len(), 12); } @@ -2421,7 +2538,10 @@ mod tests { Color::rgb(0xEE, 0xEE, 0xEE), ); - assert_eq!(text_texture_key(&first), text_texture_key(&second)); + assert_eq!( + text_texture_key(&first, None), + text_texture_key(&second, None) + ); } #[test]