first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+5 -8
View File
@@ -1,11 +1,8 @@
use galaxyui_core::{
integration::TestDriver,
keymap::{CustomTag, Keystroke},
r#async::LocalBoxFuture,
AppContext, AssetProvider,
};
use galaxyui_core::integration::TestDriver;
use galaxyui_core::keymap::{CustomTag, Keystroke};
pub use galaxyui_core::platform::app::*;
use galaxyui_core::r#async::LocalBoxFuture;
use galaxyui_core::{AppContext, AssetProvider};
use super::AsInnerMut;
@@ -83,7 +80,7 @@ impl AppBuilder {
/// [`Keystroke`]-based binding using the provided `custom_tag_to_keystroke` function.
///
/// This can be useful in the cases where an application registers a binding with a
/// [`crate::keymap::Trigger::Custom`] for use in a Mac menu, but still wants to to register the
/// [`crate::keymap::Trigger::Custom`] for use in a Mac menu, but still wants to register the
/// binding with its corresponding `Keystroke` on other platforms that don't support menus.
pub fn convert_custom_triggers_to_keystroke_triggers(
&mut self,
+7 -9
View File
@@ -1,17 +1,15 @@
use futures::future::LocalBoxFuture;
use std::sync::mpsc;
use crate::platform::app::TerminationResult;
use crate::platform::test::FontDB as TestFontDB;
use crate::{
integration::TestDriver,
platform::{self},
AppContext, AssetProvider,
};
use futures::future::LocalBoxFuture;
use super::delegate::{self, AppDelegate};
use super::event_loop::{self, AppEvent};
use super::windowing::WindowManager;
use std::sync::mpsc;
use crate::integration::TestDriver;
use crate::platform::app::TerminationResult;
use crate::platform::test::FontDB as TestFontDB;
use crate::platform::{self};
use crate::{AppContext, AssetProvider};
pub struct App {
callbacks: platform::app::AppCallbacks,
@@ -1,18 +1,14 @@
use parking_lot::Mutex;
use crate::{
clipboard::InMemoryClipboard,
notification::{NotificationSendError, RequestPermissionsOutcome},
platform::{self, Cursor},
};
use std::mem::ManuallyDrop;
use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock};
use std::thread;
use parking_lot::Mutex;
use super::event_loop::AppEvent;
use crate::clipboard::InMemoryClipboard;
use crate::notification::{NotificationSendError, RequestPermissionsOutcome};
use crate::platform::{self, Cursor};
/// Stores the ID of the application's main thread, which we can reference
/// to determine if a given thread is the main thread or not.
@@ -44,7 +40,7 @@ impl AppDelegate {
fn send_event(&self, event: AppEvent) {
if self.event_sender.send(event).is_err() {
log::warn!("Tried to send event, but event loop is no longer running");
log::debug!("Tried to send event, but event loop is no longer running");
}
}
}
@@ -214,7 +210,7 @@ impl platform::DispatchDelegate for DispatchDelegate {
.send(AppEvent::RunTask(ManuallyDrop::new(task)))
.is_err()
{
log::warn!("Tried to send event, but event loop is no longer running");
log::debug!("Tried to send event, but event loop is no longer running");
}
}
}
@@ -1,14 +1,11 @@
use std::mem::ManuallyDrop;
use std::sync::mpsc::{Receiver, Sender};
use crate::{
platform::{
self,
app::{AppCallbackDispatcher, ApproveTerminateResult, TerminationResult},
TerminationMode,
},
AppContext, WindowId,
use crate::platform::app::{
AppCallbackDispatcher, ApproveTerminateResult, TerminationRequestSource, TerminationResult,
};
use crate::platform::{self, TerminationMode};
use crate::{AppContext, WindowId};
/// Application events handled on the headless platform's main thread.
pub(super) enum AppEvent {
@@ -51,7 +48,7 @@ pub(super) fn run(
let should_terminate = match termination_mode {
TerminationMode::Cancellable => {
matches!(
callbacks.should_terminate_app(),
callbacks.should_terminate_app(TerminationRequestSource::User),
ApproveTerminateResult::Terminate
)
}
@@ -9,6 +9,5 @@ mod windowing;
pub use app::App;
pub use delegate::AppDelegate;
#[cfg(target_os = "macos")]
pub(crate) use windowing::Window;
@@ -1,16 +1,16 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::mpsc};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::mpsc;
use anyhow::Result;
use crate::{
geometry::rect::RectF,
geometry::vector::{vec2f, Vector2F},
platform::{self, WindowOptions},
windowing::WindowCallbacks,
WindowId,
};
use super::event_loop::AppEvent;
use crate::geometry::rect::RectF;
use crate::geometry::vector::{vec2f, Vector2F};
use crate::platform::{self, WindowOptions};
use crate::windowing::WindowCallbacks;
use crate::WindowId;
pub struct WindowManager {
windows: HashMap<WindowId, Rc<Window>>,
+5 -12
View File
@@ -1,13 +1,10 @@
// Re-export a couple winit types and modules as the concrete implementations
// for the linux platform.
use super::app::AppBackend;
use super::AsInnerMut;
pub use crate::windowing::winit::app::App;
use crate::{
windowing::{self, WindowingSystem},
AppContext,
};
use super::{app::AppBackend, AsInnerMut};
use crate::windowing::{self, WindowingSystem};
use crate::AppContext;
/// An extension trait defining additional configurability for
/// applications when running on Linux.
@@ -56,11 +53,7 @@ pub fn user_windowing_system() -> WindowingSystem {
}
pub fn is_wsl() -> bool {
use std::sync::OnceLock;
static IS_WSL: OnceLock<bool> = OnceLock::new();
IS_WSL
.get_or_init(|| std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists())
.to_owned()
command::wsl::is_wsl()
}
pub fn is_wayland_env_var_set() -> bool {
+23
View File
@@ -0,0 +1,23 @@
use objc2_app_kit::{NSAlert, NSApplication, NSModalResponse};
/// Configures and runs an `NSAlert` modally, returning its response.
///
/// Exported via `#[no_mangle]` so the AppKit `showModal:modalId:` dispatch path
/// in `app.m` can call it by its C symbol; `app.m` invokes it inside a
/// main-queue block, so this runs on the main thread and `runModal` stays
/// synchronous.
#[no_mangle]
pub extern "C-unwind" fn configureAndRunModal(
alert: &NSAlert,
app: &NSApplication,
) -> NSModalResponse {
alert.setShowsSuppressionButton(true);
// It is generally frowned-upon to be overly assertive about putting our windows in
// the user's face. However, it is reasonable to do this before showing our modal. If
// we don't make ourselves the top active app, our modal might show up behind another
// app's window.
app.activateIgnoringOtherApps(true);
alert.runModal()
}
+130 -138
View File
@@ -1,90 +1,43 @@
use cocoa::appkit::NSApp;
use cocoa::foundation::{NSUInteger, NSURL};
use cocoa::{
base::{id, nil},
foundation::{NSArray, NSAutoreleasePool, NSData, NSString},
};
use std::borrow::Cow;
use std::ffi::CStr;
use std::os::raw::c_void;
use std::path::PathBuf;
use cocoa::base::id;
use futures_util::future::LocalBoxFuture;
use objc::{
class, msg_send,
runtime::{Object, Sel, BOOL, NO, YES},
sel, sel_impl,
use objc::runtime::{Object, Sel, BOOL, NO, YES};
use objc2::rc::{autoreleasepool, Retained};
use objc2::{msg_send, AnyThread, MainThreadMarker};
use objc2_app_kit::{NSAlert, NSApplication, NSImage, NSRunningApplication};
use objc2_foundation::{NSArray, NSData, NSString, NSUInteger, NSURL};
use galaxyui_core::assets::AssetProvider;
use galaxyui_core::integration::TestDriver;
use galaxyui_core::keymap::{Keystroke, Trigger};
use galaxyui_core::modals::{AlertDialog, ModalId};
use galaxyui_core::platform::app::{
AppCallbackDispatcher, ApproveTerminateResult, TerminationRequestSource,
};
use galaxyui_core::platform::menu::{Menu, MenuBar};
use galaxyui_core::platform::{self, FilePickerCallback, SaveFilePickerCallback};
use galaxyui_core::{AppContext, Event};
use std::{
borrow::Cow,
ffi::CStr,
os::raw::{c_char, c_void},
path::PathBuf,
};
use super::keycode::{Keycode, CMD_KEY, CONTROL_KEY, OPTION_KEY, SHIFT_KEY};
use super::menus::{make_dock_menu, make_main_menu};
use super::window::{get_window_state, IntegrationTestWindowManager, Window, WindowManager};
use crate::platform::app::{AppBackend, AppBuilder};
use crate::platform::AsInnerMut;
use crate::platform::{
app::{AppBackend, AppBuilder},
AsInnerMut,
};
use galaxyui_core::{
assets::AssetProvider,
integration::TestDriver,
keymap::{Keystroke, Trigger},
modals::{AlertDialog, ModalId},
platform::app::{AppCallbackDispatcher, ApproveTerminateResult},
platform::menu::{Menu, MenuBar},
platform::SaveFilePickerCallback,
platform::{self, FilePickerCallback},
AppContext, Event,
};
use super::{
keycode::{Keycode, CMD_KEY, CONTROL_KEY, OPTION_KEY, SHIFT_KEY},
make_nsstring,
menus::{make_dock_menu, make_main_menu},
window::{get_window_state, IntegrationTestWindowManager, Window, WindowManager},
};
pub trait NSAlert: Sized {
unsafe fn alloc(_: Self) -> id {
msg_send![class!(NSAlert), alloc]
}
unsafe fn init(self) -> id;
unsafe fn autorelease(self) -> id;
unsafe fn set_message_text(self, message_text: id);
unsafe fn set_informative_text(self, informative_text: id);
unsafe fn add_button_with_title(self, title: id);
}
impl NSAlert for id {
unsafe fn init(self) -> id {
msg_send![self, init]
}
unsafe fn autorelease(self) -> id {
msg_send![self, autorelease]
}
unsafe fn set_message_text(self, message_text: id) {
msg_send![self, setMessageText: message_text]
}
unsafe fn set_informative_text(self, informative_text: id) {
msg_send![self, setInformativeText: informative_text]
}
unsafe fn add_button_with_title(self, title: id) {
msg_send![self, addButtonWithTitle: title]
}
}
pub fn create_native_platform_modal(dialog: AlertDialog) -> id {
unsafe {
let alert = NSAlert::autorelease(NSAlert::init(NSAlert::alloc(nil)));
alert.set_informative_text(make_nsstring(&dialog.info_text));
alert.set_message_text(make_nsstring(&dialog.message_text));
for title in dialog.buttons {
alert.add_button_with_title(make_nsstring(&title));
}
alert
/// Builds a native macOS alert dialog from an [`AlertDialog`].
pub fn create_native_platform_modal(dialog: AlertDialog) -> Retained<NSAlert> {
// SAFETY: native modals are constructed on the main thread.
let mtm = unsafe { MainThreadMarker::new_unchecked() };
let alert = NSAlert::new(mtm);
alert.setInformativeText(&NSString::from_str(&dialog.info_text));
alert.setMessageText(&NSString::from_str(&dialog.message_text));
for title in dialog.buttons {
alert.addButtonWithTitle(&NSString::from_str(&title));
}
alert
}
const RUST_WRAPPER_IVAR_NAME: &str = "rustWrapper";
@@ -110,6 +63,9 @@ pub trait AppExt {
/// Sets the macOS dock menu constructor function.
fn set_dock_menu_builder(&mut self, value: impl FnOnce(&mut AppContext) -> Menu + 'static);
/// Sets whether the application should show its Dock icon on launch.
fn set_show_dock_icon_on_launch(&mut self, value: bool);
}
type MenuBarBuilderFn = Box<dyn FnOnce(&mut AppContext) -> MenuBar>;
@@ -121,6 +77,7 @@ pub struct App {
callbacks: AppCallbackDispatcher,
activate_on_launch: bool,
dev_icon: Option<Cow<'static, [u8]>>,
show_dock_icon_on_launch: bool,
menu_bar_builder: Option<MenuBarBuilderFn>,
dock_menu_builder: Option<DockMenuBuilderFn>,
init_fn: Option<platform::app::AppInitCallbackFn>,
@@ -162,6 +119,7 @@ impl App {
callbacks: AppCallbackDispatcher::new(callbacks, ui_app),
activate_on_launch: true,
dev_icon: None,
show_dock_icon_on_launch: true,
menu_bar_builder: None,
dock_menu_builder: None,
init_fn: None,
@@ -174,44 +132,47 @@ impl App {
) {
self.init_fn = Some(Box::new(init_fn));
unsafe {
let pool = NSAutoreleasePool::new(nil);
// The autorelease pool stays open for the whole app lifetime (`run` blocks
// until termination).
autoreleasepool(|_| {
// Get (and create, if necessary) the underlying NSApplication.
let app: id = get_warp_app();
// SAFETY: `get_warp_app()` returns the warp NSApplication subclass instance.
let app_ptr = unsafe { get_warp_app() };
let app = unsafe { &*app_ptr.cast::<NSApplication>() };
let running_app: id = msg_send![class!(NSRunningApplication), currentApplication];
let bundle_id: id = msg_send![running_app, bundleIdentifier];
let dev_icon = if bundle_id.is_null() {
self.dev_icon.as_ref().map(|dev_icon| {
let data: id = msg_send![class!(NSData), alloc];
let data: id = data.initWithBytes_length_(
dev_icon.as_ptr() as *const c_void,
dev_icon.len() as u64,
);
let image: id = msg_send![class!(NSImage), alloc];
image.initWithData_(data)
// When running without an application bundle (dev builds), install the
// provided dev icon as the app icon. This is a dev-only path: if the icon
// bytes fail to decode we skip the call below and leave the default icon.
let running_app = NSRunningApplication::currentApplication();
let dev_icon: Option<Retained<NSImage>> = if running_app.bundleIdentifier().is_none() {
self.dev_icon.as_ref().and_then(|dev_icon| {
let data = NSData::with_bytes(dev_icon);
NSImage::initWithData(NSImage::alloc(), &data)
})
} else {
None
};
let app_delegate: id = msg_send![app, delegate];
// SAFETY: the app and its delegate are exclusively owned here, so writing
// the `rustWrapper` ivar and messaging them is sound.
unsafe {
let app_delegate = app.delegate().expect("the warp app always has a delegate");
let self_ptr = Box::into_raw(Box::new(self));
(*app).set_ivar(RUST_WRAPPER_IVAR_NAME, self_ptr as *mut c_void);
(*app_delegate).set_ivar(RUST_WRAPPER_IVAR_NAME, self_ptr as *mut c_void);
let self_ptr = Box::into_raw(Box::new(self));
(*app_ptr).set_ivar(RUST_WRAPPER_IVAR_NAME, self_ptr as *mut c_void);
(*Retained::as_ptr(&app_delegate).cast::<Object>().cast_mut())
.set_ivar(RUST_WRAPPER_IVAR_NAME, self_ptr as *mut c_void);
if let Some(dev_icon) = dev_icon {
let _: () = msg_send![app, setApplicationIconImage: dev_icon];
if let Some(dev_icon) = dev_icon {
app.setApplicationIconImage(Some(&dev_icon));
}
app.run();
// App is done running when we get here, so we can reinstantiate the Box and drop it.
drop(Box::from_raw(self_ptr));
}
let _: () = msg_send![app, run];
let _: () = msg_send![pool, drain];
// App is done running when we get here, so we can reinstantiate the Box and drop it.
drop(Box::from_raw(self_ptr));
}
});
}
}
@@ -243,6 +204,13 @@ impl AppExt for AppBuilder {
AppBackend::Headless(_) => (),
}
}
fn set_show_dock_icon_on_launch(&mut self, value: bool) {
match self.as_inner_mut() {
AppBackend::CurrentPlatform(app) => app.show_dock_icon_on_launch = value,
AppBackend::Headless(_) => (),
}
}
}
unsafe fn get_app(object: &mut Object) -> &mut App {
@@ -291,31 +259,47 @@ pub unsafe extern "C-unwind" fn warp_app_will_finish_launching(this: &mut Object
let app = get_app(this);
// SAFETY: this delegate callback runs on the main thread.
let mtm = MainThreadMarker::new_unchecked();
let ns_app = NSApplication::sharedApplication(mtm);
if app.activate_on_launch {
let _: () = msg_send![NSApp(), activateIgnoringOtherApps: YES];
ns_app.activateIgnoringOtherApps(true);
}
if let Some(init_fn) = app.init_fn.take() {
app.callbacks.initialize_app(init_fn);
}
let app_delegate: id = msg_send![NSApp(), delegate];
let app_delegate = ns_app
.delegate()
.expect("the warp app always has a delegate");
if app.callbacks.has_internet_reachability_changed_callback() {
let _: () = msg_send![app_delegate, setReachabilityListener];
// `setReachabilityListener` is a custom warp app-delegate selector.
let _: () = msg_send![&*app_delegate, setReachabilityListener];
}
if let Some(menu_bar_builder) = app.menu_bar_builder.take() {
let menu_bar = app.callbacks.with_mutable_app_context(menu_bar_builder);
let nsmenu = make_main_menu(menu_bar);
let () = msg_send![NSApp(), setMainMenu: nsmenu];
ns_app.setMainMenu(Some(&nsmenu));
}
if let Some(dock_menu_builder) = app.dock_menu_builder.take() {
let dock_menu = app.callbacks.with_mutable_app_context(dock_menu_builder);
let nsmenu = make_dock_menu(dock_menu);
let _: () = msg_send![app_delegate, setDockMenu: nsmenu];
// `setDockMenu:` is a custom warp app-delegate selector.
let _: () = msg_send![&*app_delegate, setDockMenu: &*nsmenu];
}
let show_dock_icon = if app.show_dock_icon_on_launch {
YES
} else {
NO
};
// `setDockIconVisible:` is a custom warp app-delegate selector.
let _: BOOL = msg_send![&*app_delegate, setDockIconVisible: show_dock_icon];
}
#[no_mangle]
@@ -337,10 +321,18 @@ pub(crate) extern "C-unwind" fn warp_app_internet_reachability_changed(
/// Returns whether or not we can proceed with termination.
#[no_mangle]
pub(crate) extern "C-unwind" fn warp_app_should_terminate_app(this: &mut Object) -> BOOL {
pub(crate) extern "C-unwind" fn warp_app_should_terminate_app(
this: &mut Object,
system_initiated: BOOL,
) -> BOOL {
let app = unsafe { get_app(this) };
match app.callbacks.should_terminate_app() {
let source = if system_initiated != NO {
TerminationRequestSource::System
} else {
TerminationRequestSource::User
};
match app.callbacks.should_terminate_app(source) {
ApproveTerminateResult::Terminate => YES,
ApproveTerminateResult::Cancel => NO,
}
@@ -538,11 +530,13 @@ extern "C-unwind" fn cpu_will_sleep(this: &mut Object) {
#[no_mangle]
extern "C-unwind" fn warp_app_open_files(this: &mut Object, paths: id) {
// SAFETY: `paths` is an `NSArray<NSString>` of file paths.
let paths = unsafe {
let paths = &*paths.cast::<NSArray<NSString>>();
(0..paths.count())
.filter_map(|i| {
let path = paths.objectAtIndex(i);
match CStr::from_ptr(path.UTF8String() as *mut c_char).to_str() {
match CStr::from_ptr(path.UTF8String()).to_str() {
Ok(string) => Some(PathBuf::from(string)),
Err(err) => {
log::error!("error converting path to string: {err}");
@@ -558,11 +552,13 @@ extern "C-unwind" fn warp_app_open_files(this: &mut Object, paths: id) {
#[no_mangle]
extern "C-unwind" fn warp_app_open_urls(this: &mut Object, urls: id) {
// SAFETY: `urls` is an `NSArray<NSURL>`.
let urls = unsafe {
let urls = &*urls.cast::<NSArray<NSURL>>();
(0..urls.count())
.filter_map(|i| {
let url = urls.objectAtIndex(i).absoluteString();
match CStr::from_ptr(url.UTF8String() as *mut c_char).to_str() {
let url = urls.objectAtIndex(i).absoluteString()?;
match CStr::from_ptr(url.UTF8String()).to_str() {
Ok(string) => Some(string.to_string()),
Err(err) => {
log::error!("error converting url to string: {err}");
@@ -590,16 +586,15 @@ pub(crate) extern "C-unwind" fn warp_open_panel_file_selected(urls: id, callback
// avoid the memory leak that would occur if we left it in raw pointer form.
let callback = unsafe { Box::from_raw(callback as *mut FilePickerCallback) };
// SAFETY: `urls` is an `NSArray<NSURL>` of selected files.
let paths = unsafe {
let urls = &*urls.cast::<NSArray<NSURL>>();
(0..urls.count())
.map(|i| {
let file_url = urls.objectAtIndex(i);
let file_path: id = msg_send![file_url, path];
let slice = std::slice::from_raw_parts(
file_path.UTF8String() as *const std::ffi::c_uchar,
file_path.len(),
);
std::str::from_utf8_unchecked(slice).to_string()
urls.objectAtIndex(i)
.path()
.map(|file_path| file_path.to_string())
.unwrap_or_default()
})
.collect::<Vec<_>>()
};
@@ -608,6 +603,7 @@ pub(crate) extern "C-unwind" fn warp_open_panel_file_selected(urls: id, callback
log::info!("No file was selected. Dialog was cancelled.")
}
// SAFETY: `get_warp_app()` returns the warp NSApplication subclass instance.
let app = unsafe { get_app(&mut *get_warp_app()) };
app.callbacks.with_mutable_app_context(move |ctx| {
callback(Ok(paths), ctx);
@@ -619,23 +615,19 @@ pub(crate) extern "C-unwind" fn warp_open_panel_file_selected(urls: id, callback
pub(crate) extern "C-unwind" fn warp_save_panel_file_selected(url: id, callback: *mut c_void) {
let callback = unsafe { Box::from_raw(callback as *mut SaveFilePickerCallback) };
let path = if url.is_null() {
None
} else {
unsafe {
let file_path: id = msg_send![url, path];
let slice = std::slice::from_raw_parts(
file_path.UTF8String() as *const std::ffi::c_uchar,
file_path.len(),
);
Some(std::str::from_utf8_unchecked(slice).to_string())
}
// SAFETY: `url` is null or a valid `NSURL`.
let path = unsafe {
url.cast::<NSURL>()
.as_ref()
.and_then(|url| url.path())
.map(|file_path| file_path.to_string())
};
if path.is_none() {
log::info!("Save dialog was cancelled.");
}
// SAFETY: `get_warp_app()` returns the warp NSApplication subclass instance.
let app = unsafe { get_app(&mut *get_warp_app()) };
app.callbacks.with_mutable_app_context(move |ctx| {
callback(path, ctx);
+48 -64
View File
@@ -1,62 +1,54 @@
use anyhow::{anyhow, Result};
use cocoa::appkit::{NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString};
use cocoa::foundation::{NSArray, NSData};
use cocoa::{
base::{id, nil},
foundation::NSString,
};
use objc::{class, msg_send, sel, sel_impl};
use std::ffi::CStr;
use std::os::raw::{c_uchar, c_void};
use std::os::raw::c_uchar;
use std::slice;
use super::make_nsstring;
use anyhow::Result;
use cocoa::base::id;
use objc2::rc::Retained;
use objc2_app_kit::{NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString};
use objc2_foundation::{ns_string, NSArray, NSData, NSString};
use galaxyui_core::clipboard::{ClipboardContent, ImageData};
extern "C" {
fn getFilePathsFromPasteboard() -> id;
}
pub struct Clipboard(id);
pub struct Clipboard(Retained<NSPasteboard>);
unsafe impl Send for Clipboard {}
impl Clipboard {
pub fn new() -> Result<Self> {
let pboard = unsafe { NSPasteboard::generalPasteboard(nil) };
if pboard.is_null() {
Err(anyhow!("NSPasteboard::generalPasteboard returned nil"))
} else {
Ok(Clipboard(pboard))
}
// `generalPasteboard` is documented to always return the shared
// pasteboard, so objc2 models it as a non-null `Retained`.
Ok(Clipboard(NSPasteboard::generalPasteboard()))
}
}
unsafe fn pasteboard_type_for_image_mime_type(mime_type: &str) -> Option<id> {
let pasteboard_type = match mime_type {
"image/png" => "public.png",
"image/jpeg" => "public.jpeg",
"image/gif" => "public.gif",
"image/webp" => "public.webp",
"image/svg+xml" => "public.svg-image",
_ => return None,
};
Some(make_nsstring(pasteboard_type))
fn pasteboard_type_for_image_mime_type(mime_type: &str) -> Option<&'static NSString> {
match mime_type {
"image/png" => Some(ns_string!("public.png")),
"image/jpeg" => Some(ns_string!("public.jpeg")),
"image/gif" => Some(ns_string!("public.gif")),
"image/webp" => Some(ns_string!("public.webp")),
"image/svg+xml" => Some(ns_string!("public.svg-image")),
_ => None,
}
}
impl crate::Clipboard for Clipboard {
fn write(&mut self, contents: ClipboardContent) {
unsafe {
let nsstr = make_nsstring(&contents.plain_text);
let nsstr = NSString::from_str(&contents.plain_text);
self.0
.declareTypes_owner(NSArray::arrayWithObject(nil, NSPasteboardTypeString), nil);
NSPasteboard::setString_forType(self.0, nsstr, NSPasteboardTypeString);
.declareTypes_owner(&NSArray::from_slice(&[NSPasteboardTypeString]), None);
self.0.setString_forType(&nsstr, NSPasteboardTypeString);
if let Some(html) = contents.html {
let nsstr = make_nsstring(&html);
let nsstr = NSString::from_str(&html);
self.0
.addTypes_owner(NSArray::arrayWithObject(nil, NSPasteboardTypeHTML), nil);
NSPasteboard::setString_forType(self.0, nsstr, NSPasteboardTypeHTML);
.addTypes_owner(&NSArray::from_slice(&[NSPasteboardTypeHTML]), None);
self.0.setString_forType(&nsstr, NSPasteboardTypeHTML);
}
if let Some(images) = contents.images {
@@ -66,17 +58,13 @@ impl crate::Clipboard for Clipboard {
else {
continue;
};
let data: id = msg_send![class!(NSData), alloc];
let data: id = data.initWithBytes_length_(
image.data.as_ptr() as *const c_void,
image.data.len() as u64,
);
// `NSData::with_bytes` copies the image bytes into a +1-retained
// NSData. The pasteboard retains it in `setData:forType:`, and the
// `Retained` releases our reference when it drops at the loop end.
let data = NSData::with_bytes(&image.data);
self.0
.addTypes_owner(NSArray::arrayWithObject(nil, pasteboard_type), nil);
let _: () = msg_send![self.0, setData: data forType: pasteboard_type];
// Balance the +1 retain from `[NSData alloc]`. The pasteboard retains
// `data` in `setData:forType:`, so the object stays alive as needed.
let _: () = msg_send![data, release];
.addTypes_owner(&NSArray::from_slice(&[pasteboard_type]), None);
self.0.setData_forType(Some(&data), pasteboard_type);
}
}
}
@@ -86,11 +74,11 @@ impl crate::Clipboard for Clipboard {
unsafe {
// Try getting file paths from the clipboard. If we end up with an empty
// array of file paths, fallback to getting the string from the pasteboard.
let file_paths = getFilePathsFromPasteboard();
let file_paths = &*getFilePathsFromPasteboard().cast::<NSArray<NSString>>();
let available_paths = file_paths.count();
let text = NSPasteboard::stringForType(self.0, NSPasteboardTypeString);
let mut content = ClipboardContent::plain_text(if text != nil {
let text = self.0.stringForType(NSPasteboardTypeString);
let mut content = ClipboardContent::plain_text(if let Some(text) = text.as_deref() {
CStr::from_ptr(text.UTF8String())
.to_str()
.unwrap_or("")
@@ -114,8 +102,8 @@ impl crate::Clipboard for Clipboard {
);
}
let html = NSPasteboard::stringForType(self.0, NSPasteboardTypeHTML);
if html != nil {
let html = self.0.stringForType(NSPasteboardTypeHTML);
if let Some(html) = html.as_deref() {
content.html = Some(
CStr::from_ptr(html.UTF8String())
.to_str()
@@ -143,24 +131,20 @@ impl Clipboard {
// macOS pasteboard type identifiers for supported image formats
// Ordered by preference for web compatibility
let supported_pasteboard_types = [
make_nsstring("public.png"),
make_nsstring("public.jpeg"),
make_nsstring("public.gif"),
make_nsstring("public.webp"),
make_nsstring("public.svg-image"),
make_nsstring("com.compuserve.gif"),
ns_string!("public.png"),
ns_string!("public.jpeg"),
ns_string!("public.gif"),
ns_string!("public.webp"),
ns_string!("public.svg-image"),
ns_string!("com.compuserve.gif"),
];
let mut images = Vec::new();
for &pasteboard_type in &supported_pasteboard_types {
let data = NSPasteboard::dataForType(self.0, pasteboard_type);
if data != nil {
let length = NSData::length(data);
for pasteboard_type in supported_pasteboard_types {
if let Some(data) = self.0.dataForType(pasteboard_type) {
let length = data.len();
if length > 0 {
let bytes_ptr = NSData::bytes(data) as *const u8;
let bytes = slice::from_raw_parts(bytes_ptr, length as usize);
let mime_type = match CStr::from_ptr(pasteboard_type.UTF8String())
.to_str()
.unwrap_or("")
@@ -175,8 +159,8 @@ impl Clipboard {
// Try to extract filename from HTML content if available
let filename = {
let html = NSPasteboard::stringForType(self.0, NSPasteboardTypeHTML);
if html != nil {
let html = self.0.stringForType(NSPasteboardTypeHTML);
if let Some(html) = html.as_deref() {
let html_str =
CStr::from_ptr(html.UTF8String()).to_str().unwrap_or("");
if !html_str.is_empty() {
@@ -190,7 +174,7 @@ impl Clipboard {
};
images.push(ImageData {
data: bytes.to_vec(),
data: data.to_vec(),
mime_type: mime_type.to_string(),
filename,
});
+82 -65
View File
@@ -1,30 +1,30 @@
use super::app::create_native_platform_modal;
use super::keycode::{modifier_code, Keycode};
use super::utils::nsstring_as_str;
use super::{app, make_nsstring, Clipboard, Window};
use std::ffi::c_void;
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use cocoa::base::{BOOL, NO, YES};
use cocoa::foundation::NSUInteger;
use cocoa::{
appkit::{NSApp, NSRequestUserAttentionType},
base::{id, nil},
};
use cocoa::base::{id, nil, BOOL, NO, YES};
use objc2::{msg_send, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSCursor, NSRequestUserAttentionType};
use objc2_av_foundation::{AVAuthorizationStatus, AVCaptureDevice, AVMediaTypeAudio};
use objc2_foundation::NSUInteger;
use galaxyui_core::accessibility::AccessibilityContent;
use galaxyui_core::clipboard::InMemoryClipboard;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::modals::{AlertDialog, ModalId};
use galaxyui_core::notification::{NotificationSendError, RequestPermissionsOutcome};
use galaxyui_core::notification::{
NotificationSendError, RequestPermissionsOutcome, UserNotification,
};
use galaxyui_core::platform::{
Cursor, FilePickerCallback, FilePickerConfiguration, MicrophoneAccessState,
SendNotificationErrorCallback, TerminationMode,
};
use galaxyui_core::ApplicationBundleInfo;
use galaxyui_core::{
accessibility::AccessibilityContent, notification::UserNotification, platform, WindowId,
};
use objc::{class, msg_send, sel, sel_impl};
use std::ffi::c_void;
use std::path::Path;
use std::sync::Arc;
use galaxyui_core::{platform, ApplicationBundleInfo, WindowId};
use super::app::create_native_platform_modal;
use super::keycode::{modifier_code, Keycode};
use super::utils::nsstring_as_str;
use super::{app, make_nsstring, Clipboard, Window};
// Functions implemented in objC files.
extern "C" {
@@ -198,21 +198,19 @@ impl platform::Delegate for AppDelegate {
/// Sets the cursor shape
/// See https://developer.apple.com/documentation/appkit/nscursor?language=objc
fn set_cursor_shape(&self, cursor: Cursor) {
unsafe {
let cursor: id = match cursor {
Cursor::Arrow => msg_send![class!(NSCursor), arrowCursor],
Cursor::IBeam => msg_send![class!(NSCursor), IBeamCursor],
Cursor::Crosshair => msg_send![class!(NSCursor), crosshairCursor],
Cursor::OpenHand => msg_send![class!(NSCursor), openHandCursor],
Cursor::NotAllowed => msg_send![class!(NSCursor), operationNotAllowedCursor],
Cursor::PointingHand => msg_send![class!(NSCursor), pointingHandCursor],
Cursor::ResizeLeftRight => msg_send![class!(NSCursor), resizeLeftRightCursor],
Cursor::ResizeUpDown => msg_send![class!(NSCursor), resizeUpDownCursor],
Cursor::ClosedHand => msg_send![class!(NSCursor), closedHandCursor],
Cursor::DragCopy => msg_send![class!(NSCursor), dragCopyCursor],
};
let () = msg_send![cursor, set];
}
let cursor = match cursor {
Cursor::Arrow => NSCursor::arrowCursor(),
Cursor::IBeam => NSCursor::IBeamCursor(),
Cursor::Crosshair => NSCursor::crosshairCursor(),
Cursor::OpenHand => NSCursor::openHandCursor(),
Cursor::NotAllowed => NSCursor::operationNotAllowedCursor(),
Cursor::PointingHand => NSCursor::pointingHandCursor(),
Cursor::ResizeLeftRight => NSCursor::resizeLeftRightCursor(),
Cursor::ResizeUpDown => NSCursor::resizeUpDownCursor(),
Cursor::ClosedHand => NSCursor::closedHandCursor(),
Cursor::DragCopy => NSCursor::dragCopyCursor(),
};
cursor.set();
}
#[cfg(feature = "test-util")]
@@ -282,12 +280,14 @@ impl platform::Delegate for AppDelegate {
fn open_character_palette(&self) {
// Open the character palette in a async task on the main thread to
// ensure we don't double-borrow the app.
dispatch::Queue::main().exec_async(move || unsafe {
dispatch::Queue::main().exec_async(move || {
// See https://developer.apple.com/documentation/appkit/nsapplication/1428455-orderfrontcharacterpalette.
// If the `sender` argument is nil, the palette is shown relative to the
// first responder's cursor location. In our case, that will be the Warp
// host view, with a location set via the `active_cursor_position` API.
let () = msg_send![NSApp(), orderFrontCharacterPalette: nil];
// SAFETY: the closure runs on the main dispatch queue.
let mtm = unsafe { MainThreadMarker::new_unchecked() };
NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None);
});
}
@@ -304,12 +304,10 @@ impl platform::Delegate for AppDelegate {
}
fn request_user_attention(&self, _window_id: WindowId) {
unsafe {
let () = msg_send![
NSApp(),
requestUserAttention: NSRequestUserAttentionType::NSInformationalRequest
];
}
// SAFETY: delegate methods run on the main thread.
let mtm = unsafe { MainThreadMarker::new_unchecked() };
let _ = NSApplication::sharedApplication(mtm)
.requestUserAttention(NSRequestUserAttentionType::InformationalRequest);
}
fn request_desktop_notification_permissions(
@@ -373,8 +371,14 @@ impl platform::Delegate for AppDelegate {
fn show_native_platform_modal(&self, id: ModalId, modal: AlertDialog) {
let alert = create_native_platform_modal(modal);
// `ModalId` is `#[repr(transparent)]` over `usize`, matching the
// `showModal:modalId:` selector's `NSUInteger` parameter.
// SAFETY: `ModalId` and `NSUInteger` share the same representation.
let modal_id: NSUInteger = unsafe { std::mem::transmute(id) };
// SAFETY: `showModal:modalId:` is a custom warp NSApplication selector.
unsafe {
let _: () = msg_send![app::get_warp_app(), showModal: alert modalId: id];
let app = &*app::get_warp_app().cast::<NSApplication>();
let _: () = msg_send![app, showModal: &*alert, modalId: modal_id];
}
}
@@ -394,20 +398,38 @@ impl platform::Delegate for AppDelegate {
}
}
fn set_dock_icon_visible(&self, visible: bool) {
dispatch::Queue::main().exec_async(move || {
// SAFETY: the closure runs on the main dispatch queue.
let mtm = unsafe { MainThreadMarker::new_unchecked() };
let app = NSApplication::sharedApplication(mtm);
let app_delegate = app.delegate().expect("the warp app always has a delegate");
let value: BOOL = if visible { YES } else { NO };
// `setDockIconVisible:` is a custom warp app-delegate selector.
// SAFETY: messaging the app delegate on the main thread.
let _: BOOL = unsafe { msg_send![&*app_delegate, setDockIconVisible: value] };
});
}
fn terminate_app(&self, termination_mode: TerminationMode) {
// Execute `[NSApp terminate]` asynchronously on the main thread to
// ensure we don't accidentally run into any double-borrow errors.
dispatch::Queue::main().exec_async(move || unsafe {
dispatch::Queue::main().exec_async(move || {
// SAFETY: the closure runs on the main dispatch queue.
let mtm = unsafe { MainThreadMarker::new_unchecked() };
let app = NSApplication::sharedApplication(mtm);
match termination_mode {
// ContentTransferred windows have already moved their content to another
// window (e.g. during tab drag), so they can close immediately without
// prompting the user for confirmation.
TerminationMode::ForceTerminate | TerminationMode::ContentTransferred => {
let _: () = msg_send![NSApp(), setForceTermination];
// `setForceTermination` is a custom warp NSApplication selector.
// SAFETY: messaging the shared application.
let _: () = unsafe { msg_send![&*app, setForceTermination] };
}
TerminationMode::Cancellable => {}
}
let _: () = msg_send![NSApp(), terminate: nil];
app.terminate(None);
});
}
@@ -416,24 +438,20 @@ impl platform::Delegate for AppDelegate {
}
fn microphone_access_state(&self) -> MicrophoneAccessState {
unsafe {
let cls = class!(AVCaptureDevice);
// "soun" is not a typo, it's the correct constant name.
let media_type_audio = make_nsstring("soun");
// SAFETY: this is a global static variable, but simply reading it should be safe.
let media_type = unsafe { AVMediaTypeAudio };
let Some(media_type) = media_type else {
return MicrophoneAccessState::NotDetermined;
};
// AVAuthorizationStatus constants:
// 0 = AVAuthorizationStatusNotDetermined - User has not yet made a choice
// 1 = AVAuthorizationStatusRestricted - Restricted by system settings/parental controls
// 2 = AVAuthorizationStatusDenied - User explicitly denied access
// 3 = AVAuthorizationStatusAuthorized - User granted access
let status: i32 = msg_send![cls, authorizationStatusForMediaType: media_type_audio];
match status {
0 => MicrophoneAccessState::NotDetermined,
1 => MicrophoneAccessState::Restricted,
2 => MicrophoneAccessState::Denied,
3 => MicrophoneAccessState::Authorized,
_ => MicrophoneAccessState::NotDetermined, // fallback
}
// SAFETY: this can raise an exception if you pass an invalid media type, but we're only
// ever passing AVMediaTypeAudio here.
let status = unsafe { AVCaptureDevice::authorizationStatusForMediaType(media_type) };
match status {
AVAuthorizationStatus::Restricted => MicrophoneAccessState::Restricted,
AVAuthorizationStatus::Denied => MicrophoneAccessState::Denied,
AVAuthorizationStatus::Authorized => MicrophoneAccessState::Authorized,
_ => MicrophoneAccessState::NotDetermined, // fallback
}
}
}
@@ -473,8 +491,7 @@ pub unsafe extern "C-unwind" fn warp_on_notification_send_error(
impl platform::DispatchDelegate for DispatchDelegate {
fn is_main_thread(&self) -> bool {
let is_main_thread: BOOL = unsafe { msg_send![class!(NSThread), isMainThread] };
is_main_thread == YES
MainThreadMarker::new().is_some()
}
fn run_on_main_thread(&self, task: async_task::Runnable) {
+56 -54
View File
@@ -1,21 +1,16 @@
use cocoa::foundation::NSUInteger;
use std::{ffi::CStr, os::raw::c_char};
use std::ffi::CStr;
use galaxyui_core::event::{KeyEventDetails, ModifiersState};
use galaxyui_core::platform::keyboard::{KeyCode, PhysicalKey};
use galaxyui_core::{keymap::Keystroke, Event};
use cocoa::{
appkit::{NSEvent, NSEventModifierFlags, NSEventType},
base::{id, YES},
foundation::NSString,
};
use cocoa::base::id;
use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType};
use objc2_foundation::NSUInteger;
use pathfinder_geometry::vector::vec2f;
use galaxyui_core::event::{KeyEventDetails, ModifiersState};
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::keyboard::{KeyCode, PhysicalKey};
use galaxyui_core::Event;
use super::{
keycode::{scancode_to_physicalkey, Keycode},
utils::unicode_char_to_key,
};
use super::keycode::{scancode_to_physicalkey, Keycode};
use super::utils::unicode_char_to_key;
// Unpublished but widely known and stable flags for distinguishing left/right alt.
// Google "NX_DEVICELALTKEYMASK" for more.
@@ -24,11 +19,11 @@ const RIGHT_ALT_MASK: NSUInteger = 0x00000040;
fn modifier_flags_to_state(flags: NSEventModifierFlags) -> ModifiersState {
ModifiersState {
alt: flags.contains(NSEventModifierFlags::NSAlternateKeyMask),
cmd: flags.contains(NSEventModifierFlags::NSCommandKeyMask),
shift: flags.contains(NSEventModifierFlags::NSShiftKeyMask),
ctrl: flags.contains(NSEventModifierFlags::NSControlKeyMask),
func: flags.contains(NSEventModifierFlags::NSFunctionKeyMask),
alt: flags.contains(NSEventModifierFlags::Option),
cmd: flags.contains(NSEventModifierFlags::Command),
shift: flags.contains(NSEventModifierFlags::Shift),
ctrl: flags.contains(NSEventModifierFlags::Control),
func: flags.contains(NSEventModifierFlags::Function),
}
}
@@ -49,11 +44,12 @@ pub unsafe fn from_native(
window_height: Option<f32>,
is_first_mouse: bool,
) -> Option<Event> {
let event_type = native_event.eventType();
let native_event = &*native_event.cast::<NSEvent>();
let event_type = native_event.r#type();
// Filter out event types that aren't in the NSEventType enum.
// See https://github.com/servo/cocoa-rs/issues/155#issuecomment-323482792 for details.
match event_type as u64 {
match event_type.0 as u64 {
0 | 21 | 32 | 33 | 35 | 36 | 37 => {
return None;
}
@@ -62,7 +58,7 @@ pub unsafe fn from_native(
let modifiers = modifier_flags_to_state(native_event.modifierFlags());
match event_type {
NSEventType::NSKeyDown => {
NSEventType::KeyDown => {
let native_modifiers = native_event.modifierFlags();
// Get the base character for this key without any modifiers (including Shift)
@@ -76,8 +72,8 @@ pub unsafe fn from_native(
right_alt: (native_modifiers.bits() & RIGHT_ALT_MASK) != 0,
key_without_modifiers,
};
let unmodified_chars = native_event.charactersIgnoringModifiers();
let unmodified_chars = CStr::from_ptr(unmodified_chars.UTF8String() as *mut c_char)
let unmodified_chars = native_event.charactersIgnoringModifiers()?;
let unmodified_chars = CStr::from_ptr(unmodified_chars.UTF8String())
.to_str()
.ok()?;
@@ -88,24 +84,30 @@ pub unsafe fn from_native(
};
let keystroke = Keystroke {
ctrl: native_modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
alt: native_modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
shift: native_modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
cmd: native_modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
ctrl: native_modifiers.contains(NSEventModifierFlags::Control),
alt: native_modifiers.contains(NSEventModifierFlags::Option),
shift: native_modifiers.contains(NSEventModifierFlags::Shift),
cmd: native_modifiers.contains(NSEventModifierFlags::Command),
meta: false, /* handled separately */
key: unmodified_chars.into(),
};
let chars = native_event.characters().UTF8String() as *mut c_char;
let chars = if chars.is_null() {
// `UTF8String` can return null in some rare cases where the
// string isn't valid UTF-8. For example, if the user
// enters a UTF-8 surrogate character, e.g. U+DDDD, via the
// Unicode Hex Input keyboard, the conversion will produce
// null.
String::new()
} else {
CStr::from_ptr(chars).to_str().ok()?.to_owned()
let characters = native_event.characters();
let chars = match characters.as_deref() {
None => String::new(),
Some(characters) => {
let chars = characters.UTF8String();
if chars.is_null() {
// `UTF8String` can return null in some rare cases where the
// string isn't valid UTF-8. For example, if the user
// enters a UTF-8 surrogate character, e.g. U+DDDD, via the
// Unicode Hex Input keyboard, the conversion will produce
// null.
String::new()
} else {
CStr::from_ptr(chars).to_str().ok()?.to_owned()
}
}
};
Some(Event::KeyDown {
@@ -115,20 +117,20 @@ pub unsafe fn from_native(
is_composing: false,
})
}
NSEventType::NSMouseMoved => window_height.map(|window_height| Event::MouseMoved {
NSEventType::MouseMoved => window_height.map(|window_height| Event::MouseMoved {
position: vec2f(
native_event.locationInWindow().x as f32,
window_height - native_event.locationInWindow().y as f32,
),
cmd: native_event
.modifierFlags()
.contains(NSEventModifierFlags::NSCommandKeyMask),
.contains(NSEventModifierFlags::Command),
shift: native_event
.modifierFlags()
.contains(NSEventModifierFlags::NSShiftKeyMask),
.contains(NSEventModifierFlags::Shift),
is_synthetic: false,
}),
NSEventType::NSFlagsChanged => {
NSEventType::FlagsChanged => {
let key_code = native_key_code_to_key_code(native_event.keyCode());
window_height.map(|window_height| Event::ModifierStateChanged {
@@ -140,7 +142,7 @@ pub unsafe fn from_native(
key_code,
})
}
NSEventType::NSLeftMouseDown => window_height.map(|window_height| {
NSEventType::LeftMouseDown => window_height.map(|window_height| {
let position = vec2f(
native_event.locationInWindow().x as f32,
window_height - native_event.locationInWindow().y as f32,
@@ -165,14 +167,14 @@ pub unsafe fn from_native(
}
}
}),
NSEventType::NSLeftMouseUp => window_height.map(|window_height| Event::LeftMouseUp {
NSEventType::LeftMouseUp => window_height.map(|window_height| Event::LeftMouseUp {
position: vec2f(
native_event.locationInWindow().x as f32,
window_height - native_event.locationInWindow().y as f32,
),
modifiers,
}),
NSEventType::NSLeftMouseDragged => {
NSEventType::LeftMouseDragged => {
window_height.map(|window_height| Event::LeftMouseDragged {
position: vec2f(
native_event.locationInWindow().x as f32,
@@ -184,7 +186,7 @@ pub unsafe fn from_native(
// TODO: This option is deprecated by Apple in favour of NSEventTypeOtherMouseDown
// but we'll likely need to update cocoa.
// See https://developer.apple.com/documentation/appkit/nsothermousedown.
NSEventType::NSOtherMouseDown => {
NSEventType::OtherMouseDown => {
let window_height = window_height?;
let window_location = native_event.locationInWindow();
let position = vec2f(
@@ -192,8 +194,8 @@ pub unsafe fn from_native(
window_height - (window_location.y as f32),
);
let modifier_flags = native_event.modifierFlags();
let cmd = modifier_flags.contains(NSEventModifierFlags::NSCommandKeyMask);
let shift = modifier_flags.contains(NSEventModifierFlags::NSShiftKeyMask);
let cmd = modifier_flags.contains(NSEventModifierFlags::Command);
let shift = modifier_flags.contains(NSEventModifierFlags::Shift);
let click_count = native_event.clickCount() as u32;
match native_event.buttonNumber() {
@@ -219,20 +221,20 @@ pub unsafe fn from_native(
}
}
// For trackpads, this event will get triggered by the user's secondary click setting.
NSEventType::NSRightMouseDown => window_height.map(|window_height| Event::RightMouseDown {
NSEventType::RightMouseDown => window_height.map(|window_height| Event::RightMouseDown {
position: vec2f(
native_event.locationInWindow().x as f32,
window_height - native_event.locationInWindow().y as f32,
),
cmd: native_event
.modifierFlags()
.contains(NSEventModifierFlags::NSCommandKeyMask),
.contains(NSEventModifierFlags::Command),
shift: native_event
.modifierFlags()
.contains(NSEventModifierFlags::NSShiftKeyMask),
.contains(NSEventModifierFlags::Shift),
click_count: native_event.clickCount() as u32,
}),
NSEventType::NSScrollWheel => window_height.map(|window_height| Event::ScrollWheel {
NSEventType::ScrollWheel => window_height.map(|window_height| Event::ScrollWheel {
position: vec2f(
native_event.locationInWindow().x as f32,
window_height - native_event.locationInWindow().y as f32,
@@ -241,7 +243,7 @@ pub unsafe fn from_native(
native_event.scrollingDeltaX() as f32,
native_event.scrollingDeltaY() as f32,
),
precise: native_event.hasPreciseScrollingDeltas() == YES,
precise: native_event.hasPreciseScrollingDeltas(),
modifiers,
}),
_ => None,
+11 -10
View File
@@ -1,5 +1,9 @@
use super::text_layout::{layout_line, layout_text};
use crate::fonts::font_kit::{properties_to_font_kit, Rasterizer};
use std::any::Any;
use std::collections::HashMap;
use std::ops::Range;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, bail, Result};
use core_foundation::array::{CFArray, CFArrayRef};
use core_foundation::base::{CFType, ItemRef, TCFType};
@@ -14,7 +18,8 @@ use core_text::font_descriptor::{
SymbolicTraitAccessors, TraitAccessors,
};
use core_text::{font, font_collection, font_descriptor};
use dashmap::{mapref::entry::Entry, DashMap};
use dashmap::mapref::entry::Entry;
use dashmap::DashMap;
use font_kit::font::Font;
use font_kit::loaders::core_text::NativeFont;
use futures::future::BoxFuture;
@@ -30,13 +35,9 @@ use itertools::Itertools as _;
use ordered_float::OrderedFloat;
use pathfinder_geometry::rect::RectI;
use pathfinder_geometry::vector::{Vector2F, Vector2I};
use std::any::Any;
use std::collections::HashMap;
use std::ops::Range;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use super::text_layout::{layout_line, layout_text};
use crate::fonts::font_kit::{properties_to_font_kit, Rasterizer};
struct FontFamily {
name: String,
+3 -2
View File
@@ -1,5 +1,6 @@
use cocoa::foundation::{NSPoint, NSRect, NSSize};
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use objc2_foundation::{NSPoint, NSRect, NSSize};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
pub trait Vector2FExt {
fn to_ns_point(&self) -> NSPoint;
fn to_ns_size(&self) -> NSSize;
+20 -17
View File
@@ -1,14 +1,11 @@
use std::slice;
use cocoa::{
base::{id, nil, BOOL},
foundation::{NSArray, NSString, NSUInteger},
};
use cocoa::base::{id, BOOL};
use cocoa::foundation::NSUInteger;
use objc2::rc::Retained;
use objc2_foundation::{NSArray, NSNumber, NSString};
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::keyboard::{KeyCode, NativeKeyCode, PhysicalKey};
use objc::{msg_send, sel, sel_impl};
use super::make_nsstring;
// Modifier key mask values for the Carbon API.
pub const CMD_KEY: u16 = 256;
@@ -32,10 +29,11 @@ impl Keycode {
#[allow(clippy::useless_conversion)]
let key = keyCodeToChar(self.0 as u64, shift_key_pressed.into());
if key == nil {
if key.is_null() {
return None;
}
let key = &*key.cast::<NSString>();
let cstr = key.UTF8String() as *const u8;
std::str::from_utf8(slice::from_raw_parts(cstr, key.len()))
.ok()
@@ -46,16 +44,21 @@ impl Keycode {
// There could have multiple keycodes mapping to one virtual key. Return an iterator
// to all possible values of keycode here.
pub fn keycodes_from_key_name(key_name: &str) -> impl Iterator<Item = Keycode> {
unsafe {
let keycodes: id = charToKeyCodes(make_nsstring(key_name));
let keycodes_length = keycodes.count();
let key_name = NSString::from_str(key_name);
// `charToKeyCodes` borrows the string only for the duration of the call
// and returns an autoreleased array of NSNumber keycodes.
let keycodes: *const NSArray<NSNumber> =
unsafe { charToKeyCodes(Retained::as_ptr(&key_name) as id) }.cast();
let keycodes_length = if keycodes.is_null() {
0
} else {
unsafe { (*keycodes).count() }
};
(0..keycodes_length).map(move |i| {
let keycode: NSUInteger =
msg_send![keycodes.objectAtIndex(i), unsignedIntegerValue];
Self(keycode as u16)
})
}
(0..keycodes_length).map(move |i| {
let keycode = unsafe { (*keycodes).objectAtIndex(i).unsignedIntegerValue() };
Self(keycode as u16)
})
}
}
+166 -106
View File
@@ -1,38 +1,40 @@
use cocoa::appkit::{NSApp, NSEventModifierFlags, NSMenu, NSMenuItem};
use cocoa::base::selector;
use cocoa::{
appkit::{
NSDownArrowFunctionKey, NSEndFunctionKey, NSF10FunctionKey, NSF11FunctionKey,
NSF12FunctionKey, NSF13FunctionKey, NSF14FunctionKey, NSF15FunctionKey, NSF16FunctionKey,
NSF17FunctionKey, NSF18FunctionKey, NSF19FunctionKey, NSF1FunctionKey, NSF20FunctionKey,
NSF2FunctionKey, NSF3FunctionKey, NSF4FunctionKey, NSF5FunctionKey, NSF6FunctionKey,
NSF7FunctionKey, NSF8FunctionKey, NSF9FunctionKey, NSHomeFunctionKey, NSInsertFunctionKey,
NSLeftArrowFunctionKey, NSPageDownFunctionKey, NSPageUpFunctionKey,
NSRightArrowFunctionKey, NSUpArrowFunctionKey,
},
base::{id, nil},
foundation::{NSArray, NSAutoreleasePool, NSInteger},
use std::boxed::Box;
use std::cell::RefCell;
use std::collections::HashMap;
use std::ffi::c_void;
use std::rc::Rc;
use cocoa::base::{id, nil};
use lazy_static::lazy_static;
use objc2::rc::{autoreleasepool, Retained};
use objc2::runtime::Sel;
use objc2::{sel, MainThreadMarker};
use objc2_app_kit::{
NSApplication, NSControlStateValue, NSDownArrowFunctionKey, NSEndFunctionKey,
NSEventModifierFlags, NSF10FunctionKey, NSF11FunctionKey, NSF12FunctionKey, NSF13FunctionKey,
NSF14FunctionKey, NSF15FunctionKey, NSF16FunctionKey, NSF17FunctionKey, NSF18FunctionKey,
NSF19FunctionKey, NSF1FunctionKey, NSF20FunctionKey, NSF2FunctionKey, NSF3FunctionKey,
NSF4FunctionKey, NSF5FunctionKey, NSF6FunctionKey, NSF7FunctionKey, NSF8FunctionKey,
NSF9FunctionKey, NSHomeFunctionKey, NSInsertFunctionKey, NSLeftArrowFunctionKey, NSMenu,
NSMenuItem, NSPageDownFunctionKey, NSPageUpFunctionKey, NSRightArrowFunctionKey,
NSUpArrowFunctionKey,
};
use objc2_foundation::{ns_string, NSInteger, NSString};
use galaxyui_core::actions::StandardAction;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::menu::{
ItemTriggeredCallback, Menu, MenuBar, MenuItem, MenuItemProperties, MenuItemPropertyChanges,
UpdateMenuItemCallback,
};
use lazy_static::lazy_static;
use objc::runtime::{NO, YES};
use objc::{msg_send, sel, sel_impl};
use std::{boxed::Box, cell::RefCell, collections::HashMap, ffi::c_void, rc::Rc};
use super::app::callback_dispatcher;
use super::make_nsstring;
lazy_static! {
/// A mac-menu-specific map of key names to special characters used for the keyboard shortcuts
/// in the mac menus
static ref MENU_KEY_EQUIVALENTS: HashMap<&'static str, char> = {
fn to_char(key: u16) -> char {
char::from_u32(key.into()).unwrap()
fn to_char(key: u32) -> char {
char::from_u32(key).unwrap()
}
HashMap::from([
@@ -161,24 +163,41 @@ extern "C" {
}
struct StandardMenuItemProperties {
title: &'static str, // menu item title
action: &'static str, // the selector name
shortcut: &'static str, // the key equivalent string, or empty for none
/// The menu item title.
title: &'static NSString,
/// The selector to invoke.
action: Sel,
/// The key equivalent string, or empty for none.
shortcut: &'static NSString,
modifiers: NSEventModifierFlags,
}
enum KeyEquivalent {
Static(&'static NSString),
Dynamic(Retained<NSString>),
}
impl KeyEquivalent {
fn as_nsstring(&self) -> &NSString {
match self {
Self::Static(value) => value,
Self::Dynamic(value) => value,
}
}
}
// Get properties from a standard action.
fn resolve_standard_action(action: StandardAction) -> StandardMenuItemProperties {
let cmd = NSEventModifierFlags::NSCommandKeyMask;
let option = NSEventModifierFlags::NSAlternateKeyMask;
let ctrl = NSEventModifierFlags::NSControlKeyMask;
let cmd = NSEventModifierFlags::Command;
let option = NSEventModifierFlags::Option;
let ctrl = NSEventModifierFlags::Control;
let none = NSEventModifierFlags::empty();
fn make(
title: &'static str,
action: &'static str,
title: &'static NSString,
action: Sel,
modifiers: NSEventModifierFlags,
shortcut: &'static str,
shortcut: &'static NSString,
) -> StandardMenuItemProperties {
StandardMenuItemProperties {
title,
@@ -189,42 +208,73 @@ fn resolve_standard_action(action: StandardAction) -> StandardMenuItemProperties
}
match action {
StandardAction::Close => make("Close Window", "performClose:", none, ""),
StandardAction::Quit => make("Quit Galaxy", "terminate:", cmd, "q"),
StandardAction::Hide => make("Hide Galaxy", "hide:", cmd, "h"),
StandardAction::HideOtherApps => {
make("Hide Others", "hideOtherApplications:", cmd | option, "h")
}
StandardAction::ShowAllApps => make("Show All", "unhideAllApplications:", none, ""),
StandardAction::Minimize => make("Minimize", "performMiniaturize:", cmd, "m"),
StandardAction::Zoom => make("Zoom", "performZoom:", none, ""),
StandardAction::BringAllToFront => make("Bring All to Front", "arrangeInFront:", none, ""),
StandardAction::ToggleFullScreen => {
make("ToggleFullScreen", "toggleFullScreen:", cmd | ctrl, "f")
}
StandardAction::Paste => make("Paste", "paste:", none, ""),
StandardAction::Close => make(
ns_string!("Close Window"),
sel!(performClose:),
none,
ns_string!(""),
),
StandardAction::Quit => make(
ns_string!("Quit Warp"),
sel!(terminate:),
cmd,
ns_string!("q"),
),
StandardAction::Hide => make(ns_string!("Hide Warp"), sel!(hide:), cmd, ns_string!("h")),
StandardAction::HideOtherApps => make(
ns_string!("Hide Others"),
sel!(hideOtherApplications:),
cmd | option,
ns_string!("h"),
),
StandardAction::ShowAllApps => make(
ns_string!("Show All"),
sel!(unhideAllApplications:),
none,
ns_string!(""),
),
StandardAction::Minimize => make(
ns_string!("Minimize"),
sel!(performMiniaturize:),
cmd,
ns_string!("m"),
),
StandardAction::Zoom => make(ns_string!("Zoom"), sel!(performZoom:), none, ns_string!("")),
StandardAction::BringAllToFront => make(
ns_string!("Bring All to Front"),
sel!(arrangeInFront:),
none,
ns_string!(""),
),
StandardAction::ToggleFullScreen => make(
ns_string!("ToggleFullScreen"),
sel!(toggleFullScreen:),
cmd | ctrl,
ns_string!("f"),
),
StandardAction::Paste => make(ns_string!("Paste"), sel!(paste:), none, ns_string!("")),
}
}
/// Determine the key equivalent for the given keystroke
fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (id, NSEventModifierFlags) {
fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (KeyEquivalent, NSEventModifierFlags) {
let mut flags = NSEventModifierFlags::empty();
let keystroke = match keystroke {
Some(value) => value,
None => return (make_nsstring(""), flags),
None => return (KeyEquivalent::Static(ns_string!("")), flags),
};
let key_equivalent = match MENU_KEY_EQUIVALENTS.get(keystroke.key.as_str()) {
Some(c) => make_nsstring(String::from(*c)),
None => make_nsstring(&keystroke.key),
Some(c) => KeyEquivalent::Dynamic(NSString::from_str(&String::from(*c))),
None => KeyEquivalent::Dynamic(NSString::from_str(&keystroke.key)),
};
for (is_set, flag) in [
(keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask),
(keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask),
(keystroke.shift, NSEventModifierFlags::NSShiftKeyMask),
(keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask),
(keystroke.cmd, NSEventModifierFlags::Command),
(keystroke.alt, NSEventModifierFlags::Option),
(keystroke.shift, NSEventModifierFlags::Shift),
(keystroke.ctrl, NSEventModifierFlags::Control),
] {
if is_set {
flags |= flag
@@ -238,39 +288,42 @@ fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (id, NSEventModifier
unsafe fn apply_changes(changes: MenuItemPropertyChanges, item: id) {
// Wrap in a local autorelease pool: AppKit invokes `warp_menu_item_needs_update`
// on every menu validation (per menu open and per keystroke for shortcut matching),
// so this is a hot path. A local pool bounds peak memory for the NSString temporaries
// created here (item title, key equivalent) without relying on the outer AppKit pool.
let pool = NSAutoreleasePool::new(nil);
if let Some(name) = changes.name {
let _: () = msg_send![item, setTitle: make_nsstring(name)];
}
if let Some(keystroke) = changes.keystroke {
let (key_equivalent, modifiers) = resolve_key_equivalent(keystroke.as_ref());
let _: () = msg_send![item, setKeyEquivalent: key_equivalent];
let _: () = msg_send![item, setKeyEquivalentModifierMask: modifiers];
}
if let Some(disabled) = changes.disabled {
let enabled = if disabled { NO } else { YES };
let _: () = msg_send![item, setEnabled: enabled];
}
if let Some(checked) = changes.checked {
// NSControlStateValue has Off as 0, On as 1, Mixed as -1.
let control_state: NSInteger = i64::from(checked);
let _: () = msg_send![item, setState: control_state];
}
if let Some(submenu) = changes.submenu {
let nsmenu = submenu
.map(|menu_items| make_submenu(menu_items))
.unwrap_or(nil);
set_menu_item_submenu(item, nsmenu);
}
pool.drain();
// so this is a hot path. A local pool bounds peak memory for the temporaries AppKit
// produces here (e.g. inside `setTitle:`/`setKeyEquivalent:`) without relying on the
// outer AppKit pool.
autoreleasepool(|_| unsafe {
let menu_item = &*item.cast::<NSMenuItem>();
if let Some(name) = changes.name {
menu_item.setTitle(&NSString::from_str(&name));
}
if let Some(keystroke) = changes.keystroke {
let (key_equivalent, modifiers) = resolve_key_equivalent(keystroke.as_ref());
menu_item.setKeyEquivalent(key_equivalent.as_nsstring());
menu_item.setKeyEquivalentModifierMask(modifiers);
}
if let Some(disabled) = changes.disabled {
menu_item.setEnabled(!disabled);
}
if let Some(checked) = changes.checked {
// NSControlStateValue has Off as 0, On as 1, Mixed as -1.
let control_state = i64::from(checked) as NSControlStateValue;
menu_item.setState(control_state);
}
if let Some(submenu) = changes.submenu {
let nsmenu = match submenu {
Some(menu_items) => make_submenu(menu_items),
None => nil,
};
set_menu_item_submenu(item, nsmenu);
}
});
}
unsafe fn make_submenu(menu_items: Vec<MenuItem>) -> id {
let nsmenu = make_delegated_menu(make_nsstring(""));
let nsmenu = make_delegated_menu(ns_string!("") as *const NSString as id);
let nsmenu_ref = &*nsmenu.cast::<NSMenu>();
for menu_item in menu_items {
nsmenu.addItem_(make_menu_item(menu_item));
nsmenu_ref.addItem(&*make_menu_item(menu_item).cast::<NSMenuItem>());
}
nsmenu
}
@@ -296,19 +349,22 @@ unsafe fn make_menu_item(menu_item: MenuItem) -> id {
nsmenu_item
}
MenuItem::Standard(standard_action) => {
let mtm = MainThreadMarker::new_unchecked();
let properties = resolve_standard_action(standard_action);
let nsmenu_item = NSMenuItem::alloc(nil)
.initWithTitle_action_keyEquivalent_(
make_nsstring(properties.title),
selector(properties.action),
make_nsstring(properties.shortcut),
)
.autorelease();
nsmenu_item.setKeyEquivalentModifierMask_(properties.modifiers);
let _: id = msg_send![nsmenu_item, setTag: standard_action as libc::c_long];
nsmenu_item
let nsmenu_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
properties.title,
Some(properties.action),
properties.shortcut,
);
nsmenu_item.setKeyEquivalentModifierMask(properties.modifiers);
nsmenu_item.setTag(standard_action as NSInteger);
Retained::autorelease_ptr(nsmenu_item) as id
}
MenuItem::Separator => {
Retained::autorelease_ptr(NSMenuItem::separatorItem(MainThreadMarker::new_unchecked()))
as id
}
MenuItem::Separator => NSMenuItem::separatorItem(nil),
MenuItem::Services => make_services_menu_item(),
}
}
@@ -316,37 +372,41 @@ unsafe fn make_menu_item(menu_item: MenuItem) -> id {
/// \return an autoreleased NSMenuItem with a submenu represented by \p menu.
// This supports creating the top-level menu bar.
unsafe fn make_top_level_menu_item(menu: Menu) -> id {
let nsmenu = make_delegated_menu(make_nsstring(&menu.title));
let mtm = MainThreadMarker::new_unchecked();
let nsmenu = make_delegated_menu(Retained::as_ptr(&NSString::from_str(&menu.title)) as id);
let nsmenu = &*nsmenu.cast::<NSMenu>();
if menu.is_window_menu() {
// `setWindowsMenu` gives us all the default window menu items like
// 'Enter Full Screen' and 'Tile Window to Left of Screen'.
let () = msg_send![NSApp(), setWindowsMenu: nsmenu];
NSApplication::sharedApplication(mtm).setWindowsMenu(Some(nsmenu));
}
for menu_item in menu.menu_items {
nsmenu.addItem_(make_menu_item(menu_item));
nsmenu.addItem(&*make_menu_item(menu_item).cast::<NSMenuItem>());
}
let menuitem = NSMenuItem::alloc(nil).init().autorelease();
menuitem.setSubmenu_(nsmenu);
menuitem
let menuitem = NSMenuItem::new(mtm);
menuitem.setSubmenu(Some(nsmenu));
Retained::autorelease_ptr(menuitem) as id
}
/// \return an autoreleased NSMenu representing the given menu bar.
pub unsafe fn make_main_menu(menubar: MenuBar) -> id {
let main_menu = NSMenu::alloc(nil).init().autorelease();
/// \return an NSMenu representing the given menu bar.
pub unsafe fn make_main_menu(menubar: MenuBar) -> Retained<NSMenu> {
let mtm = MainThreadMarker::new_unchecked();
let main_menu = NSMenu::new(mtm);
for menu in menubar.menus {
main_menu.addItem_(make_top_level_menu_item(menu));
main_menu.addItem(&*make_top_level_menu_item(menu).cast::<NSMenuItem>());
}
main_menu
}
/// \return an autoreleased NSMenu representing the given dock menu.
pub unsafe fn make_dock_menu(menu: Menu) -> id {
let dock_menu = NSMenu::alloc(nil).init().autorelease();
/// \return an NSMenu representing the given dock menu.
pub unsafe fn make_dock_menu(menu: Menu) -> Retained<NSMenu> {
let mtm = MainThreadMarker::new_unchecked();
let dock_menu = NSMenu::new(mtm);
for item in menu.menu_items {
dock_menu.addItem_(make_menu_item(item));
dock_menu.addItem(&*make_menu_item(item).cast::<NSMenuItem>());
}
dock_menu
}
+12 -12
View File
@@ -1,5 +1,6 @@
#![allow(deprecated)]
mod alert;
mod app;
pub mod clipboard;
pub mod delegate;
@@ -15,28 +16,27 @@ pub mod utils;
mod window;
pub use app::{App, AppExt};
use clipboard::*;
use cocoa::base::{id, nil};
use cocoa::foundation::NSAutoreleasePool;
pub use delegate::{AppDelegate, IntegrationTestDelegate};
pub use fonts::FontDB;
pub use rendering::is_low_power_gpu_available;
pub use window::Window;
pub use window::WindowExt;
use clipboard::*;
use geometry::*;
use cocoa::{
base::{id, nil},
foundation::{NSAutoreleasePool, NSString},
};
use objc::{msg_send, sel, sel_impl};
use objc2::rc::Retained;
use objc2_foundation::NSString;
pub use rendering::is_low_power_gpu_available;
pub use window::{Window, WindowExt};
/// Create an autoreleased NSString from a string reference.
pub fn make_nsstring<S>(s: S) -> id
where
S: AsRef<str>,
{
unsafe { NSString::alloc(nil).init_str(s.as_ref()).autorelease() }
// `NSString::from_str` returns a +1-retained `Retained<NSString>`.
// `autorelease_ptr` hands that retain count to the innermost autorelease
// pool and returns the raw pointer.
Retained::autorelease_ptr(NSString::from_str(s.as_ref())).cast()
}
/// Holds a Cocoa autorelease pool and drains it when the guard is dropped.
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Result};
use chrono::DateTime;
use cocoa::base::id;
use cocoa::foundation::NSUInteger;
use objc2_foundation::NSUInteger;
use galaxyui_core::notification::{
NotificationResponse, NotificationSendError, RequestPermissionsOutcome,
};
+2 -1
View File
@@ -11,6 +11,7 @@
: NSObject <NSApplicationDelegate, NSMenuDelegate, UNUserNotificationCenterDelegate>
@property(strong) NSMenu *dockMenu;
- (BOOL)setDockIconVisible:(BOOL)visible;
@end
@@ -32,7 +33,7 @@ void warp_app_active_window_changed(id app);
void warp_app_notification_clicked(id app, double date, id data);
void warp_app_open_urls(id app, id urls);
void warp_app_os_appearance_changed(id app);
BOOL warp_app_should_terminate_app(id app);
BOOL warp_app_should_terminate_app(id app, BOOL systemInitiated);
BOOL warp_app_should_close_window(id app, id window);
BOOL warp_app_are_key_bindings_disabled_for_window(id app, id window);
BOOL warp_app_has_binding_for_keystroke(id app, id event);
+36 -1
View File
@@ -254,8 +254,30 @@ NSUInteger activeScreenId() {
}
}
// Returns YES when the in-flight quit Apple event carries a kAEQuitReason
// indicating the system (logout / restart / shutdown / scheduled OS update)
// initiated the termination, as opposed to the user quitting the app directly.
// Per AERegistry.h, the documented kAEQuitReason values are kAEQuitAll,
// kAEShutDown, kAERestart, and kAEReallyLogOut; a missing event or reason
// (both accessors return 0 on nil) means the user or another process quit us.
static BOOL isSystemInitiatedTermination(void) {
NSAppleEventDescriptor *event =
[[NSAppleEventManager sharedAppleEventManager] currentAppleEvent];
NSAppleEventDescriptor *reason = [event attributeDescriptorForKeyword:kAEQuitReason];
switch ([reason typeCodeValue]) {
case kAEQuitAll:
case kAEShutDown:
case kAERestart:
case kAEReallyLogOut:
return YES;
default:
return NO;
}
}
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)application {
BOOL okToTerminate = YES;
BOOL systemInitiated = isSystemInitiatedTermination();
// If this is the second termination attempt after we've already hidden the app, we can go ahead
// and terminate.
@@ -266,10 +288,18 @@ NSUInteger activeScreenId() {
if (!forceTermination) {
// Make sure the rust app doesn't have any reasons to interrupt quit, e.g. needs to relaunch
// for autoupdate, but launching the new process failed.
okToTerminate = warp_app_should_terminate_app(application);
okToTerminate = warp_app_should_terminate_app(application, systemInitiated);
}
if (okToTerminate) {
if (systemInitiated) {
// Comply immediately when the system asked us to quit. Anything but
// `NSTerminateNow` here (including the hide-then-reterminate dance
// below, which returns `NSTerminateCancel`) makes macOS treat Warp
// as blocking the logout/shutdown, which can abort a scheduled OS
// update and leave the app in a stuck-looking state (#12441).
return NSTerminateNow;
}
// We want to hide the application before we start the teardown
// process, to ensure the user isn't affected by any slow teardown
// steps. The tricky part here is that a call to `[NSApp hide]` isn't
@@ -437,6 +467,11 @@ NSUInteger activeScreenId() {
return self.dockMenu;
}
- (BOOL)setDockIconVisible:(BOOL)visible {
NSApplicationActivationPolicy policy =
visible ? NSApplicationActivationPolicyRegular : NSApplicationActivationPolicyAccessory;
return [NSApp setActivationPolicy:policy];
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler {
@@ -13,5 +13,6 @@
enableTitlebarDrag:(BOOL)enableTitlebarDrag
testMode:(BOOL)testMode;
- (void)setAsyncCallback:(BOOL)shouldAsync;
- (void)setPresentsWithTransaction:(BOOL)presentsWithTransaction;
- (BOOL)keyDownImpl:(NSEvent *)event;
@end
@@ -55,6 +55,14 @@ void warp_marked_text_cleared(WarpHostView *);
// Whether we're in the middle of a call to interpretKeyEvents.
BOOL interpretingKeyEvents;
// Whether the IME modified marked text (via setMarkedText: or unmarkText)
// during the current interpretKeyEvents: pass. Used to avoid wiping a
// freshly-set marked text in the split-commit scenario where an IME
// calls insertText: (committing some text) and then setMarkedText: (with
// new in-progress text) in the same keystroke. Without this, the trailing
// unmarkText in keyDownImpl would clobber that new marked text.
BOOL imeTouchedMarkedTextDuringInterpret;
}
- (BOOL)acceptsFirstResponder {
@@ -145,6 +153,10 @@ void warp_marked_text_cleared(WarpHostView *);
- (void)setAsyncCallback:(BOOL)shouldAsync {
asyncCallback = shouldAsync;
}
- (void)setPresentsWithTransaction:(BOOL)presentsWithTransaction {
CAMetalLayer *layer = (CAMetalLayer *)self.layer;
layer.presentsWithTransaction = presentsWithTransaction;
}
- (void)keyDown:(NSEvent *)event {
[self keyDownImpl:event];
@@ -153,6 +165,7 @@ void warp_marked_text_cleared(WarpHostView *);
- (BOOL)keyDownImpl:(NSEvent *)event {
BOOL wasComposing = [self hasMarkedText];
[textToInsert setString:@""];
imeTouchedMarkedTextDuringInterpret = NO;
// Interpret the key events here so we could check whether user is composing
// text within the IME and pass the state down to the KeyDown events.
@@ -182,7 +195,14 @@ void warp_marked_text_cleared(WarpHostView *);
// Dispatch TypedCharacter event after KeyDown has been dispatched.
if ([textToInsert length] > 0 && !handled) {
warp_handle_insert_text(self, (NSString *)textToInsert);
[self unmarkText];
// Only clear marked text if the IME did not touch it during this
// interpretKeyEvents pass. Otherwise we'd either fire a redundant
// ClearMarkedText (if IME already cleared) or, worse, wipe the new
// marked text the IME just set in a split-commit (e.g. Japanese IME
// committing a phrase and queuing the next character as marked text).
if (!imeTouchedMarkedTextDuringInterpret) {
[self unmarkText];
}
}
return handled;
@@ -259,7 +279,7 @@ void warp_marked_text_cleared(WarpHostView *);
layer.allowsNextDrawableTimeout = NO;
layer.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable;
layer.needsDisplayOnBoundsChange = YES;
layer.presentsWithTransaction = YES;
layer.presentsWithTransaction = NO;
layer.delegate = self;
layer.opaque = NO;
return layer;
@@ -465,6 +485,10 @@ void warp_marked_text_cleared(WarpHostView *);
- (void)setMarkedText:(id)string
selectedRange:(NSRange)selectedRange
replacementRange:(NSRange)replacementRange {
if (interpretingKeyEvents) {
imeTouchedMarkedTextDuringInterpret = YES;
}
[markedText release];
if ([string isKindOfClass:[NSAttributedString class]])
markedText = [[NSMutableAttributedString alloc] initWithAttributedString:string];
@@ -482,6 +506,9 @@ void warp_marked_text_cleared(WarpHostView *);
}
- (void)unmarkText {
if (interpretingKeyEvents) {
imeTouchedMarkedTextDuringInterpret = YES;
}
[[markedText mutableString] setString:@""];
if (self.readyForWarp) {
warp_update_ime_state(self, NO);
+110 -3
View File
@@ -21,6 +21,8 @@ NSWindowStyleMask warpWindowMask = NSWindowStyleMaskClosable | NSWindowStyleMask
// The default macOS titlebar height (in points).
static const CGFloat DEFAULT_TITLEBAR_HEIGHT = 28.0;
static const NSSize MIN_WINDOW_SIZE = {480.0, 192.0};
static const NSSize TEST_MIN_WINDOW_SIZE = {124.0, 34.0};
// A back-to-front ordered array of windows, identified by their `windowNumber`
// property.
@@ -141,12 +143,20 @@ NSNumber *previouslyActiveAppPID;
// we explicitly force callbacks to be synchronous if it's caused by the user instead
// of another system call (such as the active screen changing)
[warp_view setAsyncCallback:NO];
// While the user is dragging to resize the window, we want to present frames
// within transactions to ensure the resize is visually smooth and there is no
// stuttering resulting from asynchronous presentation.
[warp_view setPresentsWithTransaction:YES];
}
- (void)windowDidEndLiveResize:(NSNotification *)notification {
WarpWindow *warp_window = notification.object;
WarpHostView *warp_view = warp_window.contentView;
// Reset state changed in `windowWillStartLiveResize`.
[warp_view setAsyncCallback:YES];
[warp_view setPresentsWithTransaction:NO];
}
- (void)setForceTermination {
@@ -285,6 +295,7 @@ static NSLayoutConstraint *configure_titlebar_height(NSWindow *window, CGFloat h
void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, bool hideTitleBar) {
window.testMode = testMode;
window.hideTitleBar = hideTitleBar;
NSSize minWindowSize = testMode ? TEST_MIN_WINDOW_SIZE : MIN_WINDOW_SIZE;
// Set the background color to clear to support window background transparency. When this is set
// to NSColor.clearColor with alpha = 0 and window drop shadows are enabled, MacOS renders a
@@ -298,8 +309,22 @@ void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, boo
window.acceptsMouseMovedEvents = YES;
window.titlebarAppearsTransparent = hideTitleBar;
window.titleVisibility = hideTitleBar ? NSWindowTitleHidden : NSWindowTitleVisible;
window.minSize = minWindowSize;
window.contentMinSize = minWindowSize;
if ([window respondsToSelector:@selector(setMinFullScreenContentSize:)]) {
window.minFullScreenContentSize = minWindowSize;
}
}
@interface NSWindow (PrivateAPI)
- (NSInteger)_resizeDirectionForMouseLocation:(NSPoint)location;
@end
@interface WarpWindow ()
- (NSButton *)standardWindowButtonAtEvent:(NSEvent *)event;
- (BOOL)eventIsOverResizeEdge:(NSEvent *)event;
@end
@implementation WarpWindow {
// The windowState is managed on the Rust side.
void *windowState;
@@ -316,6 +341,7 @@ void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, boo
// macOS from cascading or clamping the window position while a tab-drag preview window is
// being created and positioned under the cursor.
BOOL _suppressFrameConstraintsDuringDrag;
BOOL _leftMouseDownStartedInNativeWindowChrome;
}
@synthesize testMode;
@@ -385,8 +411,50 @@ void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, boo
return [super constrainFrameRect:frameRect toScreen:screen];
}
- (NSButton *)standardWindowButtonAtEvent:(NSEvent *)event {
NSWindowButton buttons[] = {
NSWindowCloseButton,
NSWindowMiniaturizeButton,
NSWindowZoomButton,
};
for (NSUInteger i = 0; i < sizeof(buttons) / sizeof(buttons[0]); i++) {
NSButton *button = [self standardWindowButton:buttons[i]];
if (button && !button.hidden) {
NSPoint point = [button convertPoint:event.locationInWindow fromView:nil];
if (NSPointInRect(point, button.bounds)) {
return button;
}
}
}
return nil;
}
- (BOOL)eventIsOverResizeEdge:(NSEvent *)event {
if ((self.styleMask & NSWindowStyleMaskResizable) == 0) {
return NO;
}
if ([self respondsToSelector:@selector(_resizeDirectionForMouseLocation:)]) {
return [self _resizeDirectionForMouseLocation:event.locationInWindow] != -1;
}
return NO;
}
- (void)sendEvent:(NSEvent *)event {
switch (event.type) {
case NSEventTypeLeftMouseDown: {
NSButton *windowButton = [self standardWindowButtonAtEvent:event];
if (windowButton) {
_leftMouseDownStartedInNativeWindowChrome = NO;
[windowButton mouseDown:event];
break;
}
_leftMouseDownStartedInNativeWindowChrome = [self eventIsOverResizeEdge:event];
[super sendEvent:event];
break;
}
// In some cases, NSWindow's default sendEvent: implementation will dispatch a MouseDown
// event and subsequent MouseDragged events to the content view, but then dispatch the
// remaining MouseDragged events and MouseUp event elsewhere.
@@ -396,10 +464,27 @@ void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, boo
// This breaks drag-and-drop for panes and tabs (see CLD-2581), so we work around it with
// custom dispatching.
case NSEventTypeLeftMouseUp:
[self.contentView mouseUp:event];
if (@available(macOS 27, *)) {
if (_leftMouseDownStartedInNativeWindowChrome) {
[super sendEvent:event];
} else {
[self.contentView mouseUp:event];
}
} else {
[self.contentView mouseUp:event];
}
_leftMouseDownStartedInNativeWindowChrome = NO;
break;
case NSEventTypeLeftMouseDragged:
[self.contentView mouseDragged:event];
if (@available(macOS 27, *)) {
if (_leftMouseDownStartedInNativeWindowChrome) {
[super sendEvent:event];
} else {
[self.contentView mouseDragged:event];
}
} else {
[self.contentView mouseDragged:event];
}
break;
// The NSWindow's default sendEvent: implementation does not propagate RightMouseDown events
@@ -432,6 +517,15 @@ void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, boo
// We need to bypass the default performKeyEquivalent implementation which, in the case of
// having keybinding conflicts with MacOS itself, yields priority to the OS.
if ([event type] == NSEventTypeKeyDown) {
// Skip the key-equivalent priority path while the IME has marked text. Arrow keys carry
// NSEventModifierFlagFunction, so AppKit delivers them here before keyDown:. If we call
// keyDownImpl and Rust suppresses the keystroke (composing mode), we return NO, and AppKit
// proceeds to call keyDown: — running interpretKeyEvents a second time for the same event.
// See #9709.
if ([(WarpHostView *)self.contentView hasMarkedText]) {
return [super performKeyEquivalent:event];
}
NSApplication *application = [NSApplication sharedApplication];
// If we are recording a keystroke for an EditableBinding.
@@ -456,8 +550,12 @@ void init_warp_nswindow(NSWindow<WarpWindowProtocol> *window, bool testMode, boo
WarpWindowDelegate *delegate = self.delegate;
if (forceTermination) {
[delegate setForceTermination];
// Bypass performClose: (which can be deferred or vetoed by the
// delegate's shouldClose) and tear the window down right away.
[self close];
} else {
[self performClose:nil];
}
[self performClose:nil];
});
}
@@ -1001,6 +1099,15 @@ void hide_window(WarpWindow<WarpWindowProtocol> *window) {
[window orderOut:nil];
}
// Sets the per-window opacity. Unlike `hide_window`, this does not change the
// window's z-order, key state, or the app's active state — making it a much
// cheaper way to visually hide a window (e.g. a tab drag preview) without
// triggering AppKit's `orderOut:` machinery or the previous-app activation
// dance.
void set_window_alpha(WarpWindow<WarpWindowProtocol> *window, double alpha) {
[window setAlphaValue:alpha];
}
void set_window_title(id window, NSString *title) {
if ([window isKindOfClass:[WarpPanel class]] && [window isVisible]) {
// For the hotkey window (which is an NSPanel), we need to explicitly
@@ -1,5 +1,13 @@
use std::ffi::c_void;
use std::ptr::NonNull;
use galaxyui_core::platform::CapturedFrame;
use metal::{MTLPixelFormat, MTLStorageMode};
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{
MTLDevice, MTLOrigin, MTLPixelFormat, MTLRegion, MTLSize, MTLStorageMode, MTLTexture,
MTLTextureDescriptor, MTLTextureUsage,
};
use pathfinder_geometry::vector::Vector2F;
#[cfg(test)]
@@ -19,12 +27,15 @@ mod tests;
/// # Returns
/// * `Some(CapturedFrame)` containing the RGBA pixel data if successful
/// * `None` if the texture dimensions are invalid
pub fn capture_frame(texture: &metal::TextureRef, size: Vector2F) -> Option<CapturedFrame> {
pub fn capture_frame(
texture: &ProtocolObject<dyn MTLTexture>,
size: Vector2F,
) -> Option<CapturedFrame> {
let width = size.x() as usize;
let height = size.y() as usize;
if width == 0 || height == 0 {
log::warn!("Invalid texture dimensions: {}x{}", width, height);
log::warn!("Invalid texture dimensions: {width}x{height}");
return None;
}
@@ -33,21 +44,26 @@ pub fn capture_frame(texture: &metal::TextureRef, size: Vector2F) -> Option<Capt
let mut pixel_data: Vec<u8> = vec![0u8; buffer_size];
let region = metal::MTLRegion {
origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
size: metal::MTLSize {
width: width as u64,
height: height as u64,
let region = MTLRegion {
origin: MTLOrigin { x: 0, y: 0, z: 0 },
size: MTLSize {
width,
height,
depth: 1,
},
};
texture.get_bytes(
pixel_data.as_mut_ptr() as *mut std::ffi::c_void,
bytes_per_row as u64,
region,
0,
);
// SAFETY: `pixel_data` holds `bytes_per_row * height` bytes, matching the requested region and
// row stride, so Metal copies the texture contents into a valid buffer.
unsafe {
texture.getBytes_bytesPerRow_fromRegion_mipmapLevel(
NonNull::new(pixel_data.as_mut_ptr() as *mut c_void)
.expect("pixel buffer pointer is non-null"),
bytes_per_row,
region,
0,
);
}
Some(CapturedFrame::new_bgra(
width as u32,
@@ -79,26 +95,30 @@ pub(crate) fn convert_bgra_to_rgba(data: &mut [u8]) {
/// * A new Metal texture that can be rendered to and read back from
#[allow(dead_code)]
pub fn create_capture_texture(
device: &metal::Device,
width: u64,
height: u64,
device: &ProtocolObject<dyn MTLDevice>,
width: usize,
height: usize,
pixel_format: MTLPixelFormat,
) -> metal::Texture {
let texture_descriptor = metal::TextureDescriptor::new();
texture_descriptor.set_pixel_format(pixel_format);
texture_descriptor.set_width(width);
texture_descriptor.set_height(height);
texture_descriptor.set_depth(1);
texture_descriptor.set_mipmap_level_count(1);
texture_descriptor.set_sample_count(1);
texture_descriptor.set_array_length(1);
) -> Retained<ProtocolObject<dyn MTLTexture>> {
let texture_descriptor = MTLTextureDescriptor::new();
texture_descriptor.setPixelFormat(pixel_format);
// SAFETY: the dimensions are caller-provided valid texture sizes within Metal limits.
unsafe {
texture_descriptor.setWidth(width);
texture_descriptor.setHeight(height);
texture_descriptor.setDepth(1);
texture_descriptor.setMipmapLevelCount(1);
texture_descriptor.setSampleCount(1);
texture_descriptor.setArrayLength(1);
}
// Set usage flags for rendering and reading
texture_descriptor
.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
texture_descriptor.setUsage(MTLTextureUsage::RenderTarget | MTLTextureUsage::ShaderRead);
// Use managed storage mode so we can read it back
texture_descriptor.set_storage_mode(MTLStorageMode::Managed);
texture_descriptor.setStorageMode(MTLStorageMode::Managed);
device.new_texture(&texture_descriptor)
device
.newTextureWithDescriptor(&texture_descriptor)
.expect("device should create a capture texture")
}
@@ -1,3 +1,6 @@
use objc2::runtime::ProtocolObject;
use objc2_metal::MTLDevice;
pub mod frame_capture;
mod renderer;
mod renderer_manager;
@@ -8,6 +11,6 @@ pub use renderer_manager::RendererManager;
///
/// In dual GPU Macs, this is `false` for the discrete high-performance GPU.
#[cfg_attr(wgpu, allow(dead_code))]
pub fn is_integrated_gpu(device: &metal::Device) -> bool {
device.is_low_power() && !device.is_removable()
pub fn is_integrated_gpu(device: &ProtocolObject<dyn MTLDevice>) -> bool {
device.isLowPower() && !device.isRemovable()
}
@@ -1,54 +1,61 @@
use crate::rendering::atlas::{AllocatedRegion, TextureId};
use crate::rendering::{get_best_dash_gap, GlyphCache, GlyphRasterBoundsFn, RasterizeGlyphFn};
use galaxyui_core::{
fonts::{self, SubpixelAlignment},
rendering::{self, texture_cache::TextureCache},
use std::collections::HashMap;
use std::ffi::c_void;
use std::fs::File;
use std::io::Write;
use std::mem;
use std::ptr::NonNull;
use std::sync::Once;
use dispatch2::DispatchData;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::NSString;
use objc2_metal::{
MTLBlendFactor, MTLBlendOperation, MTLBuffer, MTLClearColor, MTLCommandBuffer,
MTLCommandEncoder, MTLCommandQueue, MTLDevice, MTLDrawable, MTLFunction, MTLIndexType,
MTLLibrary, MTLLoadAction, MTLOrigin, MTLPixelFormat, MTLPrimitiveType, MTLRegion,
MTLRenderCommandEncoder, MTLRenderPassDescriptor, MTLRenderPipelineDescriptor,
MTLRenderPipelineState, MTLResourceOptions, MTLScissorRect, MTLSize, MTLStoreAction,
MTLTexture, MTLTextureDescriptor, MTLViewport,
};
use objc2_quartz_core::CAMetalDrawable;
use pathfinder_color::{ColorF, ColorU};
use pathfinder_geometry::rect::{RectF, RectI};
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxyui_core::fonts::{self, canvas, RasterizedGlyph, SubpixelAlignment};
use galaxyui_core::platform::CapturedFrame;
use galaxyui_core::rendering::texture_cache::TextureCache;
use galaxyui_core::rendering::{self};
use galaxyui_core::scene::{CornerRadius, GlyphFade, GlyphKey, Icon, Image, Layer, Scene};
use super::frame_capture::capture_frame;
use crate::platform::mac::rendering::renderer::Device;
use crate::platform::mac::window::WindowState;
use cocoa::base::id;
use galaxyui_core::platform::CapturedFrame;
use metal::{
Function, MTLBlendFactor, MTLBlendOperation, MTLIndexType, MTLPrimitiveType,
MTLResourceOptions, RenderPipelineDescriptor,
};
use objc::{msg_send, sel, sel_impl};
use galaxyui_core::fonts::{canvas, RasterizedGlyph};
use galaxyui_core::scene::{CornerRadius, GlyphFade, Icon, Image, Layer, Scene};
use pathfinder_color::{ColorF, ColorU};
use pathfinder_geometry::{
rect::RectF,
vector::{vec2f, Vector2F},
};
use std::collections::HashMap;
use galaxyui_core::scene::GlyphKey;
use pathfinder_geometry::rect::RectI;
use std::{fs::File, mem, sync::Once};
use std::{io::Write, os::raw::c_void};
use crate::rendering::atlas::{AllocatedRegion, TextureId};
use crate::rendering::{get_best_dash_gap, GlyphCache, GlyphRasterBoundsFn, RasterizeGlyphFn};
const METAL_LIB_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/shaders.metallib"));
static WRITE_LIB_TO_FILE: Once = Once::new();
/// A structure to help manage a single rendering pass.
struct RenderPass<'a> {
drawable: &'a metal::MetalDrawableRef,
buffer: &'a metal::CommandBufferRef,
encoder: &'a metal::RenderCommandEncoderRef,
drawable: &'a ProtocolObject<dyn CAMetalDrawable>,
buffer: Retained<ProtocolObject<dyn MTLCommandBuffer>>,
encoder: Retained<ProtocolObject<dyn MTLRenderCommandEncoder>>,
encoding_finished: bool,
}
impl<'a> RenderPass<'a> {
fn new(
command_queue: &'a mut metal::CommandQueue,
drawable: &'a metal::MetalDrawableRef,
command_queue: &ProtocolObject<dyn MTLCommandQueue>,
drawable: &'a ProtocolObject<dyn CAMetalDrawable>,
) -> Self {
let buffer = command_queue.new_command_buffer();
let encoder = buffer.new_render_command_encoder(Self::create_descriptor(drawable));
let buffer = command_queue
.commandBuffer()
.expect("command queue should always vend a command buffer");
let encoder = buffer
.renderCommandEncoderWithDescriptor(&Self::create_descriptor(drawable))
.expect("command buffer should always vend a render command encoder");
Self {
drawable,
buffer,
@@ -67,37 +74,52 @@ impl<'a> RenderPass<'a> {
mut self,
drawable_size: pathfinder_geometry::vector::Vector2F,
should_capture: bool,
presents_with_transaction: bool,
) -> Option<CapturedFrame> {
self.encoder.end_encoding();
self.encoder.endEncoding();
self.encoding_finished = true;
// If we're able to do asynchronous presentation, do so - it allows us to avoid
// blocking on the GPU for the duration of the frame.
if !should_capture && !presents_with_transaction {
self.buffer
.presentDrawable(ProtocolObject::from_ref(self.drawable));
self.buffer.commit();
return None;
}
// Otherwise, commit the buffer and wait for it to complete before continuing.
self.buffer.commit();
self.buffer.waitUntilCompleted();
self.buffer.wait_until_completed();
let captured = if should_capture {
let capture = if should_capture {
let texture = self.drawable.texture();
capture_frame(texture, drawable_size)
capture_frame(&texture, drawable_size)
} else {
None
};
self.drawable.present();
captured
capture
}
/// Creates a descriptor for a pass that renders into the provided drawable.
fn create_descriptor(drawable: &metal::MetalDrawableRef) -> &metal::RenderPassDescriptorRef {
let descriptor = metal::RenderPassDescriptor::new();
fn create_descriptor(
drawable: &ProtocolObject<dyn CAMetalDrawable>,
) -> Retained<MTLRenderPassDescriptor> {
let descriptor = MTLRenderPassDescriptor::new();
let color_attachment = descriptor.color_attachments().object_at(0).expect(
"should always be able to get a color attachment for a CAMetalLayer's drawable",
);
color_attachment.set_texture(Some(drawable.texture()));
color_attachment.set_load_action(metal::MTLLoadAction::Clear);
color_attachment.set_store_action(metal::MTLStoreAction::Store);
color_attachment.set_clear_color(metal::MTLClearColor::new(0., 0., 0., 0.));
// SAFETY: index 0 is always a valid color attachment slot for a CAMetalLayer's drawable.
let color_attachment = unsafe { descriptor.colorAttachments().objectAtIndexedSubscript(0) };
color_attachment.setTexture(Some(&drawable.texture()));
color_attachment.setLoadAction(MTLLoadAction::Clear);
color_attachment.setStoreAction(MTLStoreAction::Store);
color_attachment.setClearColor(MTLClearColor {
red: 0.,
green: 0.,
blue: 0.,
alpha: 0.,
});
descriptor
}
@@ -108,33 +130,33 @@ impl Drop for RenderPass<'_> {
// Make sure that `end_encoding()` is called, even if a panic occurs
// during rendering.
if !self.encoding_finished {
self.encoder.end_encoding();
self.encoder.endEncoding();
}
}
}
/// A set of resources necessary for rendering that retain state across frames.
struct Resources {
draw_rects_pipeline_state: metal::RenderPipelineState,
draw_images_pipeline_state: metal::RenderPipelineState,
draw_glyphs_pipeline_state: metal::RenderPipelineState,
quad_vertices: metal::Buffer,
quad_indices: metal::Buffer,
glyph_cache: GlyphCache<metal::Texture>,
texture_cache: TextureCache<metal::Texture>,
draw_rects_pipeline_state: Retained<ProtocolObject<dyn MTLRenderPipelineState>>,
draw_images_pipeline_state: Retained<ProtocolObject<dyn MTLRenderPipelineState>>,
draw_glyphs_pipeline_state: Retained<ProtocolObject<dyn MTLRenderPipelineState>>,
quad_vertices: Retained<ProtocolObject<dyn MTLBuffer>>,
quad_indices: Retained<ProtocolObject<dyn MTLBuffer>>,
glyph_cache: GlyphCache<Retained<ProtocolObject<dyn MTLTexture>>>,
texture_cache: TextureCache<Retained<ProtocolObject<dyn MTLTexture>>>,
}
/// A structure that manages rendering scenes using a particular hardware
/// device.
pub struct Renderer {
resources: Resources,
command_queue: metal::CommandQueue,
command_queue: Retained<ProtocolObject<dyn MTLCommandQueue>>,
}
impl Renderer {
pub fn new(
device: &metal::Device,
color_pixel_format: metal::MTLPixelFormat,
device: &ProtocolObject<dyn MTLDevice>,
color_pixel_format: MTLPixelFormat,
glyph_config: rendering::GlyphConfig,
) -> Self {
let library = if cfg!(feature = "enable-metal-frame-capture") {
@@ -143,39 +165,61 @@ impl Renderer {
let mut file = File::create(&temp_lib_path).unwrap();
file.write_all(METAL_LIB_BYTES).unwrap();
});
device.new_library_with_file(temp_lib_path).unwrap()
let path = NSString::from_str(temp_lib_path.to_str().unwrap());
// `newLibraryWithURL:` is the non-deprecated replacement, but we
// load the shader library from a file path here.
#[allow(deprecated)]
let library = device.newLibraryWithFile_error(&path).unwrap();
library
} else {
device.new_library_with_data(METAL_LIB_BYTES).unwrap()
let data = DispatchData::from_static_bytes(METAL_LIB_BYTES);
device.newLibraryWithData_error(&data).unwrap()
};
let rect_vertex_shader = library.get_function("rect_vertex_shader", None).unwrap();
let rect_fragment_shader = library.get_function("rect_fragment_shader", None).unwrap();
let rect_vertex_shader = library
.newFunctionWithName(&NSString::from_str("rect_vertex_shader"))
.unwrap();
let rect_fragment_shader = library
.newFunctionWithName(&NSString::from_str("rect_fragment_shader"))
.unwrap();
let rect_pipeline = Self::create_pipeline(
"Rects",
color_pixel_format,
&rect_vertex_shader,
&rect_fragment_shader,
);
let draw_rects_pipeline_state = device.new_render_pipeline_state(&rect_pipeline).unwrap();
let draw_rects_pipeline_state = device
.newRenderPipelineStateWithDescriptor_error(&rect_pipeline)
.unwrap();
let image_fragment_shader = library.get_function("image_fragment_shader", None).unwrap();
let image_fragment_shader = library
.newFunctionWithName(&NSString::from_str("image_fragment_shader"))
.unwrap();
let image_pipeline = Self::create_pipeline(
"Images",
color_pixel_format,
&rect_vertex_shader,
&image_fragment_shader,
);
let draw_images_pipeline_state = device.new_render_pipeline_state(&image_pipeline).unwrap();
let draw_images_pipeline_state = device
.newRenderPipelineStateWithDescriptor_error(&image_pipeline)
.unwrap();
let glyph_vertex_shader = library.get_function("glyph_vertex_shader", None).unwrap();
let glyph_fragment_shader = library.get_function("glyph_fragment_shader", None).unwrap();
let glyph_vertex_shader = library
.newFunctionWithName(&NSString::from_str("glyph_vertex_shader"))
.unwrap();
let glyph_fragment_shader = library
.newFunctionWithName(&NSString::from_str("glyph_fragment_shader"))
.unwrap();
let glyph_pipeline = Self::create_pipeline(
"Glyphs",
color_pixel_format,
&glyph_vertex_shader,
&glyph_fragment_shader,
);
let draw_glyphs_pipeline_state = device.new_render_pipeline_state(&glyph_pipeline).unwrap();
let draw_glyphs_pipeline_state = device
.newRenderPipelineStateWithDescriptor_error(&glyph_pipeline)
.unwrap();
let quad_vertices = new_metal_buffer(
device,
@@ -206,30 +250,33 @@ impl Renderer {
glyph_cache,
texture_cache: TextureCache::new(),
},
command_queue: device.new_command_queue(),
command_queue: device
.newCommandQueue()
.expect("device should always vend a command queue"),
}
}
fn create_pipeline(
label: &str,
color_pixel_format: metal::MTLPixelFormat,
vertex_shader: &Function,
fragment_shader: &Function,
) -> RenderPipelineDescriptor {
let pipeline = metal::RenderPipelineDescriptor::new();
pipeline.set_label(label);
pipeline.set_vertex_function(Some(vertex_shader));
pipeline.set_fragment_function(Some(fragment_shader));
color_pixel_format: MTLPixelFormat,
vertex_shader: &ProtocolObject<dyn MTLFunction>,
fragment_shader: &ProtocolObject<dyn MTLFunction>,
) -> Retained<MTLRenderPipelineDescriptor> {
let pipeline = MTLRenderPipelineDescriptor::new();
pipeline.setLabel(Some(&NSString::from_str(label)));
pipeline.setVertexFunction(Some(vertex_shader));
pipeline.setFragmentFunction(Some(fragment_shader));
let attachment = pipeline.color_attachments().object_at(0).unwrap();
attachment.set_pixel_format(color_pixel_format);
attachment.set_blending_enabled(true);
attachment.set_rgb_blend_operation(MTLBlendOperation::Add);
attachment.set_alpha_blend_operation(MTLBlendOperation::Add);
attachment.set_source_rgb_blend_factor(MTLBlendFactor::SourceAlpha);
attachment.set_source_alpha_blend_factor(MTLBlendFactor::One);
attachment.set_destination_rgb_blend_factor(MTLBlendFactor::OneMinusSourceAlpha);
attachment.set_destination_alpha_blend_factor(MTLBlendFactor::OneMinusSourceAlpha);
// SAFETY: index 0 is always a valid color attachment slot for a render pipeline.
let attachment = unsafe { pipeline.colorAttachments().objectAtIndexedSubscript(0) };
attachment.setPixelFormat(color_pixel_format);
attachment.setBlendingEnabled(true);
attachment.setRgbBlendOperation(MTLBlendOperation::Add);
attachment.setAlphaBlendOperation(MTLBlendOperation::Add);
attachment.setSourceRGBBlendFactor(MTLBlendFactor::SourceAlpha);
attachment.setSourceAlphaBlendFactor(MTLBlendFactor::One);
attachment.setDestinationRGBBlendFactor(MTLBlendFactor::OneMinusSourceAlpha);
attachment.setDestinationAlphaBlendFactor(MTLBlendFactor::OneMinusSourceAlpha);
pipeline
}
@@ -239,16 +286,21 @@ impl Renderer {
scene: &Scene,
ctx: &MetalDrawContext,
should_capture: bool,
presents_with_transaction: bool,
) -> Option<CapturedFrame> {
self.resources
.glyph_cache
.update_config(&scene.rendering_config().glyphs);
let render_pass = RenderPass::new(&mut self.command_queue, ctx.drawable);
let render_pass = RenderPass::new(&self.command_queue, ctx.drawable);
Frame::new(scene, render_pass.encoder, &mut self.resources, ctx).draw();
Frame::new(scene, &render_pass.encoder, &mut self.resources, ctx).draw();
render_pass.finish_with_capture(ctx.drawable_size, should_capture)
render_pass.finish_with_capture(
ctx.drawable_size,
should_capture,
presents_with_transaction,
)
}
}
@@ -257,7 +309,7 @@ impl Renderer {
/// image.
pub struct Frame<'a> {
scene: &'a Scene,
command_encoder: &'a metal::RenderCommandEncoderRef,
command_encoder: &'a ProtocolObject<dyn MTLRenderCommandEncoder>,
resources: &'a mut Resources,
ctx: &'a MetalDrawContext<'a>,
}
@@ -265,7 +317,7 @@ pub struct Frame<'a> {
impl<'a> Frame<'a> {
fn new(
scene: &'a Scene,
command_encoder: &'a metal::RenderCommandEncoderRef,
command_encoder: &'a ProtocolObject<dyn MTLRenderCommandEncoder>,
resources: &'a mut Resources,
ctx: &'a MetalDrawContext<'a>,
) -> Self {
@@ -278,7 +330,7 @@ impl<'a> Frame<'a> {
}
fn draw(&mut self) {
self.command_encoder.set_viewport(metal::MTLViewport {
self.command_encoder.setViewport(MTLViewport {
originX: 0.0,
originY: 0.0,
width: self.ctx.drawable_size.x() as f64,
@@ -296,26 +348,24 @@ impl<'a> Frame<'a> {
let device_bounds = RectF::new(Vector2F::zero(), self.ctx.drawable_size);
let bounds = (bounds * self.scene.scale_factor()).intersection(device_bounds);
if let Some(intersection) = bounds {
self.command_encoder
.set_scissor_rect(metal::MTLScissorRect {
x: intersection.origin_x().round() as u64,
y: intersection.origin_y().round() as u64,
width: intersection.width().round() as u64,
height: intersection.height().round() as u64,
});
self.command_encoder.setScissorRect(MTLScissorRect {
x: intersection.origin_x().round() as usize,
y: intersection.origin_y().round() as usize,
width: intersection.width().round() as usize,
height: intersection.height().round() as usize,
});
} else {
// The layer's clip bounds don't intersect the window bounds
// at all; we can skip drawing anything in this layer.
continue;
}
} else {
self.command_encoder
.set_scissor_rect(metal::MTLScissorRect {
x: 0_u64,
y: 0_u64,
width: self.ctx.drawable_size.x() as u64,
height: self.ctx.drawable_size.y() as u64,
});
self.command_encoder.setScissorRect(MTLScissorRect {
x: 0_usize,
y: 0_usize,
width: self.ctx.drawable_size.x() as usize,
height: self.ctx.drawable_size.y() as usize,
});
}
self.draw_rects(layer);
self.draw_images(layer);
@@ -389,64 +439,83 @@ impl<'a> Frame<'a> {
MTLResourceOptions::StorageModeManaged,
);
self.command_encoder
.set_vertex_buffer(1, Some(per_rect_uniforms_buffer.as_ref()), 0);
let uniforms = shader::Uniforms::new(self.ctx.drawable_size.into());
self.command_encoder.set_vertex_bytes(
2,
mem::size_of::<shader::Uniforms>() as u64,
[uniforms].as_ptr() as *const _,
);
self.command_encoder.set_fragment_bytes(
0,
mem::size_of::<shader::Uniforms>() as u64,
[uniforms].as_ptr() as *const _,
);
let uniforms_ptr = NonNull::from(&uniforms).cast::<c_void>();
let uniforms_len = mem::size_of::<shader::Uniforms>();
// SAFETY: the per-rect uniform buffer and `uniforms` value outlive this encoded draw
// call, and the bound buffer/byte sizes and indices match the shader bindings.
unsafe {
self.command_encoder.setVertexBuffer_offset_atIndex(
Some(&per_rect_uniforms_buffer),
0,
1,
);
self.command_encoder
.setVertexBytes_length_atIndex(uniforms_ptr, uniforms_len, 2);
self.command_encoder
.setFragmentBytes_length_atIndex(uniforms_ptr, uniforms_len, 0);
}
let (_, texture) = self
.resources
.texture_cache
.get_or_insert_by_asset(asset, |asset| {
let width = asset.size().x() as u64;
let height = asset.size().y() as u64;
let width = asset.size().x() as usize;
let height = asset.size().y() as usize;
let texture_descriptor = metal::TextureDescriptor::new();
texture_descriptor.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm);
texture_descriptor.set_width(width);
texture_descriptor.set_height(height);
let texture = self.ctx.device.new_texture(&texture_descriptor);
let region = metal::MTLRegion {
origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
size: metal::MTLSize {
let texture_descriptor = MTLTextureDescriptor::new();
texture_descriptor.setPixelFormat(MTLPixelFormat::RGBA8Unorm);
// SAFETY: width/height come from a decoded asset and are within Metal limits.
unsafe {
texture_descriptor.setWidth(width);
texture_descriptor.setHeight(height);
}
let texture = self
.ctx
.device
.newTextureWithDescriptor(&texture_descriptor)
.expect("device should create an RGBA8 texture");
let region = MTLRegion {
origin: MTLOrigin { x: 0, y: 0, z: 0 },
size: MTLSize {
width,
height,
depth: 1,
},
};
let bytes_per_row: u64 = 4 * width;
texture.replace_region(
region,
0,
asset.rgba_bytes().as_ptr() as *const c_void,
bytes_per_row,
);
let bytes_per_row: usize = 4 * width;
// SAFETY: rgba_bytes holds width*height*4 bytes laid out to match the region
// and row stride.
unsafe {
texture.replaceRegion_mipmapLevel_withBytes_bytesPerRow(
region,
0,
NonNull::new(asset.rgba_bytes().as_ptr() as *mut c_void)
.expect("asset rgba bytes pointer is non-null"),
bytes_per_row,
);
}
texture
});
self.command_encoder
.set_fragment_texture(0, Some(texture.as_ref()));
// SAFETY: the bound texture and quad index buffer outlive this encoded draw call.
unsafe {
self.command_encoder
.setFragmentTexture_atIndex(Some(&**texture), 0);
self.command_encoder.draw_indexed_primitives_instanced(
MTLPrimitiveType::Triangle,
6,
MTLIndexType::UInt16,
self.resources.quad_indices.as_ref(),
0,
per_rect_uniforms.len() as u64,
);
self.command_encoder
.drawIndexedPrimitives_indexCount_indexType_indexBuffer_indexBufferOffset_instanceCount(
MTLPrimitiveType::Triangle,
6,
MTLIndexType::UInt16,
&self.resources.quad_indices,
0,
per_rect_uniforms.len(),
);
}
}
fn draw_images(&mut self, layer: &Layer) {
@@ -456,9 +525,15 @@ impl<'a> Frame<'a> {
}
self.command_encoder
.set_render_pipeline_state(&self.resources.draw_images_pipeline_state);
self.command_encoder
.set_vertex_buffer(0, Some(self.resources.quad_vertices.as_ref()), 0);
.setRenderPipelineState(&self.resources.draw_images_pipeline_state);
// SAFETY: index 0 binds the shared quad vertex buffer, which outlives the draw calls.
unsafe {
self.command_encoder.setVertexBuffer_offset_atIndex(
Some(&self.resources.quad_vertices),
0,
0,
);
}
for image in &layer.images {
self.render_image_or_icon(Some(image), None);
@@ -477,9 +552,15 @@ impl<'a> Frame<'a> {
}
self.command_encoder
.set_render_pipeline_state(&self.resources.draw_rects_pipeline_state);
self.command_encoder
.set_vertex_buffer(0, Some(self.resources.quad_vertices.as_ref()), 0);
.setRenderPipelineState(&self.resources.draw_rects_pipeline_state);
// SAFETY: index 0 binds the shared quad vertex buffer, which outlives the draw call.
unsafe {
self.command_encoder.setVertexBuffer_offset_atIndex(
Some(&self.resources.quad_vertices),
0,
0,
);
}
let mut per_rect_uniforms = Vec::new();
for rect in &layer.rects {
@@ -583,29 +664,33 @@ impl<'a> Frame<'a> {
MTLResourceOptions::StorageModeManaged,
);
self.command_encoder
.set_vertex_buffer(1, Some(per_rect_uniforms_buffer.as_ref()), 0);
let uniforms = shader::Uniforms::new(self.ctx.drawable_size.into());
self.command_encoder.set_vertex_bytes(
2,
mem::size_of::<shader::Uniforms>() as u64,
[uniforms].as_ptr() as *const _,
);
self.command_encoder.set_fragment_bytes(
0,
mem::size_of::<shader::Uniforms>() as u64,
[uniforms].as_ptr() as *const _,
);
let uniforms_ptr = NonNull::from(&uniforms).cast::<c_void>();
let uniforms_len = mem::size_of::<shader::Uniforms>();
self.command_encoder.draw_indexed_primitives_instanced(
MTLPrimitiveType::Triangle,
6,
MTLIndexType::UInt16,
self.resources.quad_indices.as_ref(),
0,
per_rect_uniforms.len() as u64,
);
// SAFETY: the per-rect uniform buffer and `uniforms` value outlive this encoded draw
// call, and the bound buffer/byte sizes and indices match the shader bindings.
unsafe {
self.command_encoder.setVertexBuffer_offset_atIndex(
Some(&per_rect_uniforms_buffer),
0,
1,
);
self.command_encoder
.setVertexBytes_length_atIndex(uniforms_ptr, uniforms_len, 2);
self.command_encoder
.setFragmentBytes_length_atIndex(uniforms_ptr, uniforms_len, 0);
self.command_encoder
.drawIndexedPrimitives_indexCount_indexType_indexBuffer_indexBufferOffset_instanceCount(
MTLPrimitiveType::Triangle,
6,
MTLIndexType::UInt16,
&self.resources.quad_indices,
0,
per_rect_uniforms.len(),
);
}
}
fn draw_glyphs(&mut self, layer: &Layer) {
@@ -615,9 +700,15 @@ impl<'a> Frame<'a> {
}
self.command_encoder
.set_render_pipeline_state(&self.resources.draw_glyphs_pipeline_state);
self.command_encoder
.set_vertex_buffer(0, Some(self.resources.quad_vertices.as_ref()), 0);
.setRenderPipelineState(&self.resources.draw_glyphs_pipeline_state);
// SAFETY: index 0 binds the shared quad vertex buffer, which outlives the draw calls.
unsafe {
self.command_encoder.setVertexBuffer_offset_atIndex(
Some(&self.resources.quad_vertices),
0,
0,
);
}
let scale_factor = self.scene.scale_factor();
@@ -703,15 +794,9 @@ impl<'a> Frame<'a> {
MTLResourceOptions::StorageModeManaged,
);
self.command_encoder
.set_vertex_buffer(1, Some(per_glyph_uniforms_buffer.as_ref()), 0);
let uniforms = shader::Uniforms::new(self.ctx.drawable_size.into());
self.command_encoder.set_vertex_bytes(
2,
mem::size_of::<shader::Uniforms>() as u64,
[uniforms].as_ptr() as *const _,
);
let uniforms_ptr = NonNull::from(&uniforms).cast::<c_void>();
let uniforms_len = mem::size_of::<shader::Uniforms>();
let texture = self
.resources
@@ -719,16 +804,29 @@ impl<'a> Frame<'a> {
.texture(&texture_id)
.expect("texture ID should be in atlas");
self.command_encoder.set_fragment_texture(0, Some(texture));
self.command_encoder.draw_indexed_primitives_instanced(
MTLPrimitiveType::Triangle,
6,
MTLIndexType::UInt16,
self.resources.quad_indices.as_ref(),
0,
per_glyph_uniforms.len() as u64,
);
// SAFETY: the per-glyph uniform buffer, `uniforms` value, bound texture, and quad
// index buffer outlive this encoded draw call, and the bound sizes/indices match the
// shader bindings.
unsafe {
self.command_encoder.setVertexBuffer_offset_atIndex(
Some(&per_glyph_uniforms_buffer),
0,
1,
);
self.command_encoder
.setVertexBytes_length_atIndex(uniforms_ptr, uniforms_len, 2);
self.command_encoder
.setFragmentTexture_atIndex(Some(&**texture), 0);
self.command_encoder
.drawIndexedPrimitives_indexCount_indexType_indexBuffer_indexBufferOffset_instanceCount(
MTLPrimitiveType::Triangle,
6,
MTLIndexType::UInt16,
&self.resources.quad_indices,
0,
per_glyph_uniforms.len(),
);
}
}
}
}
@@ -740,15 +838,20 @@ impl Drop for Frame<'_> {
}
fn new_metal_buffer<T>(
device: &metal::Device,
device: &ProtocolObject<dyn MTLDevice>,
data: &[T],
options: MTLResourceOptions,
) -> metal::Buffer {
device.new_buffer_with_data(
data.as_ptr() as *const c_void,
std::mem::size_of_val(data) as u64,
options,
)
) -> Retained<ProtocolObject<dyn MTLBuffer>> {
// SAFETY: `data` points to `size_of_val(data)` initialized bytes; Metal copies them into the
// new buffer, so the pointer only needs to be valid for the duration of this call.
unsafe {
device.newBufferWithBytes_length_options(
NonNull::new(data.as_ptr() as *mut c_void).expect("buffer data pointer is non-null"),
std::mem::size_of_val(data),
options,
)
}
.expect("device should create a buffer")
}
mod shader {
@@ -917,8 +1020,8 @@ mod shader {
}
pub(super) struct MetalDrawContext<'a> {
pub(super) device: &'a metal::Device,
pub(super) drawable: &'a metal::MetalDrawableRef,
pub(super) device: &'a ProtocolObject<dyn MTLDevice>,
pub(super) drawable: &'a ProtocolObject<dyn CAMetalDrawable>,
pub(super) drawable_size: Vector2F,
rasterize_glyph_fn: &'a RasterizeGlyphFn<'a>,
glyph_raster_bounds_fn: &'a GlyphRasterBoundsFn<'a>,
@@ -958,17 +1061,17 @@ impl super::super::Renderer for Renderer {
log::error!("Metal renderer called with non-metal device");
return;
};
let metal_device: &ProtocolObject<dyn MTLDevice> = metal_device;
let drawable = unsafe {
let native_view = window.native_view();
let layer: id = msg_send![native_view, layer];
let drawable: &metal::MetalDrawableRef = msg_send![layer, nextDrawable];
drawable
};
let metal_layer = window.metal_layer();
let presents_with_transaction = metal_layer.presentsWithTransaction();
let drawable = metal_layer
.nextDrawable()
.expect("CAMetalLayer with allowsNextDrawableTimeout disabled always vends a drawable");
let ctx = &MetalDrawContext {
device: metal_device,
drawable,
drawable: &drawable,
drawable_size: window.physical_size(),
rasterize_glyph_fn: &|glyph_key, scale, subpixel_alignment, glyph_config, format| {
font_cache.rasterized_glyph(
@@ -986,7 +1089,7 @@ impl super::super::Renderer for Renderer {
let capture_callback = window.capture_callback.borrow_mut().take();
let should_capture = capture_callback.is_some();
let captured = Self::render(self, scene, ctx, should_capture);
let captured = Self::render(self, scene, ctx, should_capture, presents_with_transaction);
if let (Some(frame), Some(callback)) = (captured, capture_callback) {
callback(frame);
}
@@ -1002,35 +1105,48 @@ impl super::super::Renderer for Renderer {
fn insert_glyph_into_texture(
region: AllocatedRegion,
glyph: &RasterizedGlyph,
texture: &mut metal::Texture,
texture: &mut Retained<ProtocolObject<dyn MTLTexture>>,
) {
let region = metal::MTLRegion {
origin: metal::MTLOrigin {
x: region.pixel_region.origin_x() as u64,
y: region.pixel_region.origin_y() as u64,
let region = MTLRegion {
origin: MTLOrigin {
x: region.pixel_region.origin_x() as usize,
y: region.pixel_region.origin_y() as usize,
z: 0,
},
size: metal::MTLSize {
width: region.pixel_region.width() as u64,
height: region.pixel_region.height() as u64,
size: MTLSize {
width: region.pixel_region.width() as usize,
height: region.pixel_region.height() as usize,
depth: 1,
},
};
let bytes_per_row: u64 = 4 * (glyph.canvas.size.x() as u64);
texture.replace_region(
region,
0,
glyph.canvas.pixels.as_slice().as_ptr() as *const c_void,
bytes_per_row,
);
let bytes_per_row: usize = 4 * (glyph.canvas.size.x() as usize);
// SAFETY: the glyph canvas holds at least `bytes_per_row * region.height` bytes laid out to
// match the destination region.
unsafe {
texture.replaceRegion_mipmapLevel_withBytes_bytesPerRow(
region,
0,
NonNull::new(glyph.canvas.pixels.as_slice().as_ptr() as *mut c_void)
.expect("glyph canvas pixel pointer is non-null"),
bytes_per_row,
);
}
}
/// Creates a new texture atlas for use in the cache.
fn create_new_texture_atlas(atlas_size: usize, device: &metal::Device) -> metal::Texture {
let texture_descriptor = metal::TextureDescriptor::new();
texture_descriptor.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm);
texture_descriptor.set_width(atlas_size as u64);
texture_descriptor.set_height(atlas_size as u64);
device.new_texture(&texture_descriptor)
fn create_new_texture_atlas(
atlas_size: usize,
device: &ProtocolObject<dyn MTLDevice>,
) -> Retained<ProtocolObject<dyn MTLTexture>> {
let texture_descriptor = MTLTextureDescriptor::new();
texture_descriptor.setPixelFormat(MTLPixelFormat::RGBA8Unorm);
// SAFETY: `atlas_size` is a fixed, valid texture dimension within Metal limits.
unsafe {
texture_descriptor.setWidth(atlas_size);
texture_descriptor.setHeight(atlas_size);
}
device
.newTextureWithDescriptor(&texture_descriptor)
.expect("device should create an atlas texture")
}
@@ -1,8 +1,11 @@
use crate::platform::mac::rendering::metal::renderer::Renderer;
use std::collections::HashMap;
use objc2::runtime::ProtocolObject;
use objc2_metal::{MTLDevice, MTLPixelFormat};
use galaxyui_core::rendering;
use crate::platform::mac::rendering::metal::renderer::Renderer;
pub struct RendererManager {
/// Maps a device's registry ID to its renderer (collection of state related
/// to rendering on a particular device).
@@ -16,13 +19,13 @@ impl RendererManager {
}
}
pub fn renderer_for_device(&mut self, device: &metal::Device) -> &mut Renderer {
pub fn renderer_for_device(&mut self, device: &ProtocolObject<dyn MTLDevice>) -> &mut Renderer {
use std::collections::hash_map::Entry::*;
match self.renderers.entry(device.registry_id()) {
match self.renderers.entry(device.registryID()) {
Occupied(entry) => entry.into_mut(),
Vacant(entry) => entry.insert(Renderer::new(
device,
metal::MTLPixelFormat::BGRA8Unorm,
MTLPixelFormat::BGRA8Unorm,
rendering::GlyphConfig::default(),
)),
}
@@ -5,10 +5,11 @@ mod renderer_manager;
#[cfg(wgpu)]
mod wgpu;
pub use self::metal::is_integrated_gpu;
pub use renderer::{Device, Renderer};
pub use renderer::{Device, MetalDevice, Renderer};
pub use renderer_manager::RendererManager;
pub use self::metal::is_integrated_gpu;
/// Returns `true` if a low power GPU is available for rendering. Typically, this is true for
/// machines with two GPUs -- a dedicated discrete high-performance GPU and a lower power
/// integrated GPU.
@@ -17,12 +18,10 @@ pub fn is_low_power_gpu_available() -> bool {
if #[cfg(wgpu)] {
crate::r#async::block_on(crate::rendering::wgpu::is_low_power_gpu_available())
} else {
let devices = ::metal::Device::all();
let gpu_count = devices.len();
let devices = objc2_metal::MTLCopyAllDevices();
let gpu_count = devices.count();
gpu_count > 1
&& devices
.iter()
.any(metal::is_integrated_gpu)
&& (0..gpu_count).any(|i| metal::is_integrated_gpu(&devices.objectAtIndex(i)))
}
}
}
@@ -1,11 +1,22 @@
use crate::platform::mac::rendering::is_integrated_gpu;
use crate::platform::mac::window::WindowState;
use cocoa::base::id;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_app_kit::{NSView, NSWindow};
use objc2_metal::MTLDevice;
use galaxyui_core::rendering::{
GPUBackend, GPUDeviceInfo, GPUDeviceType, GPUPowerPreference, OnGPUDeviceSelected,
};
use galaxyui_core::{fonts, Scene};
use crate::platform::mac::rendering::is_integrated_gpu;
use crate::platform::mac::window::WindowState;
/// An owned handle to a Metal device, used to render with the Metal backend.
///
/// This is the objc2-metal equivalent of the legacy `metal::Device`, and is the
/// type the window layer creates (via `MTLCreateSystemDefaultDevice` /
/// `MTLCopyAllDevices`) and hands to [`Device::new`].
pub type MetalDevice = Retained<ProtocolObject<dyn MTLDevice>>;
/// Trait to render the [`Scene`] onto the screen using the provided [`WindowState`].
pub trait Renderer {
fn render(&mut self, scene: &Scene, window: &WindowState, font_cache: &fonts::Cache);
@@ -17,15 +28,15 @@ pub trait Renderer {
#[allow(clippy::upper_case_acronyms)]
pub enum Device {
#[allow(dead_code)]
Metal(metal::Device),
Metal(MetalDevice),
#[cfg(wgpu)]
WGPU(Box<crate::rendering::wgpu::Resources>),
}
impl Device {
pub fn new(
_metal_device: metal::Device,
_native_view: id,
_native_window: id,
_metal_device: MetalDevice,
_native_view: &NSView,
_native_window: &NSWindow,
_gpu_power_preference: GPUPowerPreference,
on_gpu_device_info: Box<OnGPUDeviceSelected>,
) -> Self {
@@ -45,7 +56,7 @@ impl Device {
}
#[cfg_attr(wgpu, allow(dead_code))]
fn get_gpu_device_info(device: &metal::Device) -> GPUDeviceInfo {
fn get_gpu_device_info(device: &ProtocolObject<dyn MTLDevice>) -> GPUDeviceInfo {
let device_type = if is_integrated_gpu(device) {
GPUDeviceType::IntegratedGpu
} else {
@@ -53,7 +64,7 @@ fn get_gpu_device_info(device: &metal::Device) -> GPUDeviceInfo {
};
GPUDeviceInfo {
device_type,
device_name: device.name().into(),
device_name: device.name().to_string(),
// Mimic wgpu by setting the driver name and info to empty strings when
// rendering on Metal. See https://github.com/gfx-rs/wgpu/blob/8129897ccbff869ef48a3b53a4cdd8a8a21840f9/wgpu-hal/src/metal/mod.rs#L135.
driver_name: String::new(),
@@ -1,9 +1,7 @@
use pathfinder_geometry::vector::Vector2F;
use super::{
metal,
renderer::{Device, Renderer},
};
use super::metal;
use super::renderer::{Device, Renderer};
pub struct RendererManager {
metal_renderer_manager: metal::RendererManager,
@@ -1,35 +1,32 @@
mod renderer;
mod renderer_manager;
use crate::rendering::wgpu::Resources;
use crate::{platform::mac::rendering::Device, rendering::GPUPowerPreference};
use anyhow::{anyhow, Result};
pub use renderer_manager::RendererManager;
use crate::rendering::OnGPUDeviceSelected;
use cocoa::{appkit::NSView, base::id};
use pathfinder_geometry::vector::vec2f;
use std::ptr::NonNull;
use anyhow::Result;
use objc2_app_kit::NSView;
use pathfinder_geometry::vector::vec2f;
pub use renderer_manager::RendererManager;
use wgpu::rwh::{
AppKitDisplayHandle, AppKitWindowHandle, DisplayHandle, HandleError, HasDisplayHandle,
HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle,
};
use crate::platform::mac::rendering::Device;
use crate::rendering::wgpu::Resources;
use crate::rendering::{GPUPowerPreference, OnGPUDeviceSelected};
impl Device {
/// Constructs a new [`Device`] to render using WGPU.
pub fn new_wgpu(
native_view: id,
native_view: &NSView,
gpu_power_preference: GPUPowerPreference,
on_gpu_device_info: Box<OnGPUDeviceSelected>,
) -> Result<Device> {
let view_frame = unsafe { NSView::frame(native_view) };
let view_frame = native_view.frame();
let surface_size = vec2f(view_frame.size.width as f32, view_frame.size.height as f32);
let appkit_window_handle = AppKitWindowHandle::new(
NonNull::new(native_view)
.ok_or_else(|| anyhow!("Received null NSView pointer"))?
.cast(),
);
let appkit_window_handle = AppKitWindowHandle::new(NonNull::from(native_view).cast());
let window_handle =
unsafe { WindowHandle::borrow_raw(RawWindowHandle::AppKit(appkit_window_handle)) };
let display_handle = unsafe {
@@ -61,7 +58,7 @@ impl Device {
/// guaranteed that the underlying window won't become invalid while the `WindowHandle` is alive.
/// In the case of Warp this _should_ be safe because we ultimately deallocate the native window
/// when [`crate::platform::mac::Window`] is deallocated (once a `Window` is deallocated, there
/// are no pointers to the native window anymore, which cause it to to be deallocated via the
/// are no pointers to the native window anymore, which cause it to be deallocated via the
/// `warp_dealloc_window` callback).
/// See <https://github.com/rust-windowing/raw-window-handle/pull/73> for more information on the
/// safety requirements of implementing the [`HasRawWindowHandle`] trait.
@@ -1,10 +1,12 @@
use crate::rendering::wgpu::{Renderer, Resources};
use crate::rendering::GlyphConfig;
use pathfinder_geometry::vector::Vector2F;
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use pathfinder_geometry::vector::Vector2F;
use wgpu::Device;
use crate::rendering::wgpu::{Renderer, Resources};
use crate::rendering::GlyphConfig;
pub struct RendererManager {
renderers: HashMap<DeviceID, Renderer>,
}
+33 -38
View File
@@ -1,40 +1,3 @@
use block::{Block, ConcreteBlock};
use core_foundation::array::CFArray;
use core_foundation::attributed_string::CFMutableAttributedStringRef;
use core_foundation::base::CFType;
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::mach_port::CFIndex;
use core_foundation::number::CFNumber;
use core_foundation::{
attributed_string::CFMutableAttributedString,
base::CFTypeID,
base::{CFRange, TCFType},
declare_TCFType, impl_TCFType,
string::CFString,
};
use core_graphics::base::CGFloat;
use core_graphics::color::CGColor;
use core_graphics::display::{CGPoint, CGRect, CGSize};
use core_graphics::path::CGPath;
use core_text::framesetter::CTFramesetter;
use core_text::line::CTLineRef;
use core_text::run::{CTRun, CTRunRef};
use core_text::string_attributes::kCTKernAttributeName;
use core_text::{
font::CTFont,
line::CTLine,
string_attributes::{kCTFontAttributeName, kCTParagraphStyleAttributeName},
};
use galaxyui_core::fonts::GlyphId;
use galaxyui_core::platform::LineStyle;
use galaxyui_core::text_layout::{
CaretPosition, ClipConfig, Glyph, Line, Run, StyleAndFont, TextAlignment, TextBorder,
TextFrame, TextStyle,
};
use itertools::Itertools;
use ordered_float::OrderedFloat;
use pathfinder_geometry::vector::vec2f;
use std::borrow::Cow;
use std::cell::RefCell;
use std::ffi::c_void;
@@ -42,8 +5,40 @@ use std::marker::PhantomData;
use std::ops::Range;
use std::rc::Rc;
use std::slice;
use block::{Block, ConcreteBlock};
use core_foundation::array::CFArray;
use core_foundation::attributed_string::{CFMutableAttributedString, CFMutableAttributedStringRef};
use core_foundation::base::{CFRange, CFType, CFTypeID, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::mach_port::CFIndex;
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use core_foundation::{declare_TCFType, impl_TCFType};
use core_graphics::base::CGFloat;
use core_graphics::color::CGColor;
use core_graphics::display::{CGPoint, CGRect, CGSize};
use core_graphics::path::CGPath;
use core_text::font::CTFont;
use core_text::framesetter::CTFramesetter;
use core_text::line::{CTLine, CTLineRef};
use core_text::run::{CTRun, CTRunRef};
use core_text::string_attributes::{
kCTFontAttributeName, kCTKernAttributeName, kCTParagraphStyleAttributeName,
};
use itertools::Itertools;
use ordered_float::OrderedFloat;
use pathfinder_geometry::vector::vec2f;
use vec1::Vec1;
use galaxyui_core::fonts::GlyphId;
use galaxyui_core::platform::{CapturedFrame, LineStyle};
use galaxyui_core::text_layout::{
CaretPosition, ClipConfig, Glyph, Line, Run, StyleAndFont, TextAlignment, TextBorder,
TextFrame, TextStyle,
};
use super::fonts::FontDB;
use super::utils::{cg_color_to_color_u, color_u_to_cg_color};
@@ -1023,5 +1018,5 @@ fn advances(run: &CTRun) -> Cow<'_, [CGSize]> {
}
#[cfg(test)]
#[path = "text_layout_test.rs"]
#[path = "text_layout_tests.rs"]
mod tests;
@@ -1,13 +1,13 @@
use super::*;
use crate::fonts::Properties;
use crate::fonts::{collect_glyph_indices, collect_line_caret_position_starts, init_fonts};
use crate::platform::FontDB as _;
use crate::text_layout::DEFAULT_TOP_BOTTOM_RATIO;
use anyhow::Result;
use rand::random;
use super::*;
use crate::fonts::{
collect_glyph_indices, collect_line_caret_position_starts, init_fonts, Properties,
};
use crate::platform::FontDB as _;
use crate::text_layout::DEFAULT_TOP_BOTTOM_RATIO;
pub(crate) fn collect_line_caret_position_pairs(line: &Line) -> Vec<(usize, usize)> {
line.caret_positions
.iter()
@@ -52,7 +52,7 @@ fn test_char_indices_ligatures() -> Result<()> {
// characters get combined to become a single glyph. At a high level, what this is testing
// is that after laying out the string, we see some characters get combined into a single
// glyph. For example, the text "Zapfino" gets combined into a single glyph, which is why
// there is a jump from 23 to 30 in in the list of glyph indices below.
// there is a jump from 23 to 30 in the list of glyph indices below.
// See https://docs.google.com/drawings/d/18qOKhzA5rWaMuxKVeWFDXh7ebrDjxongarAckkm0qnE/edit
// for a full diagram of what's happening here.
assert_eq!(
@@ -0,0 +1,652 @@
use anyhow::Result;
use rand::random;
use super::*;
use crate::fonts::{
collect_glyph_indices, collect_line_caret_position_starts, init_fonts, Properties,
};
use crate::platform::FontDB as _;
use crate::text_layout::DEFAULT_TOP_BOTTOM_RATIO;
pub(crate) fn collect_line_caret_position_pairs(line: &Line) -> Vec<(usize, usize)> {
line.caret_positions
.iter()
.map(|pos| (pos.start_offset, pos.last_offset))
.collect_vec()
}
#[test]
fn test_char_indices_ligatures() -> Result<()> {
let mut font_db = FontDB::new();
let zapfino = font_db.load_from_system("Zapfino")?;
let menlo = font_db.load_from_system("Menlo")?;
let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
let line = layout_line(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[
(
0..9,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
(
9..22,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
22..text.encode_utf16().count(),
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
],
&font_db,
ClipConfig::default(),
);
// It's easiest to understand what's happening here by visualizing the text and seeing which
// characters get combined to become a single glyph. At a high level, what this is testing
// is that after laying out the string, we see some characters get combined into a single
// glyph. For example, the text "Zapfino" gets combined into a single glyph, which is why
// there is a jump from 23 to 30 in the list of glyph indices below.
// See https://docs.google.com/drawings/d/18qOKhzA5rWaMuxKVeWFDXh7ebrDjxongarAckkm0qnE/edit
// for a full diagram of what's happening here.
assert_eq!(
line.runs
.iter()
.flat_map(|r| r.glyphs.iter())
.map(|g| g.index)
.collect::<Vec<_>>(),
vec![0, 2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 30, 31]
);
Ok(())
}
#[test]
fn test_caret_positions_ligatures() -> Result<()> {
// There's some overlap between caret positions and the character indices we
// store in glyphs. However, a single glyph may have multiple caret positions
// because characters/graphemes may get combined into a single glyph.
let mut font_db = FontDB::new();
let zapfino = font_db.load_from_system("Zapfino")?;
let menlo = font_db.load_from_system("Menlo")?;
// This string has 32 characters, but 35 UTF-16 code points and 41 UTF-8 code points.
// Each '𐍈' character encodes as 2 UTF-16 code points or 4 UTF-8 code points.
let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
let line = layout_line(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[
(
0..9,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
(
9..22,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
22..35,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
],
&font_db,
ClipConfig::default(),
);
// There are only 23 glyphs because 'Zapfino', 'Th', and 'is' each have ligatures.
assert_eq!(
line.runs.iter().map(|run| run.glyphs.len()).sum::<usize>(),
23
);
// There should be a caret position for each character.
assert_eq!(
line.caret_positions
.iter()
.map(|pos| pos.start_offset)
.collect::<Vec<_>>(),
(0..32).collect::<Vec<usize>>()
);
// There is a caret for the 3rd character at the 3rd position, even though
// the first 2 characters are represented with 1 glyph.
assert_eq!(
line.caret_position_for_index(3),
line.caret_positions[3].position_in_line
);
// Likewise for the second 𐍈, even though it (and the previous one) take
// multiple code points.
assert_eq!(
line.caret_position_for_index(15),
line.caret_positions[15].position_in_line
);
// This tests hit-testing on a regular character.
assert_eq!(line.caret_index_for_x(0.), Some(0));
assert_eq!(line.caret_index_for_x(20.), Some(1));
// This tests hit-testing within a ligature.
assert_eq!(line.caret_index_for_x(260.), Some(25));
// This tests rounding up to the next character.
assert_eq!(line.caret_index_for_x(268.), Some(26));
// This tests a few random positions within the bound and before the last character.
let last_caret_pos = line
.caret_positions
.last()
.map_or(0., |p| p.position_in_line);
// The bounded and unbounded method should return the same result.
for _ in 0..5 {
let pos: f32 = random();
let index = line.caret_index_for_x(pos * last_caret_pos);
assert_eq!(
index,
Some(line.caret_index_for_x_unbounded(pos * last_caret_pos))
);
}
// This tests that the unbounded method returns the first index for out-of-bound position to the left
assert_eq!(line.caret_index_for_x_unbounded(-1.), line.first_index());
// The bounded method should return `None`
assert_eq!(line.caret_index_for_x(-1.), None);
// This tests that the unbounded method returns the end index for out-of-bound position to the right
assert_eq!(
line.caret_index_for_x_unbounded(line.width + 0.1),
line.end_index()
);
assert_eq!(line.caret_index_for_x(line.width + 0.1), None);
// This tests that the unbounded method returns the correct index either before or after the last glyph
assert_eq!(
line.caret_index_for_x_unbounded(0.9 * last_caret_pos + 0.1 * line.width),
line.last_index()
);
assert_eq!(
line.caret_index_for_x_unbounded(0.1 * last_caret_pos + 0.9 * line.width),
line.end_index()
);
// The bounded method should always just return the last index
assert_eq!(
line.caret_index_for_x(0.9 * last_caret_pos + 0.1 * line.width),
Some(line.last_index())
);
assert_eq!(
line.caret_index_for_x(0.1 * last_caret_pos + 0.9 * line.width),
Some(line.last_index())
);
Ok(())
}
/// The emojis in this test use font fallback, which means it won't behave
/// consistently across platforms.
#[test]
fn test_emoji_caret_positions() -> Result<()> {
let (font_db, font_family) = init_fonts();
// We're using these emoji specifically because they're represented as multiple
// combined characters.
let text = "👨‍👧‍👧🇨🇦";
let line = font_db.text_layout_system().layout_line(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(
0..12,
StyleAndFont::new(font_family, Properties::default(), TextStyle::new()),
)],
10000.0,
ClipConfig::default(),
);
// We want the leading edge for caret positions, so the first one is at the
// start of the line.
assert_eq!(line.caret_positions[0].position_in_line, 0.0);
assert_eq!(
collect_line_caret_position_starts(&line),
// CoreText gives us one caret position per visible character.
// Each emoji is multiple characters, but one grapheme and therefore one
// caret position.
vec![0, 5]
);
// The first character is within the first emoji, so its caret position is
// at the start of the line.
assert_eq!(line.caret_position_for_index(0), 0.0);
// Likewise, the start of the next emoji returns its start position.
assert_eq!(
line.caret_position_for_index(5),
line.caret_positions[1].position_in_line
);
// Subsequent positions within the emoji also resolve to its starting offset.
assert_eq!(
line.caret_position_for_index(6),
line.caret_positions[1].position_in_line
);
// Past the end of the last emoji, we clamp to the end of the line.
assert_eq!(line.caret_position_for_index(7), line.width);
Ok(())
}
/// The RTL text and emoji in this test use font fallback, which means
/// this test won't behave consistently across platforms.
#[test]
fn test_bidi_caret_positions() -> Result<()> {
let (font_db, font_family) = init_fonts();
let text = "a שָׁלוֹם 🇨🇦 test";
let line = font_db.text_layout_system().layout_line(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(
0..text.encode_utf16().count(),
StyleAndFont::new(font_family, Properties::default(), TextStyle::new()),
)],
10000.0,
ClipConfig::default(),
);
// Caret positions should account for diacritics in the Hebrew text, as well
// as the 🇨🇦 emoji consisting of multiple characters. In addition, they should
// be sorted by display order.
assert_eq!(
collect_line_caret_position_pairs(&line),
vec![
// "a "
(0, 0),
(1, 1),
// שָׁלוֹם
(8, 8),
(6, 7),
(5, 5),
(2, 4),
// " "
(9, 9),
// 🇨🇦
(10, 11),
// " test"
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16)
]
);
Ok(())
}
#[test]
fn test_layout_text_ligatures() -> Result<()> {
let mut font_db = FontDB::new();
let zapfino = font_db.load_from_system("Zapfino")?;
let menlo = font_db.load_from_system("Menlo")?;
let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
let frame = layout_text(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[
(
0..9,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
(
9..22,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
22..text.encode_utf16().count(),
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
],
&font_db,
125., /* max_width */
f32::MAX, /* max_height */
Default::default(),
None,
);
// The text should contain multiple lines since it can't fit in 125 pixels on the first
// line.
assert_eq!(frame.lines().len(), 4);
// The text should be wrapped over 4 lines and look like this:
// "This is
// m𐍈re or
// less,
// Zapfino!𐍈"
assert_eq!(
collect_glyph_indices(&frame),
vec![
vec![0, 2, 4, 5, 7, 8],
vec![9, 10, 11, 12, 13, 14, 15, 16],
vec![17, 18, 19, 20, 21, 22,],
vec![23, 30, 31]
]
);
Ok(())
}
#[test]
fn test_layout_text_first_line_head_indent_ligatures() -> Result<()> {
// Similar test to above, except we add in a left head indent (with reduced max width)!
let mut font_db = FontDB::new();
let zapfino = font_db.load_from_system("Zapfino")?;
let menlo = font_db.load_from_system("Menlo")?;
let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
let frame = layout_text(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[
(
0..9,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
(
9..22,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
22..text.encode_utf16().count(),
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
],
&font_db,
80., /* max_width */
f32::MAX, /* max_height */
Default::default(),
Some(50.), /* first_line_head_indent */
);
// The text should contain multiple lines since we have a 50px left head indent on the first
// line and then each line only has 80px.
assert_eq!(frame.lines().len(), 6);
assert_eq!(
collect_glyph_indices(&frame),
vec![
vec![0], // left head indent means we don't have much content laid out on this line.
vec![1, 2, 4, 5, 7, 8],
vec![9, 10, 11, 12, 13, 14, 15, 16],
vec![17, 18, 19, 20, 21, 22],
vec![23, 24, 25, 27, 28],
vec![29, 30, 31],
]
);
Ok(())
}
#[test]
fn test_tab_stops_affect_line_width() -> Result<()> {
let mut font_db = FontDB::new();
let menlo = font_db.load_from_system("Menlo")?;
let font_size = 13.0;
let tab_size = 4;
let font_id = font_db.select_font(menlo, Properties::default());
let tab_interval = (font_db
.space_advance_width(font_id, font_size)
.expect("space width should be measurable")
* tab_size as f64) as f32;
let style = StyleAndFont::new(menlo, Properties::default(), TextStyle::new());
let strings = "strings";
let tabbed = "\t\t\tstrings";
let strings_line = layout_line(
strings,
LineStyle {
font_size,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(0..strings.chars().count(), style)],
&font_db,
ClipConfig::default(),
);
let tabbed_line = layout_line(
tabbed,
LineStyle {
font_size,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: Some(tab_size),
},
&[(0..tabbed.chars().count(), style)],
&font_db,
ClipConfig::default(),
);
// Each tab advances to the next stop.
let expected_width = (tab_interval * 3.0) + strings_line.width;
let error = (tabbed_line.width - expected_width).abs();
assert!(
error < 1.0,
"expected tabbed width ~{}, got {} (error {})",
expected_width,
tabbed_line.width,
error
);
Ok(())
}
#[test]
fn test_tab_stops_do_not_drift_over_long_runs() -> Result<()> {
let mut font_db = FontDB::new();
let menlo = font_db.load_from_system("Menlo")?;
let font_size = 13.0;
let tab_size = 4;
let font_id = font_db.select_font(menlo, Properties::default());
let tab_interval = (font_db
.space_advance_width(font_id, font_size)
.expect("space width should be measurable")
* tab_size as f64) as f32;
let style = StyleAndFont::new(menlo, Properties::default(), TextStyle::new());
let strings = "strings";
let strings_line = layout_line(
strings,
LineStyle {
font_size,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(0..strings.chars().count(), style)],
&font_db,
ClipConfig::default(),
);
let tab_count = 100;
let tabbed = format!("{}{}", "\t".repeat(tab_count), strings);
let tabbed_line = layout_line(
&tabbed,
LineStyle {
font_size,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: Some(tab_size),
},
&[(0..tabbed.chars().count(), style)],
&font_db,
ClipConfig::default(),
);
let expected_width = (tab_interval * tab_count as f32) + strings_line.width;
let error = (tabbed_line.width - expected_width).abs();
assert!(
error < 1.0,
"expected tabbed width ~{}, got {} (error {})",
expected_width,
tabbed_line.width,
error
);
Ok(())
}
#[test]
fn test_layout_text_large_first_line_head_indent_ligatures() -> Result<()> {
// Similar test to above, except we have a large first line head indent which goes beyond the
// max_width of the first line! We expect an empty line at the start to account for this (post-layout).
let mut font_db = FontDB::new();
let zapfino = font_db.load_from_system("Zapfino")?;
let menlo = font_db.load_from_system("Menlo")?;
let text = "This is, some text, in Zapfino being laid out!";
let frame = layout_text(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[
(
0..9,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
(
9..22,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
22..text.encode_utf16().count(),
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
],
&font_db,
80., /* max_width */
f32::MAX, /* max_height */
Default::default(),
Some(80.), /* first_line_head_indent */
);
// We expect 1 empty line at the start and then 7 lines of content.
assert_eq!(frame.lines().len(), 8);
assert_eq!(
collect_glyph_indices(&frame),
vec![
vec![], // first line head indent takes up entire line!
vec![0, 2, 4],
vec![5, 7, 8, 9, 10, 11, 12, 13],
vec![14, 15, 16, 17, 18, 19, 20, 21, 22],
vec![23, 24, 25, 27, 28],
vec![29, 30, 31, 32, 33, 34, 35, 36],
vec![37, 38, 39, 40, 41],
vec![42, 44, 45],
]
);
Ok(())
}
#[test]
fn test_layout_text_last_line_clipped_ligatures() -> Result<()> {
let mut font_db = FontDB::new();
let zapfino = font_db.load_from_system("Zapfino")?;
let menlo = font_db.load_from_system("Menlo")?;
let text = "m𐍈re, Zapfino!ll𐍈, qqqq";
let max_width = 180.;
let frame = layout_text(
text,
LineStyle {
font_size: 16.0,
line_height_ratio: 1.2,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[
(
0..5,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
5..13,
StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
),
(
13..16,
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
(
16..text.encode_utf16().count(),
StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
),
],
&font_db,
max_width,
70., /* max_height */
Default::default(),
None,
);
// The text should only fit one line.
assert_eq!(frame.lines().len(), 1);
// The text is one line long and should be clipped like so: "m𐍈re, Zapfin𐍈!l...".
// Note that the contents are not clipped, but the width being greater than the max width
// indicates that when we paint, this is clipped.
assert_eq!(
collect_glyph_indices(&frame),
vec![[0, 1, 2, 3, 4, 5, 6, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22],]
);
let first_line = frame.lines().first().unwrap();
assert!(first_line.width > max_width);
Ok(())
}
+54 -28
View File
@@ -1,32 +1,57 @@
use std::slice;
use std::str::Utf8Error;
use core_foundation::base::TCFType;
use core_graphics::base::CGFloat;
use core_graphics::color::CGColor;
use core_graphics::sys::CGColorRef;
use objc::runtime::Object;
use objc::{msg_send, sel, sel_impl};
use pathfinder_color::ColorU;
use std::os::raw::c_char;
use std::slice;
use std::str::Utf8Error;
use cocoa::appkit::{
NSDeleteFunctionKey as DELETE_KEY, NSDownArrowFunctionKey as ARROW_DOWN_KEY,
NSEndFunctionKey as END_KEY, NSF10FunctionKey as F10_FUNCTION_KEY,
NSF11FunctionKey as F11_FUNCTION_KEY, NSF12FunctionKey as F12_FUNCTION_KEY,
NSF13FunctionKey as F13_FUNCTION_KEY, NSF14FunctionKey as F14_FUNCTION_KEY,
NSF15FunctionKey as F15_FUNCTION_KEY, NSF16FunctionKey as F16_FUNCTION_KEY,
NSF17FunctionKey as F17_FUNCTION_KEY, NSF18FunctionKey as F18_FUNCTION_KEY,
NSF19FunctionKey as F19_FUNCTION_KEY, NSF1FunctionKey as F1_FUNCTION_KEY,
NSF20FunctionKey as F20_FUNCTION_KEY, NSF2FunctionKey as F2_FUNCTION_KEY,
NSF3FunctionKey as F3_FUNCTION_KEY, NSF4FunctionKey as F4_FUNCTION_KEY,
NSF5FunctionKey as F5_FUNCTION_KEY, NSF6FunctionKey as F6_FUNCTION_KEY,
NSF7FunctionKey as F7_FUNCTION_KEY, NSF8FunctionKey as F8_FUNCTION_KEY,
NSF9FunctionKey as F9_FUNCTION_KEY, NSHelpFunctionKey as HELP_KEY,
NSHomeFunctionKey as HOME_KEY, NSInsertFunctionKey as INSERT_KEY,
NSLeftArrowFunctionKey as ARROW_LEFT_KEY, NSPageDownFunctionKey as PAGE_DOWN_KEY,
NSPageUpFunctionKey as PAGE_UP_KEY, NSRightArrowFunctionKey as ARROW_RIGHT_KEY,
NSUpArrowFunctionKey as ARROW_UP_KEY,
use objc2_app_kit::{
NSDeleteFunctionKey, NSDownArrowFunctionKey, NSEndFunctionKey, NSF10FunctionKey,
NSF11FunctionKey, NSF12FunctionKey, NSF13FunctionKey, NSF14FunctionKey, NSF15FunctionKey,
NSF16FunctionKey, NSF17FunctionKey, NSF18FunctionKey, NSF19FunctionKey, NSF1FunctionKey,
NSF20FunctionKey, NSF2FunctionKey, NSF3FunctionKey, NSF4FunctionKey, NSF5FunctionKey,
NSF6FunctionKey, NSF7FunctionKey, NSF8FunctionKey, NSF9FunctionKey, NSHelpFunctionKey,
NSHomeFunctionKey, NSInsertFunctionKey, NSLeftArrowFunctionKey, NSPageDownFunctionKey,
NSPageUpFunctionKey, NSRightArrowFunctionKey, NSUpArrowFunctionKey,
};
use objc2_foundation::{NSString, NSUTF8StringEncoding};
use pathfinder_color::ColorU;
// AppKit exposes the function-key Unicode values as `c_uint`, but the lookup
// below compares them against a `u16` code unit. Narrow each one to `u16`;
// every value lies in the 0xF700..=0xF8FF private-use range and fits losslessly.
const ARROW_UP_KEY: u16 = NSUpArrowFunctionKey as u16;
const ARROW_DOWN_KEY: u16 = NSDownArrowFunctionKey as u16;
const ARROW_LEFT_KEY: u16 = NSLeftArrowFunctionKey as u16;
const ARROW_RIGHT_KEY: u16 = NSRightArrowFunctionKey as u16;
const HOME_KEY: u16 = NSHomeFunctionKey as u16;
const END_KEY: u16 = NSEndFunctionKey as u16;
const PAGE_UP_KEY: u16 = NSPageUpFunctionKey as u16;
const PAGE_DOWN_KEY: u16 = NSPageDownFunctionKey as u16;
const HELP_KEY: u16 = NSHelpFunctionKey as u16;
const INSERT_KEY: u16 = NSInsertFunctionKey as u16;
const DELETE_KEY: u16 = NSDeleteFunctionKey as u16;
const F1_FUNCTION_KEY: u16 = NSF1FunctionKey as u16;
const F2_FUNCTION_KEY: u16 = NSF2FunctionKey as u16;
const F3_FUNCTION_KEY: u16 = NSF3FunctionKey as u16;
const F4_FUNCTION_KEY: u16 = NSF4FunctionKey as u16;
const F5_FUNCTION_KEY: u16 = NSF5FunctionKey as u16;
const F6_FUNCTION_KEY: u16 = NSF6FunctionKey as u16;
const F7_FUNCTION_KEY: u16 = NSF7FunctionKey as u16;
const F8_FUNCTION_KEY: u16 = NSF8FunctionKey as u16;
const F9_FUNCTION_KEY: u16 = NSF9FunctionKey as u16;
const F10_FUNCTION_KEY: u16 = NSF10FunctionKey as u16;
const F11_FUNCTION_KEY: u16 = NSF11FunctionKey as u16;
const F12_FUNCTION_KEY: u16 = NSF12FunctionKey as u16;
const F13_FUNCTION_KEY: u16 = NSF13FunctionKey as u16;
const F14_FUNCTION_KEY: u16 = NSF14FunctionKey as u16;
const F15_FUNCTION_KEY: u16 = NSF15FunctionKey as u16;
const F16_FUNCTION_KEY: u16 = NSF16FunctionKey as u16;
const F17_FUNCTION_KEY: u16 = NSF17FunctionKey as u16;
const F18_FUNCTION_KEY: u16 = NSF18FunctionKey as u16;
const F19_FUNCTION_KEY: u16 = NSF19FunctionKey as u16;
const F20_FUNCTION_KEY: u16 = NSF20FunctionKey as u16;
const BACKSPACE_KEY: u16 = 0x7f;
const ENTER_KEY: u16 = 0x0d;
@@ -88,11 +113,12 @@ pub fn unicode_char_to_key(char: u16) -> Option<&'static str> {
///
/// This code is only unsafe since it requires interfacing with platform code.
pub unsafe fn nsstring_as_str<'a>(nsstring: *const Object) -> Result<&'a str, Utf8Error> {
const UTF8_ENCODING: usize = 4;
let cstr: *const c_char = msg_send![nsstring, UTF8String];
let len: usize = msg_send![nsstring, lengthOfBytesUsingEncoding: UTF8_ENCODING];
std::str::from_utf8(slice::from_raw_parts(cstr as *const u8, len))
// The caller guarantees `nsstring` points at a live Objective-C string, so
// reinterpret it as an `NSString` for typed access to its UTF-8 bytes.
let nsstring = &*nsstring.cast::<NSString>();
let cstr = nsstring.UTF8String();
let len = nsstring.lengthOfBytesUsingEncoding(NSUTF8StringEncoding);
std::str::from_utf8(slice::from_raw_parts(cstr.cast::<u8>(), len))
}
pub fn color_u_to_cg_color(color: ColorU) -> CGColor {
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,5 +1,5 @@
pub mod app;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
pub mod linux;
#[cfg(target_os = "macos")]
pub mod mac;
@@ -14,7 +14,7 @@ pub mod current {
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
pub use super::wasm::*;
} else if #[cfg(target_os = "linux")] {
} else if #[cfg(any(target_os = "linux", target_os = "freebsd"))] {
pub use super::linux::*;
} else if #[cfg(target_os = "macos")] {
pub use super::mac::*;
@@ -26,9 +26,8 @@ pub mod current {
}
}
pub use galaxyui_core::platform::*;
pub use app::AppBuilder;
pub use galaxyui_core::platform::*;
/// Returns whether the current device is a mobile device with touch input.
///
@@ -16,9 +16,10 @@
//! on the canvas to reposition the cursor. This is a known limitation of the hidden input
//! approach.
use gloo::events::EventListener;
use std::cell::RefCell;
use std::rc::Rc;
use gloo::events::EventListener;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{HtmlInputElement, InputEvent, KeyboardEvent};
+57 -10
View File
@@ -3,22 +3,20 @@ pub(crate) mod mobile_detection;
pub(crate) mod soft_keyboard;
use gloo::events::{EventListener, EventListenerOptions};
use wasm_bindgen::{JsCast, UnwrapThrowExt};
use crate::{keymap::Keystroke, windowing::winit::app::CustomEvent};
pub use hidden_input::{HiddenInput, HiddenInputEvent, InputCallback};
pub use mobile_detection::{is_mobile_device, is_mobile_user_agent};
pub use soft_keyboard::{SoftKeyboardInput, SoftKeyboardManager, SoftKeyboardState};
// Re-export the functions from the core crate.
pub use galaxyui_core::platform::wasm::*;
use wasm_bindgen::{JsCast, UnwrapThrowExt};
use super::KEYS_TO_IGNORE;
use crate::keymap::Keystroke;
use crate::platform::OperatingSystem;
// Re-export a couple winit types and modules as the concrete implementations
// for the wasm platform.
pub use crate::windowing::winit::app::App;
// Re-export the functions from the core crate.
pub use galaxyui_core::platform::wasm::*;
use super::KEYS_TO_IGNORE;
use crate::windowing::winit::app::CustomEvent;
fn get_visual_viewport_dimensions() -> Option<(f32, f32)> {
let window = gloo::utils::window();
@@ -111,7 +109,8 @@ pub(crate) fn add_prevent_default_listener(canvas: &web_sys::HtmlCanvasElement)
key: event.key(),
};
let allow_default_event = KEYS_TO_IGNORE.contains(&keystroke);
let allow_default_event =
KEYS_TO_IGNORE.contains(&keystroke) || is_browser_shortcut(event);
if !allow_default_event {
event.prevent_default();
}
@@ -121,6 +120,54 @@ pub(crate) fn add_prevent_default_listener(canvas: &web_sys::HtmlCanvasElement)
}
}
fn is_browser_shortcut(event: &web_sys::KeyboardEvent) -> bool {
let key = event.key().to_ascii_lowercase();
if !event.ctrl_key() && !event.alt_key() && !event.meta_key() {
return key == "f5";
}
if is_browser_history_shortcut(event, &key) {
return true;
}
if !has_browser_primary_modifier(event) || event.alt_key() {
return false;
}
// These are standard browser chrome shortcuts that should keep working in WASM sessions.
// This allowlist uses the browser's primary shortcut modifier for the current OS
// (Cmd on macOS, Ctrl elsewhere), so the behavior is not macOS-specific.
let browser_primary_shortcut_keys = [
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "=", "+", "[", "]", "l", "n", "r",
"t", "tab", "w", "pageup", "pagedown",
];
let browser_shifted_primary_shortcut_keys = [
"-", "=", "+", "[", "]", "{", "}", "n", "r", "t", "tab", "w", "pageup", "pagedown",
];
if event.shift_key() {
browser_shifted_primary_shortcut_keys.contains(&key.as_str())
} else {
browser_primary_shortcut_keys.contains(&key.as_str())
}
}
fn has_browser_primary_modifier(event: &web_sys::KeyboardEvent) -> bool {
match OperatingSystem::get() {
OperatingSystem::Mac => event.meta_key() && !event.ctrl_key(),
_ => event.ctrl_key() && !event.meta_key(),
}
}
fn is_browser_history_shortcut(event: &web_sys::KeyboardEvent, key: &str) -> bool {
if event.shift_key() || event.ctrl_key() || event.meta_key() {
return false;
}
event.alt_key() && matches!(key, "arrowleft" | "arrowright")
}
pub(crate) fn add_paste_listener(event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>) {
EventListener::new(&gloo::utils::document(), "paste", move |event| {
let event = event.dyn_ref::<web_sys::ClipboardEvent>().unwrap_throw();
+2 -1
View File
@@ -1,6 +1,7 @@
use itertools::Itertools as _;
use std::os::windows::ffi::OsStrExt as _;
use itertools::Itertools as _;
// Re-export a couple winit types and modules as the concrete implementations
// for Windows.
pub use crate::windowing::winit::app::App;