feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)
Ports the macOS-specific Zig code behind cfg(target_os = "macos"): - renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend (all renderer/metal/* files, shaders.metal verbatim, runtime library compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga; OpenGL remains the backend elsewhere. - font: CoreText face, shaper and discovery with build.rs backend selection (coretext default; coretext-freetype / coretext-harfbuzz / coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple. - input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes. - os: macos, mach, cf_release_thread, NSProcessInfo args and macOS branches; config: macOS defaults, keybinds, Application Support paths. - termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit handling, os_log logging. Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot run here. The embedded runtime/C API (embedded.zig) is the next step. Also includes the cross-platform integration edits shared by the crash, verification, Windows and macOS work (Cargo target-gating, thread crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg gates), a cfg fix so ghostty-config builds on wasm32, and an allow for the intentional recursion in the crash overflow test. Test Plan: - cargo test --workspace (3224 passed) - cargo clippy --workspace --all-targets -- -D warnings - cargo check --target aarch64-apple-darwin -p ghostty-renderer (with the documented cross env)
This commit is contained in:
1 parent
3c8fae1089
commit
452b782149
124 files changed
+14936
-1242
No files matched your search
@@ -0,0 +1,522 @@
|
||||
//! Graphics API wrapper for Metal (`renderer/Metal.zig` and `renderer/metal/*`).
|
||||
//!
|
||||
//! The renderer draws into IOSurface-backed textures ([`target::Target`]) and
|
||||
//! presents them by setting the `contents` of an [`IOSurfaceLayer`], which is
|
||||
//! installed on the apprt's NSView (macOS: made "layer-hosting") or UIView
|
||||
//! (iOS: added as a sublayer).
|
||||
//!
|
||||
//! The platform-independent parts (enum values, GPU data layouts, pixel
|
||||
//! format selection, vertex attribute tables) live in
|
||||
//! [`crate::metal_layout`] so they can be unit tested on any host.
|
||||
//!
|
||||
//! Safety: this module talks to Metal / QuartzCore through `objc2`, which is
|
||||
//! mostly safe; `unsafe` is limited to encoder calls that take raw resources,
|
||||
//! raw pointer copies and the autorelease pool, each with a SAFETY comment.
|
||||
|
||||
use std::{ffi::c_void, sync::Arc};
|
||||
|
||||
use bytemuck::Pod;
|
||||
use ghostty_config::types_b::AlphaBlending;
|
||||
use ghostty_font::atlas::Atlas;
|
||||
use objc2::{
|
||||
msg_send,
|
||||
rc::Retained,
|
||||
runtime::{AnyObject, ProtocolObject},
|
||||
sel,
|
||||
};
|
||||
use objc2_foundation::NSError;
|
||||
use objc2_metal::{
|
||||
MTLBuffer,
|
||||
MTLCommandQueue,
|
||||
MTLDevice,
|
||||
MTLGPUFamily,
|
||||
MTLSamplerAddressMode,
|
||||
MTLSamplerMinMagFilter,
|
||||
};
|
||||
|
||||
pub mod buffer;
|
||||
pub mod frame;
|
||||
pub mod io_surface_layer;
|
||||
pub mod pipeline;
|
||||
pub mod render_pass;
|
||||
pub mod sampler;
|
||||
pub mod shaders;
|
||||
pub mod target;
|
||||
pub mod texture;
|
||||
|
||||
pub use self::{
|
||||
buffer::Buffer,
|
||||
frame::Frame,
|
||||
io_surface_layer::IOSurfaceLayer,
|
||||
pipeline::Pipeline,
|
||||
render_pass::RenderPass,
|
||||
sampler::Sampler,
|
||||
shaders::Shaders,
|
||||
target::Target,
|
||||
texture::Texture,
|
||||
};
|
||||
use crate::{
|
||||
graphics_api::{ApiError, GraphicsApi, ImageTextureFormat, TextureKind},
|
||||
metal_layout::{
|
||||
MetalUniforms,
|
||||
PixelFormat,
|
||||
ResourceOptions,
|
||||
StorageMode,
|
||||
TextureUsage,
|
||||
atlas_pixel_format,
|
||||
color_pixel_format,
|
||||
default_storage_mode,
|
||||
image_pixel_format,
|
||||
max_texture_size,
|
||||
surface_size,
|
||||
},
|
||||
shader_types::Uniforms,
|
||||
shadertoy,
|
||||
};
|
||||
|
||||
/// A `MTLDevice`.
|
||||
pub type Device = Retained<ProtocolObject<dyn MTLDevice>>;
|
||||
/// A `MTLCommandQueue`.
|
||||
pub type Queue = Retained<ProtocolObject<dyn MTLCommandQueue>>;
|
||||
|
||||
/// Custom shaders are converted to MSL for Metal.
|
||||
pub const CUSTOM_SHADER_TARGET: shadertoy::Target = shadertoy::Target::Msl;
|
||||
/// The fragCoord for Metal shaders is +Y = down.
|
||||
pub const CUSTOM_SHADER_Y_IS_DOWN: bool = true;
|
||||
/// Triple buffering.
|
||||
pub const SWAP_CHAIN_COUNT: usize = 3;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// A Metal API call failed.
|
||||
#[error("Metal API call failed")]
|
||||
MetalFailed,
|
||||
/// No usable Metal device exists.
|
||||
#[error("no Metal device")]
|
||||
NoMetalDevice,
|
||||
/// An Objective-C runtime operation failed.
|
||||
#[error("Objective-C runtime failure")]
|
||||
ObjCFailed,
|
||||
/// A shader function was not found in its library.
|
||||
#[error("shader function `{0}` not found")]
|
||||
MissingFunction(String),
|
||||
/// Metal reported an error (shader compilation, pipeline creation, ...).
|
||||
#[error("metal error: {0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Log and convert an `NSError` (`checkError`).
|
||||
pub(crate) fn from_ns_error(err: &NSError) -> Error {
|
||||
let desc = err.localizedDescription().to_string();
|
||||
log::error!("metal error={desc}");
|
||||
Error::Message(desc)
|
||||
}
|
||||
}
|
||||
|
||||
/// The NSView (macOS) / UIView (iOS) the renderer draws in, plus its content
|
||||
/// scale (Zig: `rt_surface.platform` and `rt_surface.content_scale.x`).
|
||||
pub struct ViewInfo<'a> {
|
||||
/// The NSView / UIView.
|
||||
pub view: &'a AnyObject,
|
||||
/// The scale factor of the window (backing scale factor).
|
||||
pub scale_factor: f64,
|
||||
}
|
||||
|
||||
/// An autorelease pool token (`objc_autoreleasePoolPush`).
|
||||
struct PoolToken(*mut c_void);
|
||||
|
||||
// SAFETY: the token is only used from the thread that pushed it (draw_frame
|
||||
// starts and ends the pool on one thread); `Send` is needed because the
|
||||
// graphics API lives inside the renderer's mutex.
|
||||
unsafe impl Send for PoolToken {}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn objc_autoreleasePoolPush() -> *mut c_void;
|
||||
fn objc_autoreleasePoolPop(pool: *mut c_void);
|
||||
}
|
||||
|
||||
/// The Metal graphics API.
|
||||
pub struct Metal {
|
||||
layer: IOSurfaceLayer,
|
||||
|
||||
/// MTLDevice
|
||||
device: Device,
|
||||
/// MTLCommandQueue
|
||||
queue: Queue,
|
||||
|
||||
/// Alpha blending mode
|
||||
blending: AlphaBlending,
|
||||
|
||||
/// The default storage mode to use for resources created with our device.
|
||||
///
|
||||
/// This is based on whether the device is a discrete GPU or not, since
|
||||
/// discrete GPUs do not have unified memory and therefore do not support
|
||||
/// the "shared" storage mode, instead we have to use the "managed" mode.
|
||||
default_storage_mode: StorageMode,
|
||||
|
||||
/// The maximum 2D texture width and height supported by the device.
|
||||
max_texture_size: u32,
|
||||
|
||||
/// We start an AutoreleasePool before `drawFrame` and end it afterwards.
|
||||
autorelease_pool: Option<PoolToken>,
|
||||
}
|
||||
|
||||
impl Metal {
|
||||
/// Create the Metal backend and attach its layer to `view`.
|
||||
///
|
||||
/// Must be called on the thread that owns the view (the main thread).
|
||||
pub fn init(blending: AlphaBlending, info: &ViewInfo<'_>) -> Result<Metal, Error> {
|
||||
// Choose our MTLDevice and create a MTLCommandQueue for that device.
|
||||
let device = choose_device()?;
|
||||
let queue = device.newCommandQueue().ok_or(Error::MetalFailed)?;
|
||||
|
||||
// Grab metadata about the device.
|
||||
let has_unified_memory = device.hasUnifiedMemory();
|
||||
let default_storage_mode =
|
||||
default_storage_mode(cfg!(target_os = "ios"), has_unified_memory);
|
||||
let max_texture_size = query_max_texture_size(&device);
|
||||
log::debug!(
|
||||
"device properties default_storage_mode={default_storage_mode:?} max_texture_size={max_texture_size}"
|
||||
);
|
||||
|
||||
// Create an IOSurfaceLayer which we can assign to the view to make
|
||||
// it in to a "layer-hosting view", so that we can manually control
|
||||
// the layer contents.
|
||||
let layer = IOSurfaceLayer::new()?;
|
||||
let layer_object: &AnyObject = layer.layer().as_ref();
|
||||
let view = info.view;
|
||||
|
||||
// Add our layer to the view.
|
||||
//
|
||||
// On macOS we do this by making the view "layer-hosting"
|
||||
// by assigning it to the view's `layer` property BEFORE
|
||||
// setting `wantsLayer` to `true`.
|
||||
//
|
||||
// On iOS, views are always layer-backed, and `layer`
|
||||
// is readonly, so instead we add it as a sublayer.
|
||||
//
|
||||
// SAFETY: `view` is an NSView / UIView (a documented requirement of
|
||||
// `ViewInfo`), which implements these selectors with these types.
|
||||
unsafe {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _: () = msg_send![view, setLayer: layer_object];
|
||||
let _: () = msg_send![view, setWantsLayer: true];
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let view_layer: Option<Retained<AnyObject>> = msg_send![view, layer];
|
||||
let view_layer = view_layer.ok_or(Error::ObjCFailed)?;
|
||||
let _: () = msg_send![&*view_layer, addSublayer: layer_object];
|
||||
}
|
||||
|
||||
// Ensure that if our layer is oversized it does not overflow the
|
||||
// bounds of the view. (`clipsToBounds` is NSView API since macOS
|
||||
// 14; older systems don't have the setter.)
|
||||
let responds: bool = msg_send![view, respondsToSelector: sel!(setClipsToBounds:)];
|
||||
if responds {
|
||||
let _: () = msg_send![view, setClipsToBounds: true];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that our layer has a content scale set to
|
||||
// match the scale factor of the window. This avoids
|
||||
// magnification issues leading to blurry rendering.
|
||||
layer.layer().setContentsScale(info.scale_factor);
|
||||
|
||||
// This makes it so that our display callback will actually be called.
|
||||
layer.layer().setNeedsDisplayOnBoundsChange(true);
|
||||
|
||||
Ok(Metal {
|
||||
layer,
|
||||
device,
|
||||
queue,
|
||||
blending,
|
||||
default_storage_mode,
|
||||
max_texture_size,
|
||||
autorelease_pool: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer the renderer presents on.
|
||||
pub fn layer(&self) -> &IOSurfaceLayer {
|
||||
&self.layer
|
||||
}
|
||||
|
||||
/// The device the renderer draws with.
|
||||
pub fn device(&self) -> &Device {
|
||||
&self.device
|
||||
}
|
||||
|
||||
/// The default storage mode of resources created with this device.
|
||||
pub fn default_storage_mode(&self) -> StorageMode {
|
||||
self.default_storage_mode
|
||||
}
|
||||
|
||||
/// The maximum 2D texture size of the device.
|
||||
pub fn max_texture_size(&self) -> u32 {
|
||||
self.max_texture_size
|
||||
}
|
||||
|
||||
/// The pixel format of targets, custom shader textures and pipelines.
|
||||
fn color_format(&self) -> PixelFormat {
|
||||
color_pixel_format(self.blending)
|
||||
}
|
||||
|
||||
/// Returns the options to use when constructing buffers.
|
||||
pub fn buffer_options(&self) -> buffer::Options {
|
||||
buffer::Options {
|
||||
device: self.device.clone(),
|
||||
// Indicate that the CPU writes to this resource but never reads it.
|
||||
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the options to use when constructing textures.
|
||||
pub fn texture_options(&self) -> texture::Options {
|
||||
texture::Options {
|
||||
device: self.device.clone(),
|
||||
// Using an `*_srgb` pixel format makes Metal gamma encode the
|
||||
// pixels written to it *after* blending, which means we get linear
|
||||
// alpha blending rather than gamma-incorrect blending.
|
||||
pixel_format: self.color_format(),
|
||||
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
|
||||
// textureOptions is currently only used for custom shaders.
|
||||
usage: TextureUsage::custom_shader(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the options to use when constructing samplers.
|
||||
pub fn sampler_options(&self) -> sampler::Options {
|
||||
sampler::Options {
|
||||
device: self.device.clone(),
|
||||
|
||||
// These parameters match Shadertoy behaviors.
|
||||
min_filter: MTLSamplerMinMagFilter::Linear,
|
||||
mag_filter: MTLSamplerMinMagFilter::Linear,
|
||||
s_address_mode: MTLSamplerAddressMode::ClampToEdge,
|
||||
t_address_mode: MTLSamplerAddressMode::ClampToEdge,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the options to use when constructing textures for images.
|
||||
pub fn image_texture_options(
|
||||
&self,
|
||||
format: ImageTextureFormat,
|
||||
srgb: bool,
|
||||
) -> texture::Options {
|
||||
texture::Options {
|
||||
device: self.device.clone(),
|
||||
pixel_format: image_pixel_format(format, srgb),
|
||||
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
|
||||
// We only need to read from this texture from a shader.
|
||||
usage: TextureUsage::shader_read_only(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a new render target which can be presented by this API.
|
||||
pub fn init_target(&self, width: usize, height: usize) -> Result<Target, Error> {
|
||||
Target::init(&target::Options {
|
||||
device: self.device.clone(),
|
||||
// Using an `*_srgb` pixel format makes Metal gamma encode the
|
||||
// pixels written to it *after* blending, which means we get linear
|
||||
// alpha blending rather than gamma-incorrect blending.
|
||||
pixel_format: self.color_format(),
|
||||
storage_mode: self.default_storage_mode,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initializes a Texture suitable for the provided font atlas.
|
||||
pub fn init_atlas_texture(&self, atlas: &Atlas) -> Result<Texture, Error> {
|
||||
let pixel_format =
|
||||
atlas_pixel_format(atlas.format).expect("unsupported atlas format for Metal texture");
|
||||
|
||||
Texture::init(
|
||||
&texture::Options {
|
||||
device: self.device.clone(),
|
||||
pixel_format,
|
||||
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
|
||||
// We only need to read from this texture from a shader.
|
||||
usage: TextureUsage::shader_read_only(),
|
||||
},
|
||||
atlas.size as usize,
|
||||
atlas.size as usize,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the current size of the runtime surface.
|
||||
pub fn surface_size(&self) -> (u32, u32) {
|
||||
let (width, height, scale) = self.layer.bounds_and_scale();
|
||||
|
||||
// We need to clamp our runtime surface size to the maximum
|
||||
// possible texture size since we can't create a screen buffer
|
||||
// (texture) larger than that.
|
||||
surface_size(width, height, scale, self.max_texture_size)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Metal {
|
||||
fn drop(&mut self) {
|
||||
// Nothing may call back into a renderer that is going away.
|
||||
self.layer.set_display_callback(None);
|
||||
if let Some(pool) = self.autorelease_pool.take() {
|
||||
// SAFETY: the pool was pushed by `draw_frame_start` on this thread.
|
||||
unsafe { objc_autoreleasePoolPop(pool.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines the maximum 2D texture size supported by the device.
|
||||
/// We need to clamp our frame size to this if it's larger.
|
||||
fn query_max_texture_size(device: &ProtocolObject<dyn MTLDevice>) -> u32 {
|
||||
// https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf
|
||||
max_texture_size(
|
||||
device.supportsFamily(MTLGPUFamily::Apple10),
|
||||
device.supportsFamily(MTLGPUFamily::Apple3),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn choose_device() -> Result<Device, Error> {
|
||||
let devices = objc2_metal::MTLCopyAllDevices();
|
||||
|
||||
let mut chosen_device: Option<Device> = None;
|
||||
for device in &devices {
|
||||
// We want a GPU that’s connected to a display.
|
||||
if device.isHeadless() {
|
||||
continue;
|
||||
}
|
||||
chosen_device = Some(device.clone());
|
||||
// If the user has an eGPU plugged in, they probably want
|
||||
// to use it. Otherwise, integrated GPUs are better for
|
||||
// battery life and thermals.
|
||||
if device.isRemovable() || device.isLowPower() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
chosen_device.ok_or(Error::NoMetalDevice)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn choose_device() -> Result<Device, Error> {
|
||||
objc2_metal::MTLCreateSystemDefaultDevice().ok_or(Error::NoMetalDevice)
|
||||
}
|
||||
|
||||
impl GraphicsApi for Metal {
|
||||
const CUSTOM_SHADER_TARGET: shadertoy::Target = CUSTOM_SHADER_TARGET;
|
||||
const CUSTOM_SHADER_Y_IS_DOWN: bool = CUSTOM_SHADER_Y_IS_DOWN;
|
||||
const SWAP_CHAIN_COUNT: usize = SWAP_CHAIN_COUNT;
|
||||
|
||||
type Target = Target;
|
||||
type Texture = Texture;
|
||||
type Sampler = Sampler;
|
||||
type Buffer<T: Pod> = Buffer<T>;
|
||||
type BufferRef = Retained<ProtocolObject<dyn MTLBuffer>>;
|
||||
type Pipeline = Pipeline;
|
||||
type Shaders = Shaders;
|
||||
type GpuUniforms = MetalUniforms;
|
||||
type Frame = Frame;
|
||||
type RenderPass<'a> = RenderPass;
|
||||
|
||||
fn gpu_uniforms(uniforms: &Uniforms) -> MetalUniforms {
|
||||
MetalUniforms::from(uniforms)
|
||||
}
|
||||
|
||||
fn blending(&self) -> AlphaBlending {
|
||||
self.blending
|
||||
}
|
||||
|
||||
fn set_blending(&mut self, blending: AlphaBlending) {
|
||||
self.blending = blending;
|
||||
}
|
||||
|
||||
fn init_shaders(&self, custom_shaders: &[String]) -> Result<Shaders, ApiError> {
|
||||
Shaders::init(
|
||||
&self.device,
|
||||
custom_shaders,
|
||||
// Using an `*_srgb` pixel format makes Metal gamma encode the
|
||||
// pixels written to it *after* blending, which means we get linear
|
||||
// alpha blending rather than gamma-incorrect blending.
|
||||
self.color_format(),
|
||||
)
|
||||
.map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn surface_size(&self) -> Result<(u32, u32), ApiError> {
|
||||
Ok(Metal::surface_size(self))
|
||||
}
|
||||
|
||||
fn init_target(&self, width: usize, height: usize) -> Result<Target, ApiError> {
|
||||
Metal::init_target(self, width, height).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_buffer<T: Pod>(&self, len: usize) -> Result<Buffer<T>, ApiError> {
|
||||
Buffer::init(self.buffer_options(), len).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_buffer_fill<T: Pod>(&self, data: &[T]) -> Result<Buffer<T>, ApiError> {
|
||||
Buffer::init_fill(self.buffer_options(), data).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_texture(
|
||||
&self,
|
||||
kind: TextureKind,
|
||||
width: usize,
|
||||
height: usize,
|
||||
data: Option<&[u8]>,
|
||||
) -> Result<Texture, ApiError> {
|
||||
let opts = match kind {
|
||||
TextureKind::CustomShader => self.texture_options(),
|
||||
TextureKind::Image { format, srgb } => self.image_texture_options(format, srgb),
|
||||
};
|
||||
Texture::init(&opts, width, height, data).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_atlas_texture(&self, atlas: &Atlas) -> Result<Texture, ApiError> {
|
||||
Metal::init_atlas_texture(self, atlas).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_sampler(&self) -> Result<Sampler, ApiError> {
|
||||
Sampler::init(&self.sampler_options()).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn begin_frame(&self, target: Arc<Target>) -> Frame {
|
||||
Frame::begin(&self.queue, self.layer.clone(), target)
|
||||
}
|
||||
|
||||
/// Present the last presented target again. (noop for Metal)
|
||||
fn present_last_target(&self) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_last_target(&self) {}
|
||||
|
||||
/// Actions taken before doing anything in `drawFrame`.
|
||||
///
|
||||
/// Right now we use this to start an AutoreleasePool.
|
||||
fn draw_frame_start(&mut self) {
|
||||
debug_assert!(self.autorelease_pool.is_none());
|
||||
// SAFETY: balanced with the pop in `draw_frame_end` / `Drop`.
|
||||
self.autorelease_pool = Some(PoolToken(unsafe { objc_autoreleasePoolPush() }));
|
||||
}
|
||||
|
||||
/// Actions taken after `drawFrame` is done.
|
||||
///
|
||||
/// Right now we use this to end our AutoreleasePool.
|
||||
fn draw_frame_end(&mut self) {
|
||||
debug_assert!(self.autorelease_pool.is_some());
|
||||
if let Some(pool) = self.autorelease_pool.take() {
|
||||
// SAFETY: the pool was pushed by `draw_frame_start` on this thread.
|
||||
unsafe { objc_autoreleasePoolPop(pool.0) };
|
||||
}
|
||||
}
|
||||
|
||||
fn loop_enter(&mut self, draw: Arc<dyn Fn() + Send + Sync>) {
|
||||
self.layer.set_display_callback(Some(draw));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user