diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fc5fc..653e2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ - Make `Layer`'s implementation details private; it is now a struct with `as_ptr` and `is_existing` accessor methods. - Add support for tvOS, watchOS and visionOS. - Use `objc2` internally. +- Move `Layer` constructors to the type itself. + - `appkit::metal_layer_from_ns_view` is now `Layer::from_ns_view`. + - `uikit::metal_layer_from_ui_view` is now `Layer::from_ui_view`. +- Added `Layer::from_layer` to construct a `Layer` from a `CALayer` directly. +- Fixed layers not automatically resizing to match the super layer they were created from. # 0.4.0 (2023-10-31) - Update `raw-window-handle` dep to `0.6.0`. diff --git a/Cargo.toml b/Cargo.toml index a729d21..124b520 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ name = "raw-window-metal" version = "0.4.0" license = "MIT OR Apache-2.0" -authors = ["The Gfx-rs Developers"] edition = "2021" description = "Interop library between Metal and raw-window-handle" documentation = "https://docs.rs/raw-window-metal" @@ -12,14 +11,16 @@ keywords = ["window", "metal", "graphics"] categories = ["game-engines", "graphics"] exclude = [".github/*"] -[dependencies] -raw-window-handle = "0.6.0" - [target.'cfg(target_vendor = "apple")'.dependencies] objc2 = "0.5.2" objc2-foundation = { version = "0.2.2", features = [ - "NSObjCRuntime", + "NSDictionary", "NSGeometry", + "NSKeyValueObserving", + "NSObjCRuntime", + "NSString", + "NSThread", + "NSValue", ] } objc2-quartz-core = { version = "0.2.2", features = [ "CALayer", @@ -27,22 +28,14 @@ objc2-quartz-core = { version = "0.2.2", features = [ "objc2-metal", ] } -[target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.2.2", features = [ - "NSResponder", - "NSView", - "NSWindow", - "objc2-quartz-core", -] } +[dev-dependencies] +raw-window-handle = "0.6.0" -[target.'cfg(all(target_vendor = "apple", not(target_os = "macos")))'.dependencies] -objc2-ui-kit = { version = "0.2.2", features = [ - "UIResponder", - "UIView", - "UIWindow", - "UIScreen", - "objc2-quartz-core", -] } +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2-app-kit = { version = "0.2.2", features = ["NSResponder", "NSView"] } + +[target.'cfg(all(target_vendor = "apple", not(target_os = "macos")))'.dev-dependencies] +objc2-ui-kit = { version = "0.2.2", features = ["UIResponder", "UIView"] } [package.metadata.docs.rs] targets = [ diff --git a/README.md b/README.md index 2c99903..214d26c 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,6 @@ raw-window-metal = "0.4" `CAMetalLayer` is the common entrypoint for graphics APIs (e.g `gfx` or `MoltenVK`), but the handles provided by window libraries may not include such a layer. This library may extract either this layer or allocate a new one. -Code is mostly extracted from the `gfx-backend-metal` crate. - ## License Licensed under either of diff --git a/src/appkit.rs b/src/appkit.rs deleted file mode 100644 index eeae971..0000000 --- a/src/appkit.rs +++ /dev/null @@ -1,70 +0,0 @@ -use core::ffi::c_void; -use objc2::rc::Retained; -use objc2::ClassType; -use objc2_foundation::{NSObject, NSObjectProtocol}; -use objc2_quartz_core::CAMetalLayer; -use raw_window_handle::AppKitWindowHandle; -use std::ptr::NonNull; - -use crate::Layer; - -/// Get or create a new [`Layer`] associated with the given -/// [`AppKitWindowHandle`]. -/// -/// # Safety -/// -/// The handle must be valid. -pub unsafe fn metal_layer_from_handle(handle: AppKitWindowHandle) -> Layer { - unsafe { metal_layer_from_ns_view(handle.ns_view) } -} - -/// Get or create a new [`Layer`] associated with the given `NSView`. -/// -/// # Safety -/// -/// The view must be a valid instance of `NSView`. -pub unsafe fn metal_layer_from_ns_view(view: NonNull) -> Layer { - // SAFETY: Caller ensures that the view is valid. - let obj = unsafe { view.cast::().as_ref() }; - - // Check if the view is a `CAMetalLayer`. - if obj.is_kind_of::() { - // SAFETY: Just checked that the view is a `CAMetalLayer`. - let layer = unsafe { view.cast::().as_ref() }; - return Layer { - layer: layer.retain(), - pre_existing: true, - }; - } - // Otherwise assume the view is `NSView`. - let view = unsafe { view.cast::().as_ref() }; - - // Check if the view contains a valid `CAMetalLayer`. - let existing = unsafe { view.layer() }; - if let Some(existing) = existing { - if existing.is_kind_of::() { - // SAFETY: Just checked that the layer is a `CAMetalLayer`. - let layer = unsafe { Retained::cast::(existing) }; - return Layer { - layer, - pre_existing: true, - }; - } - } - - // If the layer was not `CAMetalLayer`, allocate a new one for the view. - let layer = unsafe { CAMetalLayer::new() }; - unsafe { view.setLayer(Some(&layer)) }; - view.setWantsLayer(true); - layer.setBounds(view.bounds()); - - if let Some(window) = view.window() { - let scale_factor = window.backingScaleFactor(); - layer.setContentsScale(scale_factor); - } - - Layer { - layer, - pre_existing: false, - } -} diff --git a/src/lib.rs b/src/lib.rs index 0150a77..f1d3215 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,146 @@ +//! # Interop between Metal and [`raw-window-handle`] +//! +//! Helpers for constructing a [`CAMetalLayer`] from a handle given by [`raw-window-handle`]. See +//! the [`Layer`] type for the full API. +//! +//! [`raw-window-handle`]: https://crates.io/crates/raw-window-handle +//! +//! +//! ## Example +//! +//! Create a layer from a window that implements [`HasWindowHandle`]. +//! +//! ``` +//! use objc2::rc::Retained; +//! use objc2_quartz_core::CAMetalLayer; +//! use raw_window_handle::{RawWindowHandle, HasWindowHandle}; +//! use raw_window_metal::Layer; +//! # +//! # let mtm = objc2_foundation::MainThreadMarker::new().expect("doc tests to run on main thread"); +//! # +//! # #[cfg(target_os = "macos")] +//! # let view = unsafe { objc2_app_kit::NSView::new(mtm) }; +//! # #[cfg(target_os = "macos")] +//! # let handle = RawWindowHandle::AppKit(raw_window_handle::AppKitWindowHandle::new(std::ptr::NonNull::from(&*view).cast())); +//! # +//! # #[cfg(not(target_os = "macos"))] +//! # let view = unsafe { objc2_ui_kit::UIView::new(mtm) }; +//! # #[cfg(not(target_os = "macos"))] +//! # let handle = RawWindowHandle::UiKit(raw_window_handle::UiKitWindowHandle::new(std::ptr::NonNull::from(&*view).cast())); +//! # let window = unsafe { raw_window_handle::WindowHandle::borrow_raw(handle) }; +//! +//! let layer = match window.window_handle().expect("handle available").as_raw() { +//! // SAFETY: The handle is a valid `NSView` because it came from `WindowHandle<'_>`. +//! RawWindowHandle::AppKit(handle) => unsafe { Layer::from_ns_view(handle.ns_view) }, +//! // SAFETY: The handle is a valid `UIView` because it came from `WindowHandle<'_>`. +//! RawWindowHandle::UiKit(handle) => unsafe { Layer::from_ui_view(handle.ui_view) }, +//! _ => panic!("unsupported handle"), +//! }; +//! let layer: *mut CAMetalLayer = layer.as_ptr().cast(); +//! let layer = unsafe { Retained::retain(layer).unwrap() }; +//! +//! // Use `CAMetalLayer` here. +//! ``` +//! +//! [`HasWindowHandle`]: https://docs.rs/raw-window-handle/0.6.2/raw_window_handle/trait.HasWindowHandle.html +//! +//! +//! ## Semantics +//! +//! As the user of this crate, you are likely creating a library yourself, and need to interface +//! with a layer provided by a windowing library like Winit or SDL. +//! +//! In that sense, when the user hands your library a view or a layer via. `raw-window-handle`, they +//! likely expect you to render into it. You should freely do that, but you should refrain from +//! doing things like resizing the layer by changing its `bounds`, changing its `contentsGravity`, +//! `opacity`, and similar such properties; semantically, these are things that are "outside" of +//! your library's control, and interferes with the platforms normal handling of such things (i.e. +//! the user creating a `MTKView`, and placing it inside a `NSStackView`. In such cases, you should +//! not change the bounds of the view, as that will be done automatically at a "higher" level). +//! +//! Properties specific to `CAMetalLayer` like `drawableSize`, `colorspace` and so on probably _are_ +//! fine to change, because these are properties that the user _expects_ your library to change when +//! they've given it to you (and they won't be changed by e.g. the layer being inside a stack view). +//! +//! +//! ## Reasoning behind creating a sublayer +//! +//! If a view does not have a `CAMetalLayer` as the root layer (as is the default for most views), +//! then we're in a bit of a tricky position! We cannot use the existing layer with Metal, so we +//! must do something else. There are a few options: +//! +//! 1. Panic, and require the user to pass a view with a `CAMetalLayer` layer. +//! +//! While this would "work", it doesn't solve the problem, and instead passes the ball onwards to +//! the user and ecosystem to figure it out. +//! +//! 2. Override the existing layer with a newly created layer. +//! +//! If we overlook that this does not work in UIKit since `UIView`'s `layer` is `readonly`, and +//! that as such we will need to do something different there anyhow, this is actually a fairly +//! good solution, and was what the original implementation did. +//! +//! It has some problems though, due to: +//! +//! a. Consumers of `raw-window-metal` like Wgpu and Ash in their API design choosing not to +//! register a callback with `-[CALayerDelegate displayLayer:]`, but instead leaves it up to +//! the user to figure out when to redraw. That is, they rely on other libraries' callbacks +//! telling them when to render. +//! +//! (If you were to make an API only for Metal, you would probably make the user provide a +//! `render` closure that'd be called in the right situations). +//! +//! b. Overwriting the `layer` on `NSView` makes the view "layer-hosting", see [wantsLayer], +//! which disables drawing functionality on the view like `drawRect:`/`updateLayer`. +//! +//! These two in combination makes it basically impossible for crates like Winit to provide a +//! robust rendering callback that integrates with the system's built-in mechanisms for +//! redrawing, exactly because overwriting the layer would be disabling those mechanisms! +//! +//! [wantsLayer]: https://developer.apple.com/documentation/appkit/nsview/1483695-wantslayer?language=objc +//! +//! 3. Create a sublayer. +//! +//! `CALayer` has the concept of "sublayers", which we can use instead of overriding the layer. +//! +//! This is also the recommended solution on UIKit, so it's nice that we can use the same +//! implementation regardless of operating system. +//! +//! It _might_, however, perform ever so slightly worse than overriding the layer directly. +//! +//! 4. Create a new `MTKView` (or a custom view), and add it as a subview. +//! +//! Similar to creating a sublayer (see above), but also provides a bunch of event handling that +//! we don't need. +//! +//! Option 3 seems like the most robust solution, so this is what this crate does. +//! +//! Now we have another problem though: The `bounds` and `contentsScale` of sublayers are not +//! automatically updated from the super layer. +//! +//! We could again choose to let that be up to the user of our crate, but that would be very +//! cumbersome. Instead, this crate registers the necessary observers to make the sublayer track the +//! size and scale factor of its super layer automatically. This makes it extra important that you +//! do not modify common `CALayer` properties of the layer that `raw-window-metal` creates, since +//! they may just end up being overwritten (see also "Semantics" above). + #![cfg(target_vendor = "apple")] -#![allow(clippy::missing_safety_doc)] #![cfg_attr(docsrs, feature(doc_auto_cfg, doc_cfg_hide), doc(cfg_hide(doc)))] #![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::undocumented_unsafe_blocks)] + +mod observer; +use crate::observer::ObserverLayer; use core::ffi::c_void; use core::hash; use core::panic::{RefUnwindSafe, UnwindSafe}; -use objc2::rc::Retained; -use objc2_quartz_core::CAMetalLayer; - -#[cfg(any(target_os = "macos", doc))] -pub mod appkit; - -#[cfg(any(not(target_os = "macos"), doc))] -pub mod uikit; +use core::ptr::NonNull; +use objc2::runtime::AnyClass; +use objc2::{msg_send, rc::Retained}; +use objc2::{msg_send_id, ClassType}; +use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol}; +use objc2_quartz_core::{CALayer, CAMetalLayer}; /// A wrapper around [`CAMetalLayer`]. #[doc(alias = "CAMetalLayer")] @@ -45,6 +172,7 @@ impl hash::Hash for Layer { // // TODO(madsmtm): Move this to `objc2-quartz-core`. unsafe impl Send for Layer {} +// SAFETY: Same as above. unsafe impl Sync for Layer {} // Layer methods may panic, but that won't leave the layer in an invalid state. @@ -54,10 +182,18 @@ impl UnwindSafe for Layer {} impl RefUnwindSafe for Layer {} impl Layer { - /// Get a pointer to the underlying [`CAMetalLayer`]. The pointer is valid - /// for at least as long as the [`Layer`] is valid, but can be extended by + /// Get a pointer to the underlying [`CAMetalLayer`]. + /// + /// The pointer is valid for at least as long as the [`Layer`] is valid, but can be extended by /// retaining it. /// + /// You should usually not change general `CALayer` properties like `bounds`, `contentsScale` + /// and so on of this layer, but instead modify the layer that it was created from. + /// + /// You can safely modify `CAMetalLayer` properties like `drawableSize` to match your needs, + /// though beware that if it does not match the actual size of the layer, the contents will be + /// scaled. + /// /// /// # Example /// @@ -86,4 +222,219 @@ impl Layer { pub fn pre_existing(&self) -> bool { self.pre_existing } + + /// Get or create a new `CAMetalLayer` from the given `CALayer`. + /// + /// If the given layer is a `CAMetalLayer`, this will simply return that layer. If not, a new + /// `CAMetalLayer` is created and inserted as a sublayer, and then configured such that it will + /// have the same bounds and scale factor as the given layer. + /// + /// + /// # Safety + /// + /// The given layer pointer must be a valid instance of `CALayer`. + /// + /// + /// # Examples + /// + /// Create a new layer from a `CAMetalLayer`. + /// + /// ``` + /// use std::ptr::NonNull; + /// use objc2_quartz_core::CAMetalLayer; + /// use raw_window_metal::Layer; + /// + /// let layer = unsafe { CAMetalLayer::new() }; + /// let ptr: NonNull = NonNull::from(&*layer); + /// + /// let layer = unsafe { Layer::from_ca_layer(ptr.cast()) }; + /// assert!(layer.pre_existing()); + /// ``` + /// + /// Create a `CAMetalLayer` sublayer in a `CALayer`. + /// + /// ``` + /// use std::ptr::NonNull; + /// use objc2_quartz_core::CALayer; + /// use raw_window_metal::Layer; + /// + /// let layer = CALayer::new(); + /// let ptr: NonNull = NonNull::from(&*layer); + /// + /// let layer = unsafe { Layer::from_ca_layer(ptr.cast()) }; + /// assert!(!layer.pre_existing()); + /// ``` + pub unsafe fn from_ca_layer(layer_ptr: NonNull) -> Self { + // SAFETY: Caller ensures that the pointer is a valid `CALayer`. + let root_layer: &CALayer = unsafe { layer_ptr.cast().as_ref() }; + + // Debug check that the given layer actually _is_ a CALayer. + if cfg!(debug_assertions) { + assert!( + root_layer.isKindOfClass(CALayer::class()), + "view was not a valid CALayer" + ); + } + + // Check if the view's layer is already a `CAMetalLayer`. + if root_layer.is_kind_of::() { + let layer = root_layer.retain(); + // SAFETY: Just checked that the layer is a `CAMetalLayer`. + let layer: Retained = unsafe { Retained::cast(layer) }; + Layer { + layer, + pre_existing: true, + } + } else { + let layer = ObserverLayer::new(root_layer); + Layer { + layer: Retained::into_super(layer), + pre_existing: false, + } + } + } + + fn from_retained_layer(root_layer: Retained) -> Self { + // Check if the view's layer is already a `CAMetalLayer`. + if root_layer.is_kind_of::() { + // SAFETY: Just checked that the layer is a `CAMetalLayer`. + let layer: Retained = unsafe { Retained::cast(root_layer) }; + Layer { + layer, + pre_existing: true, + } + } else { + let layer = ObserverLayer::new(&root_layer); + Layer { + layer: Retained::into_super(layer), + pre_existing: false, + } + } + } + + /// Get or create a new `CAMetalLayer` from the given `NSView`. + /// + /// If the given view is not [layer-backed], it will be made so. + /// + /// If the given view has a `CAMetalLayer` as the root layer (which can happen for example if + /// the view has overwritten `-[NSView layerClass]` or the view is `MTKView`) this will simply + /// return that layer. If not, a new `CAMetalLayer` is created and inserted as a sublayer into + /// the view's layer, and then configured such that it will have the same bounds and scale + /// factor as the given view. + /// + /// + /// # Panics + /// + /// Panics if called from a thread that is not the main thread. + /// + /// + /// # Safety + /// + /// The given view pointer must be a valid instance of `NSView`. + /// + /// + /// # Example + /// + /// Construct a layer from an [`AppKitWindowHandle`]. + /// + /// ``` + /// use raw_window_handle::AppKitWindowHandle; + /// use raw_window_metal::Layer; + /// + /// let handle: AppKitWindowHandle; + /// # let mtm = objc2_foundation::MainThreadMarker::new().expect("doc tests to run on main thread"); + /// # #[cfg(target_os = "macos")] + /// # let view = unsafe { objc2_app_kit::NSView::new(mtm) }; + /// # #[cfg(target_os = "macos")] + /// # { handle = AppKitWindowHandle::new(std::ptr::NonNull::from(&*view).cast()) }; + /// # #[cfg(not(target_os = "macos"))] + /// # { handle = unimplemented!() }; + /// let layer = unsafe { Layer::from_ns_view(handle.ns_view) }; + /// ``` + /// + /// [layer-backed]: https://developer.apple.com/documentation/appkit/nsview/1483695-wantslayer?language=objc + /// [`AppKitWindowHandle`]: https://docs.rs/raw-window-handle/0.6.2/raw_window_handle/struct.AppKitWindowHandle.html + pub unsafe fn from_ns_view(ns_view_ptr: NonNull) -> Self { + let _mtm = MainThreadMarker::new().expect("can only access NSView on the main thread"); + + // SAFETY: Caller ensures that the pointer is a valid `NSView`. + // + // We use `NSObject` here to avoid importing `objc2-app-kit`. + let ns_view: &NSObject = unsafe { ns_view_ptr.cast().as_ref() }; + + // Debug check that the given view actually _is_ a NSView. + if cfg!(debug_assertions) { + // Load the class at runtime (instead of using `class!`) + // to ensure that this still works if AppKit isn't linked. + let cls = AnyClass::get("NSView").unwrap(); + assert!(ns_view.isKindOfClass(cls), "view was not a valid NSView"); + } + + // Force the view to become layer backed + // SAFETY: The signature of `NSView::setWantsLayer` is correctly specified. + let _: () = unsafe { msg_send![ns_view, setWantsLayer: true] }; + + // SAFETY: `-[NSView layer]` returns an optional `CALayer` + let root_layer: Option> = unsafe { msg_send_id![ns_view, layer] }; + let root_layer = root_layer.expect("failed making the view layer-backed"); + + Self::from_retained_layer(root_layer) + } + + /// Get or create a new `CAMetalLayer` from the given `UIView`. + /// + /// If the given view has a `CAMetalLayer` as the root layer (which can happen for example if + /// the view has overwritten `-[UIView layerClass]` or the view is `MTKView`) this will simply + /// return that layer. If not, a new `CAMetalLayer` is created and inserted as a sublayer into + /// the view's layer, and then configured such that it will have the same bounds and scale + /// factor as the given view. + /// + /// + /// # Panics + /// + /// Panics if called from a thread that is not the main thread. + /// + /// + /// # Safety + /// + /// The given view pointer must be a valid instance of `UIView`. + /// + /// + /// # Example + /// + /// Construct a layer from a [`UiKitWindowHandle`]. + /// + /// ```no_run + /// use raw_window_handle::UiKitWindowHandle; + /// use raw_window_metal::Layer; + /// + /// let handle: UiKitWindowHandle; + /// # handle = unimplemented!(); + /// let layer = unsafe { Layer::from_ui_view(handle.ui_view) }; + /// ``` + /// + /// [`UiKitWindowHandle`]: https://docs.rs/raw-window-handle/0.6.2/raw_window_handle/struct.UiKitWindowHandle.html + pub unsafe fn from_ui_view(ui_view_ptr: NonNull) -> Self { + let _mtm = MainThreadMarker::new().expect("can only access UIView on the main thread"); + + // SAFETY: Caller ensures that the pointer is a valid `UIView`. + // + // We use `NSObject` here to avoid importing `objc2-ui-kit`. + let ui_view: &NSObject = unsafe { ui_view_ptr.cast().as_ref() }; + + // Debug check that the given view actually _is_ a UIView. + if cfg!(debug_assertions) { + // Load the class at runtime (instead of using `class!`) + // to ensure that this still works if UIKit isn't linked. + let cls = AnyClass::get("UIView").unwrap(); + assert!(ui_view.isKindOfClass(cls), "view was not a valid UIView"); + } + + // SAFETY: `-[UIView layer]` returns a non-optional `CALayer` + let root_layer: Retained = unsafe { msg_send_id![ui_view, layer] }; + + // Unlike on macOS, we cannot replace the main view as `UIView` does + // not allow it (when `NSView` does). + Self::from_retained_layer(root_layer) + } } diff --git a/src/observer.rs b/src/observer.rs new file mode 100644 index 0000000..8b1abc1 --- /dev/null +++ b/src/observer.rs @@ -0,0 +1,255 @@ +use core::ffi::c_void; +use objc2::rc::{Retained, Weak}; +use objc2::runtime::{AnyClass, AnyObject}; +use objc2::{declare_class, msg_send, msg_send_id, mutability, ClassType, DeclaredClass}; +use objc2_foundation::{ + ns_string, NSDictionary, NSKeyValueChangeKey, NSKeyValueChangeNewKey, + NSKeyValueObservingOptions, NSNumber, NSObjectNSKeyValueObserverRegistration, NSString, + NSValue, +}; +use objc2_quartz_core::{CALayer, CAMetalLayer}; + +declare_class!( + /// A `CAMetalLayer` layer that will automatically update its bounds and scale factor to match + /// its super layer. + /// + /// We do this by subclassing, to allow the user to just store the layer as + /// `Retained`, and still have our observers work. + /// + /// See the documentation on Key-Value Observing for details on how this works in general: + /// + pub(crate) struct ObserverLayer; + + // SAFETY: + // - The superclass CAMetalLayer does not have any subclassing requirements. + // - Interior mutability is a safe default. + // - CustomLayer implements `Drop` and ensures that: + // - It does not call an overridden method. + // - It does not `retain` itself. + unsafe impl ClassType for ObserverLayer { + type Super = CAMetalLayer; + type Mutability = mutability::InteriorMutable; + const NAME: &'static str = "RawWindowMetalLayer"; + } + + impl DeclaredClass for ObserverLayer { + type Ivars = Weak; + } + + // `NSKeyValueObserving` category. + // + // SAFETY: The method is correctly defined. + unsafe impl ObserverLayer { + #[method(observeValueForKeyPath:ofObject:change:context:)] + fn _observe_value( + &self, + key_path: Option<&NSString>, + object: Option<&AnyObject>, + change: Option<&NSDictionary>, + context: *mut c_void, + ) { + self.observe_value(key_path, object, change, context); + } + } +); + +impl Drop for ObserverLayer { + fn drop(&mut self) { + // It is possible for the root layer to be de-allocated before the custom layer. + // + // In that case, the observer is already de-registered, and so we don't need to do anything. + // + // We use a weak variable here to avoid issues if the layer was removed from the super + // layer, and then later de-allocated, without de-registering these observers. + if let Some(root_layer) = self.ivars().load() { + // SAFETY: The observer is registered for these key paths in `new`. + unsafe { + root_layer.removeObserver_forKeyPath(self, ns_string!("contentsScale")); + root_layer.removeObserver_forKeyPath(self, ns_string!("bounds")); + } + } + } +} + +impl ObserverLayer { + /// The context pointer, to differentiate between key-value observing registered by this class, + /// and the superclass. + fn context() -> *mut c_void { + ObserverLayer::class() as *const AnyClass as *mut c_void + } + + /// Create a new custom layer that tracks parameters from the given super layer. + pub fn new(root_layer: &CALayer) -> Retained { + let this = Self::alloc().set_ivars(Weak::new(root_layer)); + // SAFETY: Initializing `CAMetalLayer` is safe. + let this: Retained = unsafe { msg_send_id![super(this), init] }; + + // Add the layer as a sublayer of the root layer. + root_layer.addSublayer(&this); + + // Do not use auto-resizing mask. + // + // This is done to work around a bug in macOS 14 and above, where views using auto layout + // may end up setting fractional values as the bounds, and that in turn doesn't propagate + // properly through the auto-resizing mask and with contents gravity. + // + // Instead, we keep the bounds of the layer in sync with the root layer using an observer, + // see below. + // + // this.setAutoresizingMask(kCALayerHeightSizable | kCALayerWidthSizable); + + // AppKit / UIKit automatically sets the correct scale factor and bounds for layers attached + // to a view. Our layer, however, is not directly attached to a view, and so we need to + // observe changes to the root layer's parameters, and apply them to our layer. + // + // Note the use of `NSKeyValueObservingOptionInitial` to also set the initial values here. + // + // Note that for AppKit, we _could_ make the layer match the window by adding a delegate on + // the layer with the `layer:shouldInheritContentsScale:fromWindow:` method returning `true` + // - this tells the system to automatically update the scale factor when it changes on a + // window. But this wouldn't support headless rendering very well, and doesn't work on UIKit + // anyhow, so we might as well just always use the observer technique. + // + // SAFETY: Observer deregistered in `Drop` before the observer object is deallocated. + unsafe { + root_layer.addObserver_forKeyPath_options_context( + &this, + ns_string!("contentsScale"), + NSKeyValueObservingOptions::NSKeyValueObservingOptionNew + | NSKeyValueObservingOptions::NSKeyValueObservingOptionInitial, + ObserverLayer::context(), + ); + root_layer.addObserver_forKeyPath_options_context( + &this, + ns_string!("bounds"), + NSKeyValueObservingOptions::NSKeyValueObservingOptionNew + | NSKeyValueObservingOptions::NSKeyValueObservingOptionInitial, + ObserverLayer::context(), + ); + } + + // The default content gravity (`kCAGravityResize`) is a fine choice for most applications, + // as it masks / alleviates issues with resizing and behaves better when moving the window + // between monitors, so we won't modify that. + // + // Unfortunately, it may also make it harder to debug resize issues, swap this for + // `kCAGravityTopLeft` instead when doing so. + // + // this.setContentsGravity(unsafe { kCAGravityResize }); + + this + } + + fn observe_value( + &self, + key_path: Option<&NSString>, + object: Option<&AnyObject>, + change: Option<&NSDictionary>, + context: *mut c_void, + ) { + // An unrecognized context must belong to the super class. + if context != ObserverLayer::context() { + // SAFETY: The signature is correct, and it's safe to forward to the superclass' method + // when we're overriding the method. + return unsafe { + msg_send![ + super(self), + observeValueForKeyPath: key_path, + ofObject: object, + change: change, + context: context, + ] + }; + } + + let change = + change.expect("requested a change dictionary in `addObserver`, but none was provided"); + // SAFETY: The static is declared with the correct type in `objc2`. + let key = unsafe { NSKeyValueChangeNewKey }; + let new = change + .get(key) + .expect("requested change dictionary did not contain `NSKeyValueChangeNewKey`"); + + // NOTE: Setting these values usually causes a quarter second animation to occur, which is + // undesirable. + // + // However, since we're setting them inside an observer, there already is a transaction + // ongoing, and as such we don't need to wrap this in a `CATransaction` ourselves. + + if key_path == Some(ns_string!("contentsScale")) { + // SAFETY: `contentsScale` is a CGFloat, and so the observed value is always a NSNumber. + let new = unsafe { &*(new as *const AnyObject as *const NSNumber) }; + let scale_factor = new.as_cgfloat(); + + // Set the scale factor of the layer to match the root layer when it changes (e.g. if + // moved to a different monitor, or monitor settings changed). + self.setContentsScale(scale_factor); + } else if key_path == Some(ns_string!("bounds")) { + // SAFETY: `bounds` is a CGRect, and so the observed value is always a NSValue. + let new = unsafe { &*(new as *const AnyObject as *const NSValue) }; + let bounds = new.get_rect().expect("new bounds value was not CGRect"); + + // Set `bounds` and `position` so that the new layer is inside the superlayer. + // + // This differs from just setting the `bounds`, as it also takes into account any + // translation that the superlayer may have that we'd want to preserve. + self.setFrame(bounds); + } else { + panic!("unknown observed keypath {key_path:?}"); + } + } +} + +#[cfg(test)] +mod tests { + use objc2_foundation::{CGPoint, CGRect, CGSize}; + + use super::*; + + #[test] + fn release_order_does_not_matter() { + let root_layer = CALayer::new(); + let layer = ObserverLayer::new(&root_layer); + drop(root_layer); + drop(layer); + + let root_layer = CALayer::new(); + let layer = ObserverLayer::new(&root_layer); + drop(layer); + drop(root_layer); + } + + #[test] + fn scale_factor_propagates() { + let root_layer = CALayer::new(); + let layer = ObserverLayer::new(&root_layer); + + root_layer.setContentsScale(3.0); + assert_eq!(layer.contentsScale(), 3.0); + } + + #[test] + fn bounds_propagates() { + let root_layer = CALayer::new(); + let layer = ObserverLayer::new(&root_layer); + + root_layer.setBounds(CGRect::new( + CGPoint::new(10.0, 20.0), + CGSize::new(30.0, 40.0), + )); + assert_eq!(layer.position(), CGPoint::new(25.0, 40.0)); + assert_eq!( + layer.bounds(), + CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(30.0, 40.0),) + ); + } + + #[test] + fn superlayer_can_remove_all_sublayers() { + let root_layer = CALayer::new(); + let layer = ObserverLayer::new(&root_layer); + layer.removeFromSuperlayer(); + drop(layer); + root_layer.setContentsScale(3.0); + } +} diff --git a/src/uikit.rs b/src/uikit.rs deleted file mode 100644 index b48bd43..0000000 --- a/src/uikit.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::Layer; -use objc2::rc::Retained; -use objc2_foundation::NSObjectProtocol; -use objc2_quartz_core::CAMetalLayer; -use raw_window_handle::UiKitWindowHandle; -use std::{ffi::c_void, ptr::NonNull}; - -/// Get or create a new [`Layer`] associated with the given -/// [`UiKitWindowHandle`]. -/// -/// # Safety -/// -/// The handle must be valid. -pub unsafe fn metal_layer_from_handle(handle: UiKitWindowHandle) -> Layer { - if let Some(_ui_view_controller) = handle.ui_view_controller { - // TODO: ui_view_controller support - } - unsafe { metal_layer_from_ui_view(handle.ui_view) } -} - -/// Get or create a new [`Layer`] associated with the given `UIView`. -/// -/// # Safety -/// -/// The view must be a valid instance of `UIView`. -pub unsafe fn metal_layer_from_ui_view(view: NonNull) -> Layer { - // SAFETY: Caller ensures that the view is a `UIView`. - let view = unsafe { view.cast::().as_ref() }; - - let main_layer = view.layer(); - - // Check if the view's layer is already a `CAMetalLayer`. - let render_layer = if main_layer.is_kind_of::() { - // SAFETY: Just checked that the layer is a `CAMetalLayer`. - let layer = unsafe { Retained::cast::(main_layer) }; - Layer { - layer, - pre_existing: true, - } - } else { - // If the main layer is not a `CAMetalLayer`, we create a - // `CAMetalLayer` sublayer and use it instead. - // - // Unlike on macOS, we cannot replace the main view as `UIView` does - // not allow it (when `NSView` does). - let layer = unsafe { CAMetalLayer::new() }; - - let bounds = main_layer.bounds(); - layer.setFrame(bounds); - - main_layer.addSublayer(&layer); - - Layer { - layer, - pre_existing: false, - } - }; - - if let Some(window) = view.window() { - view.setContentScaleFactor(window.screen().nativeScale()); - } - - render_layer -}